diff --git a/.agents/skills/gpui-test/SKILL.md b/.agents/skills/gpui-test/SKILL.md new file mode 100644 index 00000000000..3d92659a552 --- /dev/null +++ b/.agents/skills/gpui-test/SKILL.md @@ -0,0 +1,160 @@ +--- +name: gpui-test +description: >- + Use when writing, debugging, or reproducing GPUI tests in Zed, including + gpui::test arguments, TestAppContext parameters, scheduler seeds, + ITERATIONS/SEED reproduction, parking failures, and pending task traces. +--- + +# GPUI Test Debugging + +Use this skill when the user asks about `#[gpui::test]`, GPUI test seeds or iterations, deterministic scheduler failures, parking/pending task failures, or how to reproduce a flaky GPUI test. + +## What `#[gpui::test]` does + +`#[gpui::test]` expands to a normal Rust `#[test]`, so it runs under standard Rust test runners such as `cargo test` and `cargo nextest`. + +It wraps the body in GPUI's deterministic test dispatcher/scheduler and can run the same test multiple times with different seeds. The seed controls scheduler task interleavings and any `StdRng` argument injected into the test. + +The macro supports both synchronous and asynchronous tests. + +### Supported function arguments + +The macro recognizes arguments by type name: + +| Test kind | Supported arguments | +| --- | --- | +| Sync and async | `&TestAppContext`, `&mut TestAppContext`, `StdRng` | +| Async only | `BackgroundExecutor` | +| Sync only | `&App`, `&mut App` | + +`StdRng` is seeded from the current GPUI test seed, and `BackgroundExecutor` is backed by the same deterministic test dispatcher. + +### Attribute arguments + +Use these forms on `#[gpui::test(arguments)]`: + +- No arguments: runs once with seed `0`, unless `SEED` is set. +- `seed = N`: adds a single explicit seed. +- `seeds(...)`: adds multiple explicit seeds. +- `iterations = N`: runs sequential seeds starting at `0` by default. +- `retries = N`: retries a failing run up to `N` times before surfacing the failure. +- `on_failure = "path::to::function"`: calls the function after final failure, before resuming the panic. +- `iterations` can be combined with explicit `seed` / `seeds`; explicit seeds are appended to the `0..iterations` range. +- If the `SEED` environment variable is set, it takes precedence over explicit seeds. +- With `SEED=N` and `ITERATIONS=M` or `iterations = M`, the harness runs seeds `N..N+M`. + +## Environment variables + +### GPUI test macro / scheduler execution + +- `SEED=` — chooses the scheduler seed. Use this to reproduce a failure printed as `failing seed: N`. It also seeds injected `StdRng` arguments. For `#[gpui::property_test]`, it controls the scheduler seed and GPUI applies it to the proptest config for deterministic case generation. +- `ITERATIONS=` — overrides the `iterations = ...` value at runtime. Use to sweep many seeds without editing the test. +- `PENDING_TRACES=1` or `PENDING_TRACES=true` — captures and prints pending task traces when the test scheduler panics with `Parking forbidden`. Use this when `run_until_parked()` or teardown reports pending work. +- `GPUI_RUN_UNTIL_PARKED_LOG=1` — logs when `allow_parking()` is enabled. Use to find tests that explicitly permit parking/pending work. +- `DEBUG_SCHEDULER=1` — prints scheduler clock/timer debugging from `scheduler::TestScheduler`. + +### Lower-level scheduler tests + +- `SCHEDULER_NONINTERACTIVE=1` — suppresses interactive seed progress output in `scheduler::TestScheduler::many`. This does not affect the `#[gpui::test]` harness path. + +### General Rust test debugging vars often useful with GPUI tests + +- `RUST_BACKTRACE=1` or `RUST_BACKTRACE=full` — show panic backtraces. +- `RUST_LOG=` — enable logs when the test initializes logging. +- `ZED_HEADLESS=1` — forces GPUI platform guessing toward headless mode; useful for tests that otherwise interact with platform/window setup. + +Prefer env vars over editing the test when narrowing a reproduction. + +## Reproducing a specific GPUI test + +1. Identify the crate/package and test name. + +2. Run the narrowest test filter first, skip to 3. if a failing seed is known. + + ```sh + cargo -q test -p -- --nocapture + ``` + +3. If the failure mentions a seed, rerun exactly that seed. + + ```sh + SEED= cargo -q test -p -- --nocapture + ``` + +4. If the failure is flaky and no seed is known, sweep seeds. + + ```sh + ITERATIONS=100 cargo -q test -p -- --nocapture + ``` + + When the harness prints `failing seed: `, switch to `SEED=` for all future debugging. + +5. If the failure is `Parking forbidden`, rerun with pending traces. + + ```sh + PENDING_TRACES=1 cargo -q test -p -- --nocapture + ``` + + If a failing seed was printed or is already known, include it too: + + ```sh + SEED= PENDING_TRACES=1 cargo -q test -p -- --nocapture + ``` + + Inspect the pending traces for a task that was spawned but not awaited, detached, completed, or intentionally allowed to park. + +6. If timing or timer advancement is involved, prefer GPUI scheduler timers in tests: + + ```rust + cx.background_executor().timer(duration).await; + ``` + + Avoid `smol::Timer::after(...)` in GPUI tests that rely on `run_until_parked()`, because GPUI's scheduler may not track it. + +7. Minimize the reproduction. + - Keep the failing `SEED` fixed. + - Reduce `ITERATIONS` to `1` or remove it once a seed is known. + - Remove unrelated setup only after confirming the same seed still fails. + - Preserve scheduler-sensitive awaits/yields; removing them can mask the bug. + - If randomness is test-controlled via `StdRng`, log or assert the generated scenario after fixing the scheduler seed. + +8. Validate the fix. + - Run the fixed seed. + - Run a modest seed sweep, e.g. `ITERATIONS=20`, if the failure was scheduler-sensitive. + - Run the relevant crate's test filter or broader suite if the touched code has shared behavior. + +## Common diagnosis patterns + +### Seed-dependent assertion failure + +Likely caused by a scheduler interleaving or by `StdRng`-driven test data. Fix `SEED`, reproduce, and inspect which task or generated scenario differs. + +### `Parking forbidden` + +Usually means a foreground/background task is still pending when the scheduler expected the test to make progress or finish. Look for: + +- A task that should be awaited but was dropped. +- A task that should be detached with error logging. +- A timer or receiver that is waiting forever. +- A missing `cx.run_until_parked()` after triggering async work in a test. +- A missing `cx.advance_clock(...)` to wait for debounced work in a test. +- Use of non-GPUI timers or executors that the test scheduler cannot drive. + +Rerun with `PENDING_TRACES=1` before changing code. + +### Non-determinism / wrong thread + +The scheduler can report activity from an unexpected thread. Look for work escaping GPUI's foreground/background executors, direct thread spawns, or external async runtimes not controlled by the test dispatcher. + +### Tests pass alone but fail in sweeps + +Use the failing seed from sweep output. Avoid assuming test order unless the runner is explicitly serial. Check globals, leaked entities/tasks, and state not reset by test initialization. + +## Writing GPUI tests + +- Prefer `#[gpui::test]` for tests that need `TestAppContext`, deterministic executors, fake time, or scheduler interleaving coverage. +- Add `iterations = N` when the test is intentionally checking interleavings. +- Use `StdRng` as a test argument when randomized test data should follow the same seed as the scheduler. +- Use `cx.background_executor().timer(duration).await` for delays/timeouts in GPUI tests. +- Do not add or increase `retries` while fixing a test unless the user explicitly asks or the test already documents why probabilistic tolerance is intentional. Retries can mask the failure instead of fixing it. diff --git a/.agents/skills/zed-cherry-pick/SKILL.md b/.agents/skills/zed-cherry-pick/SKILL.md new file mode 100644 index 00000000000..0f0cd02b929 --- /dev/null +++ b/.agents/skills/zed-cherry-pick/SKILL.md @@ -0,0 +1,175 @@ +--- +name: zed-cherry-pick +description: Cherry-pick one or more merged PRs and/or commits into Zed's `preview` or `stable` release branch. Use this whenever the user mentions cherry-picking to preview/stable, a failed cherry-pick run, or wants to manually port fix(es) into a release branch. +--- + +# Zed Cherry-Pick + +Zed ships from two long-lived release branches that live on `origin`: + +- `preview` channel → branch like `v1.4.x` +- `stable` channel → branch like `v1.3.x` + +The version numbers change with each release. **Never hardcode them — always discover the current mapping** (see [Finding the target branch](#finding-the-target-branch)). + +A merged PR on `main` gets ported to a release branch by `script/cherry-pick`, normally driven by the `cherry_pick` GitHub Actions workflow. When that workflow fails (almost always a merge conflict), use this skill to finish the job locally and open the cherry-pick PR by hand. + +## When to use + +Use this when the user asks to cherry-pick one or more commits and/or Pull Requests (by number or URL) to `preview` or `stable`. +Optionally, the user may specify whether to resolve merge conflicts; if unspecified, attempt the cherry-pick, and then if there are merge conflicts in practice, stop and inform the user that there are merge conflicts and offer to resolve them. (Users may prefer to resolve the merge conflicts themselves before continuing.) + +## The script you're emulating + +The canonical procedure lives in `script/cherry-pick` and the `cherry_pick` GitHub Actions workflow. Read the script first if anything looks off — your local steps must produce the same branch name, PR title, and PR body it would. + +Signature: `script/cherry-pick ` + +- `` is the release branch (e.g. `v1.4.x`), **not** the channel name. +- `` is `preview` or `stable`, used only for display text in the PR title/body. + +It creates a local branch named `cherry-pick--` (the short SHA is the first 8 chars of the commit), force-pushes it to `origin`, and opens a PR. + +## Finding the target branch + +The channel→branch mapping changes every release. Find the current one by inspecting the most recent `cherry_pick` workflow runs: + +``` +gh run list --workflow=cherry_pick.yml --limit 30 --json displayTitle,databaseId +# pick a recent run for the channel you want, then: +gh run view --log 2>&1 | grep -E "BRANCH:|CHANNEL:" +``` + +A successful run prints both `BRANCH:` and `CHANNEL:` env vars; that's your mapping. + +## Procedure + +### 1. Gather context + +You need three things: the **merge commit SHA**, the **target branch**, and the **channel name**. + +If the user requested multiple PRs and/or commits, gather the metadata for all of them first and cherry-pick them in the order they landed on `main`, oldest to newest. For PRs, order by `mergedAt`; for raw commits, use their order on `main` when available, otherwise commit date. This tends to reduce avoidable conflicts because later changes may depend on earlier ones, but it does not guarantee a conflict-free cherry-pick when the release branch has diverged. + +``` +gh pr view --json title,number,mergeCommit,mergedAt,url +``` + +If the user said the workflow failed, fetch its log to see exactly which command failed and which file conflicted: + +``` +gh run list --workflow=cherry_pick.yml --limit 10 --json databaseId,displayTitle,status,conclusion +gh run view --log-failed +``` + +The failed-run log also confirms the `BRANCH` and `COMMIT` the workflow used — handy if there's any ambiguity. + +### 2. Reproduce the script's setup locally + +The repository may be a worktree (check `.git` — if it's a file, you're in a worktree pointing at a shared gitdir). That's fine; just operate normally. + +``` +git --no-pager fetch origin +git checkout --force origin/ -B cherry-pick-- +git cherry-pick +``` + +The branch name **must** match `cherry-pick--` exactly (script convention; reviewers and tooling expect it). + +### 3. Check for missing prerequisite cherry-picks + +If the cherry-pick conflicts, do not immediately resolve the conflicts manually. + +First determine whether the conflict is likely caused by other PRs or commits that are already on `main` but missing from the release branch. If so, point out those candidate prerequisite PRs/commits to the user, including PR links, and offer to either resolve the conflicts manually or let the user run the GitHub cherry-pick workflow for those commits first. + +If the user wants to run the workflow for the missing prerequisites, stop here. This often keeps cherry-picks clean and eligible for automatic approval. + +Only resolve conflicts manually if: +- no likely missing prerequisites are found, or +- the user chooses manual conflict resolution instead of cherry-picking the prerequisites first. + +### 4. Resolve the conflicts manually + +Do this only after checking for missing prerequisite cherry-picks. + +- Inspect every conflicted file with `grep -n '<<<<<<<\\|>>>>>>>\\|=======' ` to find the markers. +- Conflicts are usually `diff3` style with three sections: HEAD (release branch), `||||||| parent of ` (merge base on `main`), and the incoming change. +- Read the **original commit** (`git --no-pager show -- `) to understand the author's intent, then pick the resolution that produces the equivalent end state on the release branch. +- Don't grab unrelated changes from `main` that happen to surround the conflict — keep the cherry-pick minimal. + +### 5. Validate + +Always build and (if reasonable) test the affected crate(s) before continuing the cherry-pick. + +``` +cargo check -p +cargo test -p +``` + +If validation fails, fix the resolution — do **not** continue with a broken build. If you can't reach a clean state, abort with `git cherry-pick --abort` and report back to the user. + +### 6. Finish the cherry-pick + +`git cherry-pick --continue` opens an editor by default. Prevent that: + +``` +git add +GIT_EDITOR=true git cherry-pick --continue +``` + +This preserves the original commit message verbatim, which is what the script does. + +### 7. Push and open the PR + +``` +git push origin -f cherry-pick-- +``` + +Then create the PR with the **exact** title and body format `script/cherry-pick` uses, so it's indistinguishable from an automated one. + +**Title:** + +``` + (cherry-pick to ) +``` + +The original commit subject already ends in ` (#)`; keep it. + +**Body** (when the original commit title ends in `(#)`, which is the normal case): + +``` +Cherry-pick of # to + +---- + +``` + +Create it with `gh pr create`, writing the body to a temp file to keep formatting intact: + +``` +git --no-pager log -1 --pretty=format:"%b" > /tmp/cp-body-tail.md +printf 'Cherry-pick of #%s to %s\n\n----\n' | cat - /tmp/cp-body-tail.md > /tmp/cp-body.md +gh pr create --base --head cherry-pick-- \\ + --title " (cherry-pick to )" \\ + --body-file /tmp/cp-body.md +``` + +Do **not** add a `Release Notes:` section — the original commit body already has one (or already says `N/A`), and you don't want it duplicated. + +## Final report to the user + +Tell the user: +- The new PR URL. +- A one-line summary of the conflict and how you resolved it. +- What validation you ran (commands + result). +- That their local branch is now `cherry-pick--`, in case they want you to switch back. + +## Gotchas + +- **`--no-pager` and `GIT_EDITOR=true`**: required for non-interactive git in this environment. Forgetting `GIT_EDITOR=true` on `cherry-pick --continue` hangs the terminal. +- **Worktree index lock**: if a previous git command was interrupted, you may see `index.lock` errors. The lock lives at `/index.lock` where `` is what `cat .git` points to (for a worktree). Remove it only if you're sure no git process is running. +- **Don't expand the cherry-pick's scope**: when resolving conflicts, never pull in unrelated changes from `main` just because they sit next to the conflict region. The PR should be the smallest diff that reproduces the original commit's intent on the release branch. +- **Channel branches are not called `preview`/`stable`**: don't try to `git fetch origin preview`. Look up the actual `vX.Y.x` branch name first. + +## When Finished + +After everything is finished, the last thing to do is to provide a link to the opened pull request(s) for the cherry-pick(s). diff --git a/.github/CODEOWNERS.hold b/.github/CODEOWNERS.hold index c0dec880c71..0e6ab04228d 100644 --- a/.github/CODEOWNERS.hold +++ b/.github/CODEOWNERS.hold @@ -55,7 +55,6 @@ /crates/open_ai/ @zed-industries/ai-team /crates/open_router/ @zed-industries/ai-team /crates/prompt_store/ @zed-industries/ai-team -/crates/rules_library/ @zed-industries/ai-team # SUGGESTED: Review needed - based on Richard Feldman (2 commits) /crates/shell_command_parser/ @zed-industries/ai-team /crates/vercel/ @zed-industries/ai-team @@ -181,7 +180,6 @@ /crates/fs_benchmarks/ @zed-industries/infrastructure-team /crates/http_client/ @zed-industries/infrastructure-team /crates/http_client_tls/ @zed-industries/infrastructure-team -/crates/nc/ @zed-industries/infrastructure-team /crates/net/ @zed-industries/infrastructure-team /crates/paths/ @zed-industries/infrastructure-team /crates/release_channel/ @zed-industries/infrastructure-team diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml deleted file mode 100644 index 82a9e274d64..00000000000 --- a/.github/workflows/community_champion_auto_labeler.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Community Champion Auto Labeler - -on: - issues: - types: [opened] - pull_request_target: - types: [opened] - -jobs: - label_community_champion: - if: github.repository_owner == 'zed-industries' - runs-on: namespace-profile-2x4-ubuntu-2404 - steps: - - name: Check if author is a community champion and apply label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - env: - COMMUNITY_CHAMPIONS: | - 0x2CA - 5brian - 5herlocked - abdelq - afgomez - AidanV - akbxr - AlvaroParker - amtoaer - artemevsevev - bajrangCoder - bcomnes - Be-ing - blopker - bnjjj - bobbymannino - CharlesChen0823 - chbk - davewa - davidbarsky - ddoemonn - djsauble - errmayank - fantacell - fdncred - findrakecil - FloppyDisco - gko - huacnlee - imumesh18 - injust - jacobtread - jansol - jeffreyguenther - jenslys - jongretar - lemorage - lingyaochu - lnay - marcocondrache - marius851000 - mikebronner - ognevny - PKief - playdohface - RemcoSmitsDev - rgbkrk - romaninsh - rxptr - Simek - someone13574 - sourcefrog - suxiaoshao - Takk8IS - tartarughina - thedadams - tidely - timvermeulen - valentinegb - versecafe - vitallium - WhySoBad - ya7010 - Zertsov - with: - script: | - const communityChampions = process.env.COMMUNITY_CHAMPIONS - .split('\n') - .map(handle => handle.trim().toLowerCase()) - .filter(handle => handle.length > 0); - - let author; - if (context.eventName === 'issues') { - author = context.payload.issue.user.login; - } else if (context.eventName === 'pull_request_target') { - author = context.payload.pull_request.user.login; - } - - if (!author || !communityChampions.includes(author.toLowerCase())) { - return; - } - - const issueNumber = context.payload.issue?.number || context.payload.pull_request?.number; - - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['community champion'] - }); - - console.log(`Applied 'community champion' label to #${issueNumber} by ${author}`); - } catch (error) { - console.error(`Failed to apply label: ${error.message}`); - } diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 4757db43437..11a3a709022 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 + ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9 on: workflow_call: inputs: diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 4003f41c273..c3503590e60 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 + ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9 RUSTUP_TOOLCHAIN: stable CARGO_BUILD_TARGET: wasm32-wasip2 on: diff --git a/.github/workflows/nix_build.yml b/.github/workflows/nix_build.yml new file mode 100644 index 00000000000..f658634c06c --- /dev/null +++ b/.github/workflows/nix_build.yml @@ -0,0 +1,97 @@ +# Generated from xtask::workflows::nix_build +# Rebuild with `cargo xtask workflows`. +name: nix_build +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' +on: + pull_request: + types: + - labeled + - synchronize +jobs: + build_nix_linux_x86_64: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && (github.event.label.name == 'run-nix' || github.event.label.name == 'run-bundling')) || (github.event.action == 'synchronize' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || contains(github.event.pull_request.labels.*.name, 'run-bundling')))) + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + - name: steps::cache_nix_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 + with: + cache: nix + - name: nix_build::build_nix::install_nix + uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: nix_build::build_nix::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]* + - name: nix_build::build_nix::build + run: nix build .#default -L --accept-flake-config + timeout-minutes: 60 + continue-on-error: true + build_nix_mac_aarch64: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && (github.event.label.name == 'run-nix' || github.event.label.name == 'run-bundling')) || (github.event.action == 'synchronize' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || contains(github.event.pull_request.labels.*.name, 'run-bundling')))) + runs-on: namespace-profile-mac-large + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + - name: steps::cache_nix_store_macos + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 + with: + path: ~/nix-cache + - name: nix_build::build_nix::install_nix + uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: nix_build::build_nix::configure_local_nix_cache + run: | + mkdir -p ~/nix-cache + echo "extra-substituters = file://$HOME/nix-cache?priority=10" | sudo tee -a /etc/nix/nix.conf + echo "require-sigs = false" | sudo tee -a /etc/nix/nix.conf + sudo launchctl kickstart -k system/org.nixos.nix-daemon + - name: nix_build::build_nix::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]* + - name: nix_build::build_nix::build + run: nix build .#default -L --accept-flake-config + - name: nix_build::build_nix::export_to_local_nix_cache + if: always() + run: | + if [ -L result ]; then + echo "Copying build closure to local binary cache..." + nix copy --to "file://$HOME/nix-cache" ./result || echo "Warning: nix copy to local cache failed" + else + echo "No build result found, skipping cache export." + fi + timeout-minutes: 60 + continue-on-error: true +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true +defaults: + run: + shell: bash -euxo pipefail {0} diff --git a/.github/workflows/pr_issue_labeler.yml b/.github/workflows/pr_issue_labeler.yml new file mode 100644 index 00000000000..f9927f32252 --- /dev/null +++ b/.github/workflows/pr_issue_labeler.yml @@ -0,0 +1,247 @@ +# Labels pull requests by author: +# - 'community champion' for community champions +# - 'bot' for bot accounts +# - 'staff' for staff team members +# - 'guild' for guild members +# - 'first contribution' for first-time external contributors +# Labels issues by author: +# - 'community champion' for community champions + +name: PR Issue Labeler + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +permissions: + contents: read + +jobs: + check-authorship-and-label: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 5 + steps: + - id: get-app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + + - id: apply-authorship-label + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.get-app-token.outputs.token }} + script: | + const BOT_LABEL = 'bot'; + const STAFF_LABEL = 'staff'; + const STAFF_TEAM_SLUG = 'staff'; + const FIRST_CONTRIBUTION_LABEL = 'first contribution'; + const GUILD_LABEL = 'guild'; + const GUILD_MEMBERS = [ + '11happy', + 'AidanV', + 'alanpjohn', + 'AmaanBilwar', + 'arjunkomath', + 'austincummings', + 'ayushk-1801', + 'criticic', + 'dongdong867', + 'emamulandalib', + 'eureka928', + 'feitreim', + 'iam-liam', + 'iksuddle', + 'ishaksebsib', + 'lingyaochu', + 'loadingalias', + 'marcocondrache', + 'mchisolm0', + 'MostlyKIGuess', + 'nairadithya', + 'nihalxkumar', + 'notJoon', + 'OmChillure', + 'Palanikannan1437', + 'polyesterswing', + 'prayanshchh', + 'razeghi71', + 'sarmadgulzar', + 'seanstrom', + 'Shivansh-25', + 'SkandaBhat', + 'th0jensen', + 'tommyming', + 'transitoryangel', + 'TwistingTwists', + 'virajbhartiya', + 'YEDASAVG', + 'Ziqi-Yang', + ]; + const COMMUNITY_CHAMPION_LABEL = 'community champion'; + const COMMUNITY_CHAMPIONS = [ + '0x2CA', + '5brian', + '5herlocked', + 'abdelq', + 'afgomez', + 'AidanV', + 'akbxr', + 'AlvaroParker', + 'amtoaer', + 'artemevsevev', + 'bajrangCoder', + 'bcomnes', + 'Be-ing', + 'blopker', + 'bnjjj', + 'bobbymannino', + 'CharlesChen0823', + 'chbk', + 'davewa', + 'davidbarsky', + 'ddoemonn', + 'djsauble', + 'errmayank', + 'fantacell', + 'fdncred', + 'findrakecil', + 'FloppyDisco', + 'gko', + 'huacnlee', + 'imumesh18', + 'injust', + 'jacobtread', + 'jansol', + 'jeffreyguenther', + 'jenslys', + 'jongretar', + 'lemorage', + 'lingyaochu', + 'lnay', + 'marcocondrache', + 'marius851000', + 'mikebronner', + 'ognevny', + 'PKief', + 'playdohface', + 'RemcoSmitsDev', + 'rgbkrk', + 'romaninsh', + 'rxptr', + 'Simek', + 'someone13574', + 'sourcefrog', + 'suxiaoshao', + 'Takk8IS', + 'tartarughina', + 'thedadams', + 'tidely', + 'timvermeulen', + 'valentinegb', + 'versecafe', + 'vitallium', + 'WhySoBad', + 'ya7010', + 'Zertsov', + ]; + + const pr = context.payload.pull_request; + const issue = context.payload.issue; + const target = pr || issue; + const author = target.user.login; + + const listIncludesAuthor = (members, author) => { + const authorLower = author.toLowerCase(); + return members.some((member) => member.toLowerCase() === authorLower); + }; + + const isStaffMember = async (author) => { + try { + const response = await github.rest.teams.getMembershipForUserInOrg({ + org: 'zed-industries', + team_slug: STAFF_TEAM_SLUG, + username: author + }); + return response.data.state === 'active'; + } catch (error) { + if (error.status !== 404) { + throw error; + } + return false; + } + }; + + const getIssueLabels = () => { + if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { + return [COMMUNITY_CHAMPION_LABEL]; + } + + return []; + }; + + const getPullRequestLabels = async () => { + if (target.user.type === 'Bot') { + return [BOT_LABEL]; + } + + if (await isStaffMember(author)) { + return [STAFF_LABEL]; + } + + // External contributors + + const labelsToAdd = []; + + if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { + labelsToAdd.push(COMMUNITY_CHAMPION_LABEL); + } + + if (listIncludesAuthor(GUILD_MEMBERS, author)) { + labelsToAdd.push(GUILD_LABEL); + } + + // We use inverted logic here due to a suspected GitHub bug where first-time contributors + // get 'NONE' instead of 'FIRST_TIME_CONTRIBUTOR' or 'FIRST_TIMER'. + // https://github.com/orgs/community/discussions/78038 + // This will break if GitHub ever adds new associations. + const association = pr.author_association; + const knownAssociations = ['CONTRIBUTOR', 'COLLABORATOR', 'MEMBER', 'OWNER', 'MANNEQUIN']; + + if (knownAssociations.includes(association)) { + console.log(`PR #${pr.number} by ${author}: not a first-time contributor (association: '${association}')`); + } else { + labelsToAdd.push(FIRST_CONTRIBUTION_LABEL); + } + + return labelsToAdd; + }; + + const labelsToAdd = pr ? await getPullRequestLabels() : getIssueLabels(); + + if (labelsToAdd.length === 0) { + return; + } + + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: target.number, + labels: labelsToAdd + }); + + const targetType = pr ? 'PR' : 'issue'; + const labels = labelsToAdd.map((label) => `'${label}'`).join(', '); + console.log(`${targetType} #${target.number} by ${author}: labeled ${labels}`); + } catch (error) { + if (pr) { + throw error; + } + + console.error(`Failed to label issue #${target.number}: ${error.message}`); + } diff --git a/.github/workflows/pr_labeler.yml b/.github/workflows/pr_labeler.yml deleted file mode 100644 index 9ea70385432..00000000000 --- a/.github/workflows/pr_labeler.yml +++ /dev/null @@ -1,150 +0,0 @@ -# Labels pull requests by author: 'bot' for bot accounts, 'staff' for -# staff team members, 'guild' for guild members, 'first contribution' for -# first-time external contributors. -name: PR Labeler - -on: - pull_request_target: - types: [opened] - -permissions: - contents: read - -jobs: - check-authorship-and-label: - if: github.repository == 'zed-industries/zed' - runs-on: namespace-profile-2x4-ubuntu-2404 - timeout-minutes: 5 - steps: - - id: get-app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 - with: - app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} - private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} - owner: zed-industries - - - id: apply-authorship-label - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.get-app-token.outputs.token }} - script: | - const BOT_LABEL = 'bot'; - const STAFF_LABEL = 'staff'; - const GUILD_LABEL = 'guild'; - const FIRST_CONTRIBUTION_LABEL = 'first contribution'; - const STAFF_TEAM_SLUG = 'staff'; - const GUILD_MEMBERS = [ - '11happy', - 'AidanV', - 'AmaanBilwar', - 'MostlyKIGuess', - 'OmChillure', - 'Palanikannan1437', - 'Shivansh-25', - 'SkandaBhat', - 'TwistingTwists', - 'YEDASAVG', - 'Ziqi-Yang', - 'alanpjohn', - 'arjunkomath', - 'austincummings', - 'ayushk-1801', - 'criticic', - 'dongdong867', - 'emamulandalib', - 'eureka928', - 'feitreim', - 'iam-liam', - 'iksuddle', - 'ishaksebsib', - 'lingyaochu', - 'loadingalias', - 'marcocondrache', - 'mchisolm0', - 'nairadithya', - 'nihalxkumar', - 'notJoon', - 'polyesterswing', - 'prayanshchh', - 'razeghi71', - 'sarmadgulzar', - 'seanstrom', - 'th0jensen', - 'tommyming', - 'transitoryangel', - 'virajbhartiya', - ]; - - const pr = context.payload.pull_request; - const author = pr.user.login; - - if (pr.user.type === 'Bot') { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [BOT_LABEL] - }); - console.log(`PR #${pr.number} by ${author}: labeled '${BOT_LABEL}' (user type: '${pr.user.type}')`); - return; - } - - let isStaff = false; - try { - const response = await github.rest.teams.getMembershipForUserInOrg({ - org: 'zed-industries', - team_slug: STAFF_TEAM_SLUG, - username: author - }); - isStaff = response.data.state === 'active'; - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - - if (isStaff) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [STAFF_LABEL] - }); - console.log(`PR #${pr.number} by ${author}: labeled '${STAFF_LABEL}' (staff team member)`); - return; - } - - const authorLower = author.toLowerCase(); - const isGuildMember = GUILD_MEMBERS.some( - (member) => member.toLowerCase() === authorLower - ); - if (isGuildMember) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [GUILD_LABEL] - }); - console.log(`PR #${pr.number} by ${author}: labeled '${GUILD_LABEL}' (guild member)`); - // No early return: guild members can also get 'first contribution' - } - - // We use inverted logic here due to a suspected GitHub bug where first-time contributors - // get 'NONE' instead of 'FIRST_TIME_CONTRIBUTOR' or 'FIRST_TIMER'. - // https://github.com/orgs/community/discussions/78038 - // This will break if GitHub ever adds new associations. - const association = pr.author_association; - const knownAssociations = ['CONTRIBUTOR', 'COLLABORATOR', 'MEMBER', 'OWNER', 'MANNEQUIN']; - - if (knownAssociations.includes(association)) { - console.log(`PR #${pr.number} by ${author}: not a first-time contributor (association: '${association}')`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [FIRST_CONTRIBUTION_LABEL] - }); - console.log(`PR #${pr.number} by ${author}: labeled '${FIRST_CONTRIBUTION_LABEL}' (association: '${association}')`); diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index bc7408f3a21..3d3d7a7ac51 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -264,85 +264,6 @@ jobs: path: target/zed-remote-server-windows-x86_64.zip if-no-files-found: error timeout-minutes: 60 - build_nix_linux_x86_64: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))) - runs-on: namespace-profile-32x64-ubuntu-2004 - env: - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} - ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} - GIT_LFS_SKIP_SMUDGE: '1' - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 - with: - cache: nix - - name: nix_build::build_nix::install_nix - uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f - with: - github_access_token: ${{ secrets.GITHUB_TOKEN }} - - name: nix_build::build_nix::cachix_action - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad - with: - name: zed - authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - cachixArgs: -v - pushFilter: -zed-editor-[0-9.]* - - name: nix_build::build_nix::build - run: nix build .#default -L --accept-flake-config - timeout-minutes: 60 - continue-on-error: true - build_nix_mac_aarch64: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))) - runs-on: namespace-profile-mac-large - env: - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} - ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} - GIT_LFS_SKIP_SMUDGE: '1' - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 - with: - path: ~/nix-cache - - name: nix_build::build_nix::install_nix - uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f - with: - github_access_token: ${{ secrets.GITHUB_TOKEN }} - - name: nix_build::build_nix::configure_local_nix_cache - run: | - mkdir -p ~/nix-cache - echo "extra-substituters = file://$HOME/nix-cache?priority=10" | sudo tee -a /etc/nix/nix.conf - echo "require-sigs = false" | sudo tee -a /etc/nix/nix.conf - sudo launchctl kickstart -k system/org.nixos.nix-daemon - - name: nix_build::build_nix::cachix_action - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad - with: - name: zed - authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - cachixArgs: -v - pushFilter: -zed-editor-[0-9.]* - - name: nix_build::build_nix::build - run: nix build .#default -L --accept-flake-config - - name: nix_build::build_nix::export_to_local_nix_cache - if: always() - run: | - if [ -L result ]; then - echo "Copying build closure to local binary cache..." - nix copy --to "file://$HOME/nix-cache" ./result || echo "Warning: nix copy to local cache failed" - else - echo "No build result found, skipping cache export." - fi - timeout-minutes: 60 - continue-on-error: true concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/track_duplicate_bot_effectiveness.yml b/.github/workflows/track_duplicate_bot_effectiveness.yml index 0d41a607061..bdcf8a5f407 100644 --- a/.github/workflows/track_duplicate_bot_effectiveness.yml +++ b/.github/workflows/track_duplicate_bot_effectiveness.yml @@ -16,8 +16,9 @@ jobs: github.event_name == 'issues' && github.repository == 'zed-industries/zed' && github.event.issue.pull_request == null && - github.event.issue.type != null && - (github.event.issue.type.name == 'Bug' || github.event.issue.type.name == 'Crash') + (github.event.issue.type == null || + github.event.issue.type.name == 'Bug' || + github.event.issue.type.name == 'Crash') runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.gitignore b/.gitignore index becb768d270..cf75babbcdd 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ crates/docs_preprocessor/actions.json # Local documentation audit files /december-2025-releases.md /docs/december-2025-documentation-gaps.md + +# NixOS integration test state +.nixos-test-history diff --git a/Cargo.lock b/Cargo.lock index 47f6daa9c6d..3b281139f1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,85 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842fd8203e6dfcf531d24f5bac792088edfba7d6b35844fead191603fb32a260" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534bc3fdc89a64a1db3c46b33c198fde2b7c3c7d094e5809c8c8bf2970c18243" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e549dd7c6562b6a2ea807b44726e6241707db054a817dc4c7e2b8d3b39bfac" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel 2.5.0", + "async-executor", + "async-task", + "atspi", + "futures-lite 2.6.1", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "acp_thread" version = "0.1.0" @@ -30,8 +109,8 @@ dependencies = [ "parking_lot", "portable-pty", "project", - "prompt_store", "rand 0.9.4", + "sandbox", "serde", "serde_json", "settings", @@ -154,6 +233,7 @@ dependencies = [ "agent_settings", "agent_skills", "anyhow", + "assets", "async-channel 2.5.0", "async-io", "chrono", @@ -163,6 +243,7 @@ dependencies = [ "cloud_llm_client", "collections", "context_server", + "criterion", "ctor", "db", "editor", @@ -203,12 +284,13 @@ dependencies = [ "smallvec", "sqlez", "streaming_diff", - "strsim", + "strsim 0.11.1", "task", "telemetry", "tempfile", "text", "theme", + "theme_settings", "thiserror 2.0.17", "ui", "unindent", @@ -224,13 +306,14 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.11.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af62fb84df2af0f933d8f5fd78b843fa5eb0ec5a48fa1b528c41951d0bbe36c" +checksum = "4361ba6627e51de955b10f3c77fb9eb959c85191a236c1c2c84e32f4ff240faf" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", - "anyhow", + "async-process", + "blocking", "futures 0.3.32", "futures-concurrency", "jsonrpcmsg", @@ -239,7 +322,7 @@ dependencies = [ "schemars 1.0.4", "serde", "serde_json", - "thiserror 2.0.17", + "shell-words", "tokio", "tokio-util", "tracing", @@ -248,20 +331,19 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce42c2d3c048c12897eef2e577dfff1e3355c632c9f1625cc953b9df48b44631" +checksum = "cabdc9d845d08ec7ed2d0c9de1ae4a1b198301407d55855261572761be90ec9f" dependencies = [ - "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "agent-client-protocol-schema" -version = "0.12.0" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49bae57dad1c28a362fbdcf7bab0583316a02b45a70792109fced55780a3b63c" +checksum = "b957d8391ac3933e2a940446171c508d2b8ffc386d8fa7d0b9c936a2575b463e" dependencies = [ "anyhow", "derive_more", @@ -323,7 +405,7 @@ dependencies = [ "agent-client-protocol", "anyhow", "collections", - "convert_case 0.8.0", + "convert_case 0.11.0", "fs", "futures 0.3.32", "gpui", @@ -345,6 +427,7 @@ name = "agent_skills" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "const_format", "fs", "futures 0.3.32", @@ -353,6 +436,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "url", "util", ] @@ -407,6 +491,7 @@ dependencies = [ "language_models", "languages", "log", + "lru", "lsp", "markdown", "menu", @@ -430,7 +515,6 @@ dependencies = [ "remote_server", "reqwest_client", "rope", - "rules_library", "schemars 1.0.4", "search", "semver", @@ -438,6 +522,7 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", + "skill_creator", "streaming_diff", "task", "telemetry", @@ -514,15 +599,14 @@ dependencies = [ [[package]] name = "alacritty_terminal" -version = "0.25.1" -source = "git+https://github.com/zed-industries/alacritty?rev=9d9640d4#9d9640d4e56d67a09d049f9c0a300aae08d4f61e" +version = "0.26.1-dev" +source = "git+https://github.com/zed-industries/alacritty?rev=fcf32feacb367b75ec84dd40f041e4fd411d3cc1#fcf32feacb367b75ec84dd40f041e4fd411d3cc1" dependencies = [ "base64 0.22.1", "bitflags 2.10.0", "home", "libc", "log", - "mach2 0.5.0", "miow", "parking_lot", "piper", @@ -601,7 +685,7 @@ version = "4.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" dependencies = [ - "cssparser", + "cssparser 0.35.0", "html5ever 0.35.0", "maplit", "tendril", @@ -714,6 +798,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + [[package]] name = "approx" version = "0.5.1" @@ -800,6 +893,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -1215,6 +1317,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "audio" version = "0.1.0" @@ -1293,22 +1432,20 @@ dependencies = [ name = "auto_update_ui" version = "0.1.0" dependencies = [ - "agent_settings", + "agent_skills", "anyhow", "auto_update", "client", "db", "editor", - "fs", "gpui", "markdown_preview", "notifications", - "project", + "prompt_store", "release_channel", "semver", "serde", "serde_json", - "settings", "smol", "telemetry", "ui", @@ -1987,6 +2124,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bigdecimal" version = "0.4.8" @@ -2019,7 +2162,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -2039,7 +2182,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -2048,6 +2191,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -2066,6 +2218,12 @@ dependencies = [ "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -2144,13 +2302,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.3", ] [[package]] @@ -2254,6 +2421,15 @@ dependencies = [ "utf8-chars", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -2272,8 +2448,8 @@ dependencies = [ "clock", "ctor", "futures 0.3.32", - "git2", "gpui", + "imara-diff", "language", "log", "pretty_assertions", @@ -2795,6 +2971,16 @@ dependencies = [ "libc", ] +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx 0.4.0", + "num-traits", +] + [[package]] name = "channel" version = "0.1.0" @@ -2923,7 +3109,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", "terminal_size", ] @@ -2998,7 +3184,6 @@ dependencies = [ "cloud_llm_client", "collections", "credentials_provider", - "db", "derive_more", "feature_flags", "fs", @@ -3009,7 +3194,7 @@ dependencies = [ "http_client_tls", "httparse", "log", - "objc2-foundation", + "objc2-foundation 0.3.2", "parking_lot", "paths", "postage", @@ -3696,6 +3881,8 @@ dependencies = [ "serde", "serde_json", "settings", + "sqlez", + "tempfile", ] [[package]] @@ -3906,9 +4093,9 @@ dependencies = [ [[package]] name = "cosmic-text" -version = "0.17.1" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c5c9868e64aa6c5410629a83450e142c80e721c727a5bc0fb18107af6c2d66b" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" dependencies = [ "bitflags 2.10.0", "fontdb", @@ -3945,13 +4132,13 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", - "objc2", + "objc2 0.6.3", "objc2-audio-toolbox", "objc2-avf-audio", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -4346,6 +4533,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -4372,20 +4572,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.4.3" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - [[package]] name = "ctrlc" version = "3.5.0" @@ -4536,6 +4730,16 @@ dependencies = [ "util", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.11" @@ -4566,6 +4770,20 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -4576,7 +4794,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -4590,7 +4808,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -4603,10 +4821,21 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -4874,6 +5103,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -4931,6 +5191,7 @@ dependencies = [ "gpui", "http 1.3.1", "http_client", + "indoc", "log", "menu", "paths", @@ -4940,6 +5201,7 @@ dependencies = [ "serde", "serde_json", "serde_json_lenient", + "serde_yaml", "settings", "shlex", "ui", @@ -5031,6 +5293,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -5039,8 +5311,19 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", - "windows-sys 0.61.2", + "redox_users 0.5.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -5056,9 +5339,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", ] [[package]] @@ -5172,19 +5455,26 @@ dependencies = [ ] [[package]] -name = "dtor" -version = "0.0.6" +name = "dugong" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" +checksum = "3f5b0a9f36306eb29685e6e27b82df4d0bb5af64261324f7d5f7716d7c39ba1b" dependencies = [ - "dtor-proc-macro", + "dugong-graphlib", + "rustc-hash 2.1.1", + "serde", + "serde_json", ] [[package]] -name = "dtor-proc-macro" -version = "0.0.5" +name = "dugong-graphlib" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" +checksum = "75aca4df30a85b3ba8cead498f4e38e9de4aff630155ce47515d11dfd729c6ea" +dependencies = [ + "hashbrown 0.16.1", + "rustc-hash 2.1.1", +] [[package]] name = "dunce" @@ -5492,7 +5782,7 @@ dependencies = [ "client", "clock", "collections", - "convert_case 0.8.0", + "convert_case 0.11.0", "criterion", "ctor", "dap", @@ -5671,6 +5961,15 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -5848,7 +6147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5955,6 +6254,8 @@ dependencies = [ "settings", "shellexpand", "terminal_view", + "theme", + "theme_settings", "util", "watch", ] @@ -6091,6 +6392,7 @@ dependencies = [ "snippet_provider", "task", "theme_settings", + "thiserror 2.0.17", "tokio", "toml 0.8.23", "tree-sitter", @@ -6156,7 +6458,6 @@ name = "extensions_ui" version = "0.1.0" dependencies = [ "anyhow", - "client", "cloud_api_types", "collections", "db", @@ -7185,6 +7486,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -7293,7 +7603,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7336,15 +7646,14 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ "bitflags 2.10.0", "libc", "libgit2-sys", "log", - "url", ] [[package]] @@ -7451,6 +7760,7 @@ dependencies = [ "settings", "smallvec", "strum 0.27.2", + "sysinfo 0.37.2", "task", "telemetry", "theme", @@ -7482,6 +7792,114 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glam" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" + +[[package]] +name = "glam" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" + +[[package]] +name = "glam" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" + +[[package]] +name = "glam" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" + +[[package]] +name = "glam" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" + +[[package]] +name = "glam" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" + +[[package]] +name = "glam" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" + +[[package]] +name = "glam" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" + +[[package]] +name = "glam" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" + +[[package]] +name = "glam" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" + +[[package]] +name = "glam" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + +[[package]] +name = "glam" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" + [[package]] name = "glib" version = "0.21.5" @@ -7675,6 +8093,7 @@ dependencies = [ name = "gpui" version = "0.2.2" dependencies = [ + "accesskit", "anyhow", "async-channel 2.5.0", "async-task", @@ -7718,8 +8137,8 @@ dependencies = [ "metal", "num_cpus", "objc", - "objc2", - "objc2-metal", + "objc2 0.6.3", + "objc2-metal 0.3.2", "parking", "parking_lot", "pathfinder_geometry", @@ -7765,6 +8184,8 @@ dependencies = [ name = "gpui_linux" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_unix", "anyhow", "as-raw-xcb-connection", "ashpd", @@ -7813,6 +8234,8 @@ dependencies = [ name = "gpui_macos" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_macos", "anyhow", "async-task", "block", @@ -7839,7 +8262,7 @@ dependencies = [ "media", "metal", "objc", - "objc2-app-kit", + "objc2-app-kit 0.3.1", "parking_lot", "pathfinder_geometry", "raw-window-handle", @@ -7957,6 +8380,8 @@ dependencies = [ name = "gpui_windows" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_windows", "anyhow", "collections", "etagere", @@ -8386,6 +8811,19 @@ dependencies = [ "regex", ] +[[package]] +name = "htmlize" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e815d50d9e411ba2690d730e6ec139c08260dddb756df315dbd16d01a587226" +dependencies = [ + "memchr", + "pastey 0.1.1", + "phf 0.13.1", + "phf_codegen 0.13.1", + "serde_json", +] + [[package]] name = "http" version = "0.2.12" @@ -8626,7 +9064,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -8644,7 +9082,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.56.0", ] [[package]] @@ -8898,7 +9336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.15.5", "serde", "serde_core", ] @@ -9270,12 +9708,13 @@ dependencies = [ [[package]] name = "json5" -version = "1.3.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" dependencies = [ + "pest", + "pest_derive", "serde", - "ucd-trie", ] [[package]] @@ -9479,6 +9918,15 @@ dependencies = [ "libc", ] +[[package]] +name = "kurbo" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd85a5776cd9500c2e2059c8c76c3b01528566b7fcbaf8098b55a33fc298849b" +dependencies = [ + "arrayvec", +] + [[package]] name = "kurbo" version = "0.11.3" @@ -9499,6 +9947,37 @@ dependencies = [ "log", ] +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set 0.5.3", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + [[package]] name = "language" version = "0.1.0" @@ -9538,7 +10017,7 @@ dependencies = [ "shellexpand", "smallvec", "streaming-iterator", - "strsim", + "strsim 0.11.1", "sum_tree", "task", "text", @@ -9639,6 +10118,7 @@ dependencies = [ "futures 0.3.32", "gpui_shared_string", "http_client", + "log", "partial-json-fixer", "schemars 1.0.4", "serde", @@ -9666,7 +10146,7 @@ dependencies = [ "cloud_api_types", "collections", "component", - "convert_case 0.8.0", + "convert_case 0.11.0", "copilot", "copilot_chat", "copilot_ui", @@ -9938,9 +10418,9 @@ dependencies = [ [[package]] name = "libgit2-sys" -version = "0.18.3+1.9.2" +version = "0.18.4+1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +checksum = "9b26f66f35e1871b22efcf7191564123d2a446ca0538cde63c23adfefa9b15b7" dependencies = [ "cc", "libc", @@ -9999,7 +10479,7 @@ dependencies = [ [[package]] name = "libwebrtc" version = "0.3.26" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "cxx", "glib", @@ -10061,6 +10541,12 @@ dependencies = [ "cc", ] +[[package]] +name = "link-section" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1e908a416d6e9f725743b84a36feea40c4c131e805fbc26d61f9f451f36080" + [[package]] name = "linkify" version = "0.10.0" @@ -10070,6 +10556,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -10097,7 +10589,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "livekit" version = "0.7.32" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "base64 0.22.1", "bmrng", @@ -10123,7 +10615,7 @@ dependencies = [ [[package]] name = "livekit-api" version = "0.4.14" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "base64 0.21.7", "futures-util", @@ -10150,7 +10642,7 @@ dependencies = [ [[package]] name = "livekit-protocol" version = "0.7.1" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "futures-util", "livekit-runtime", @@ -10166,7 +10658,7 @@ dependencies = [ [[package]] name = "livekit-runtime" version = "0.4.0" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "tokio", "tokio-stream", @@ -10270,6 +10762,58 @@ dependencies = [ "value-bag", ] +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lol_html" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6888e8653f6e49cb2924c660fc367a8beeb6239b71e117fa082153c6ea44d427" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cssparser 0.36.0", + "encoding_rs", + "foldhash 0.2.0", + "hashbrown 0.16.1", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror 2.0.17", +] + [[package]] name = "loom" version = "0.7.2" @@ -10439,6 +10983,18 @@ dependencies = [ "libc", ] +[[package]] +name = "manatee" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5ed3cc0bf5f911d242bed4b4cdf12c00186e471e9fdf57d9dd0a033bbbdc87" +dependencies = [ + "indexmap 2.11.4", + "nalgebra", + "rustc-hash 2.1.1", + "thiserror 2.0.17", +] + [[package]] name = "maplit" version = "1.0.2" @@ -10464,7 +11020,7 @@ dependencies = [ "linkify", "log", "markup5ever_rcdom", - "mermaid-rs-renderer", + "mermaid_render", "node_runtime", "pulldown-cmark 0.13.0", "settings", @@ -10508,7 +11064,7 @@ checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" dependencies = [ "log", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", "tendril", @@ -10563,6 +11119,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-owned" version = "0.3.4" @@ -10703,19 +11269,78 @@ dependencies = [ ] [[package]] -name = "mermaid-rs-renderer" -version = "0.2.2" -source = "git+https://github.com/zed-industries/mermaid-rs-renderer?rev=782b89a7da3f0e91e51f98d00a93acba679be6fb#782b89a7da3f0e91e51f98d00a93acba679be6fb" +name = "mermaid_render" +version = "0.1.0" dependencies = [ "anyhow", - "fontdb", + "gpui", + "mermaid_render", + "merman", + "quick-xml 0.38.3", + "serde_json", +] + +[[package]] +name = "merman" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3209bcfe9c8e9787a7534f8d97f1d27f7d2fdd54d3c49d9f59bb693aedabbf95" +dependencies = [ + "merman-core", + "merman-render", + "thiserror 2.0.17", +] + +[[package]] +name = "merman-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72fc438439ca428b449486f8eaf9ce2f8ab76cea159181d0b1b454e1192e4649" +dependencies = [ + "chrono", + "euclid", + "htmlize", + "indexmap 2.11.4", "json5", - "once_cell", + "lalrpop", + "lalrpop-util", + "logos", + "lol_html", "regex", + "rustc-hash 2.1.1", + "ryu-js", "serde", "serde_json", + "serde_yaml", "thiserror 2.0.17", - "ttf-parser", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "merman-render" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5698e2681196051479ae8bc5153ed7eda6490f56240fd8d92da7c3932a00735" +dependencies = [ + "base64 0.22.1", + "chrono", + "dugong", + "indexmap 2.11.4", + "manatee", + "merman-core", + "pulldown-cmark 0.12.2", + "regex", + "roughr-merman", + "rustc-hash 2.1.1", + "ryu-js", + "serde", + "serde_json", + "svgtypes 0.11.0", + "thiserror 2.0.17", + "unicode-width", + "url", ] [[package]] @@ -10739,7 +11364,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "convert_case 0.8.0", + "convert_case 0.11.0", "log", "pretty_assertions", "serde_json", @@ -10994,8 +11619,8 @@ checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" [[package]] name = "naga" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -11017,6 +11642,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nalgebra" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" +dependencies = [ + "approx 0.5.1", + "glam 0.14.0", + "glam 0.15.2", + "glam 0.16.0", + "glam 0.17.3", + "glam 0.18.0", + "glam 0.19.0", + "glam 0.20.5", + "glam 0.21.3", + "glam 0.22.0", + "glam 0.23.0", + "glam 0.24.2", + "glam 0.25.0", + "glam 0.27.0", + "glam 0.28.0", + "glam 0.29.3", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -11067,16 +11725,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "nc" -version = "0.1.0" -dependencies = [ - "anyhow", - "futures 0.3.32", - "net", - "smol", -] - [[package]] name = "ndk" version = "0.9.0" @@ -11168,6 +11816,7 @@ dependencies = [ "async-std", "async-tar", "async-trait", + "chrono", "futures 0.3.32", "http_client", "log", @@ -11301,7 +11950,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11553,6 +12202,22 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.3" @@ -11562,14 +12227,30 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[package]] name = "objc2-app-kit" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] @@ -11580,11 +12261,11 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ "bitflags 2.10.0", "libc", - "objc2", + "objc2 0.6.3", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -11593,8 +12274,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", ] [[package]] @@ -11604,10 +12285,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -11617,7 +12298,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -11627,10 +12320,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "dispatch2", "libc", - "objc2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -11639,6 +12344,18 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -11646,9 +12363,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -11662,6 +12379,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-metal" version = "0.3.2" @@ -11669,11 +12398,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -11683,10 +12425,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -12167,9 +12909,10 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" dependencies = [ - "approx", + "approx 0.5.1", "fast-srgb8", "palette_derive", + "phf 0.11.3", ] [[package]] @@ -12919,6 +13662,17 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.11.3" @@ -12929,6 +13683,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -12949,6 +13713,16 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" @@ -12975,6 +13749,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -12993,6 +13780,15 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "picker" version = "0.1.0" @@ -13187,6 +13983,16 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "points_on_curve" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca77ae128f56aad518f82cf0af3dcda13b874e59a608dbb287c7887fec97b505" +dependencies = [ + "euclid", + "num-traits", +] + [[package]] name = "polling" version = "3.11.0" @@ -13674,10 +14480,8 @@ dependencies = [ "chrono", "collections", "db", - "feature_flags", "fs", "futures 0.3.32", - "fuzzy", "gpui", "handlebars 4.5.0", "heed", @@ -13685,7 +14489,6 @@ dependencies = [ "log", "parking_lot", "paths", - "rope", "serde", "serde_json", "strum 0.27.2", @@ -13784,7 +14587,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes 1.11.1", "heck 0.5.0", - "itertools 0.11.0", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -13817,7 +14620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -13910,7 +14713,20 @@ checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ "bitflags 2.10.0", "memchr", - "pulldown-cmark-escape", + "pulldown-cmark-escape 0.10.1", + "unicase", +] + +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.10.0", + "getopts", + "memchr", + "pulldown-cmark-escape 0.11.0", "unicase", ] @@ -13931,6 +14747,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pulley-interpreter" version = "36.0.9" @@ -14037,6 +14859,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -14050,7 +14882,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.40", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -14087,9 +14919,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -14348,12 +15180,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.11.0" @@ -14483,6 +15321,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -14709,6 +15558,7 @@ dependencies = [ "smol", "sysinfo 0.37.2", "task", + "tempfile", "theme", "theme_settings", "thiserror 2.0.17", @@ -14902,7 +15752,7 @@ dependencies = [ "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.15.3", "tiny-skia", "usvg", "zune-jpeg 0.4.21", @@ -15062,6 +15912,22 @@ dependencies = [ "ztracing", ] +[[package]] +name = "roughr-merman" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34fd013a888d8b2119b3ed57cd3df0f1f9dd2db3fa94fdb98f58d60f9855ef0" +dependencies = [ + "derive_builder", + "euclid", + "num-traits", + "palette", + "points_on_curve", + "rand 0.8.6", + "svg_path_ops", + "svgtypes 0.11.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -15139,33 +16005,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" -[[package]] -name = "rules_library" -version = "0.1.0" -dependencies = [ - "anyhow", - "collections", - "editor", - "gpui", - "language", - "language_model", - "log", - "menu", - "picker", - "platform_title_bar", - "prompt_store", - "release_channel", - "rope", - "serde", - "settings", - "theme_settings", - "ui", - "ui_input", - "util", - "workspace", - "zed_actions", -] - [[package]] name = "runtimelib" version = "1.4.0" @@ -15307,7 +16146,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -15502,12 +16341,27 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" + [[package]] name = "saa" version = "5.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0ba8adb63e0deebd0744d8fc5bea394c08029159deaf680513fec1a3949144" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "safetensors" version = "0.4.5" @@ -15527,6 +16381,14 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "tempfile", +] + [[package]] name = "scc" version = "3.5.6" @@ -15879,6 +16741,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" +dependencies = [ + "bitflags 2.10.0", + "cssparser 0.36.0", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash 2.1.1", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -16042,11 +16923,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -16061,9 +16943,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -16108,6 +16990,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "session" version = "0.1.0" @@ -16220,6 +17111,7 @@ version = "0.1.0" dependencies = [ "agent", "agent_settings", + "agent_skills", "anyhow", "audio", "codestral", @@ -16411,9 +17303,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.18" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" dependencies = [ "libc", "signal-hook-registry", @@ -16448,6 +17340,19 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx 0.5.1", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -16519,6 +17424,33 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skill_creator" +version = "0.1.0" +dependencies = [ + "agent_skills", + "anyhow", + "editor", + "fs", + "futures 0.3.32", + "gpui", + "http_client", + "language", + "menu", + "notifications", + "platform_title_bar", + "release_channel", + "serde_json", + "serde_yaml_ng", + "settings", + "theme_settings", + "ui", + "ui_input", + "util", + "workspace", + "worktree", +] + [[package]] name = "skrifa" version = "0.37.0" @@ -17046,6 +17978,7 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" name = "streaming_diff" version = "0.1.0" dependencies = [ + "criterion", "ordered-float 2.10.1", "rand 0.9.4", "rope", @@ -17103,6 +18036,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -17262,6 +18201,16 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svg_path_ops" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ed183bad71dff813db12a317785a8565c9b44732cca3c2effd40a06eb9cd28" +dependencies = [ + "cgmath", + "svgtypes 0.11.0", +] + [[package]] name = "svg_preview" version = "0.1.0" @@ -17276,13 +18225,23 @@ dependencies = [ "zed_actions", ] +[[package]] +name = "svgtypes" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4b0611e7f3277f68c0fa18e385d9e2d26923691379690039548f867cef02a7" +dependencies = [ + "kurbo 0.9.5", + "siphasher 0.3.11", +] + [[package]] name = "svgtypes" version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ - "kurbo", + "kurbo 0.11.3", "siphasher 1.0.1", ] @@ -17810,7 +18769,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -17824,6 +18783,17 @@ dependencies = [ "utf-8", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -18236,7 +19206,6 @@ dependencies = [ "client", "cloud_api_types", "db", - "feature_flags", "fs", "git_ui", "gpui", @@ -18444,7 +19413,7 @@ dependencies = [ "toml_datetime 0.7.3", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -18476,7 +19445,7 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -18488,7 +19457,7 @@ dependencies = [ "indexmap 2.11.4", "toml_datetime 0.7.3", "toml_parser", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -18497,7 +19466,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow", + "winnow 0.7.13", ] [[package]] @@ -18517,7 +19486,7 @@ name = "toolchain_selector" version = "0.1.0" dependencies = [ "anyhow", - "convert_case 0.8.0", + "convert_case 0.11.0", "editor", "futures 0.3.32", "fuzzy", @@ -18725,7 +19694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f" dependencies = [ "cc", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -18746,8 +19715,8 @@ dependencies = [ "chrono", "libc", "log", - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.2", "once_cell", "percent-encoding", "scopeguard", @@ -18758,9 +19727,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.26.8" +version = "0.26.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "887bd495d0582c5e3e0d8ece2233666169fa56a9644d172fc22ad179ab2d0538" +checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" dependencies = [ "cc", "regex", @@ -19161,7 +20130,9 @@ dependencies = [ "gpui_util", "icons", "itertools 0.14.0", + "log", "menu", + "num-format", "schemars 1.0.4", "serde", "smallvec", @@ -19355,7 +20326,7 @@ dependencies = [ "flate2", "fontdb", "imagesize", - "kurbo", + "kurbo 0.11.3", "log", "pico-args", "roxmltree", @@ -19363,7 +20334,7 @@ dependencies = [ "simplecss", "siphasher 1.0.1", "strict-num", - "svgtypes", + "svgtypes 0.15.3", "tiny-skia-path", "unicode-bidi", "unicode-script", @@ -20044,9 +21015,9 @@ dependencies = [ [[package]] name = "wasmtime-c-api-impl" -version = "36.0.6" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c62ea3fa30e6b0cf61116b3035121b8f515c60ac118ebfdab2ee56d028ed1e" +checksum = "e5e71e971a27df819171b79597c0f1826fc7cf2c168111c64dbc5505a1ffbda7" dependencies = [ "anyhow", "log", @@ -20093,9 +21064,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-c-api-macros" -version = "36.0.6" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8c61294155a6d23c202f08cf7a2f9392a866edd50517508208818be626ce9f" +checksum = "20b9553165039d365931a998d9b60278cc968ba9d81531cecde8ffc3effa1fe3" dependencies = [ "proc-macro2", "quote", @@ -20461,7 +21432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" dependencies = [ "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", ] @@ -20516,7 +21487,7 @@ dependencies = [ [[package]] name = "webrtc-sys" version = "0.3.23" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "cc", "cxx", @@ -20530,7 +21501,7 @@ dependencies = [ [[package]] name = "webrtc-sys-build" version = "0.3.13" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" dependencies = [ "anyhow", "fs2", @@ -20549,8 +21520,8 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bitflags 2.10.0", @@ -20578,8 +21549,8 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -20610,39 +21581,39 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "android_system_properties", "arrayvec", "ash", "bit-set 0.9.1", "bitflags 2.10.0", - "block2", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases 0.2.1", @@ -20658,11 +21629,11 @@ dependencies = [ "log", "naga", "ndk-sys", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float 4.6.0", "parking_lot", @@ -20682,12 +21653,13 @@ dependencies = [ "wgpu-types", "windows 0.62.2", "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] name = "wgpu-naga-bridge" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "naga", "wgpu-types", @@ -20695,8 +21667,8 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?branch=v29#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -20754,6 +21726,16 @@ dependencies = [ "wasite", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "wiggle" version = "36.0.9" @@ -20817,7 +21799,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -21586,6 +22568,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" @@ -21980,6 +22971,7 @@ dependencies = [ "collections", "component", "db", + "dirs", "fs", "futures 0.3.32", "futures-lite 1.13.0", @@ -22405,12 +23397,36 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.13", "zbus_macros", "zbus_names", "zvariant", ] +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + [[package]] name = "zbus_macros" version = "5.13.2" @@ -22428,18 +23444,30 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow", + "winnow 1.0.2", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +dependencies = [ + "quick-xml 0.39.3", + "serde", + "zbus_names", "zvariant", ] [[package]] name = "zed" -version = "1.4.0" +version = "1.6.0" dependencies = [ "acp_thread", "acp_tools", @@ -22449,6 +23477,7 @@ dependencies = [ "agent-client-protocol", "agent_servers", "agent_settings", + "agent_skills", "agent_ui", "anyhow", "ashpd", @@ -22531,7 +23560,6 @@ dependencies = [ "migrator", "mimalloc", "miniprofiler_ui", - "nc", "node_runtime", "notifications", "onboarding", @@ -23085,24 +24113,24 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.9.2" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", "serde", "serde_bytes", - "winnow", + "winnow 1.0.2", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -23113,13 +24141,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 1.0.2", ] diff --git a/Cargo.toml b/Cargo.toml index 07c94aa33ee..883e0e04486 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,13 +130,13 @@ members = [ "crates/lsp", "crates/markdown", "crates/markdown_preview", + "crates/mermaid_render", "crates/media", "crates/menu", "crates/migrator", "crates/miniprofiler_ui", "crates/mistral", "crates/multi_buffer", - "crates/nc", "crates/net", "crates/node_runtime", "crates/notifications", @@ -171,7 +171,8 @@ members = [ "crates/reqwest_client", "crates/rope", "crates/rpc", - "crates/rules_library", + "crates/sandbox", + "crates/skill_creator", "crates/scheduler", "crates/schema_generator", "crates/search", @@ -388,15 +389,14 @@ lmstudio = { path = "crates/lmstudio" } lsp = { path = "crates/lsp" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } +mermaid_render = { path = "crates/mermaid_render" } svg_preview = { path = "crates/svg_preview" } media = { path = "crates/media" } menu = { path = "crates/menu" } -mermaid-rs-renderer = { git = "https://github.com/zed-industries/mermaid-rs-renderer", rev = "782b89a7da3f0e91e51f98d00a93acba679be6fb", default-features = false } migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } miniprofiler_ui = { path = "crates/miniprofiler_ui" } -nc = { path = "crates/nc" } net = { path = "crates/net" } node_runtime = { path = "crates/node_runtime" } notifications = { path = "crates/notifications" } @@ -431,8 +431,9 @@ reqwest_client = { path = "crates/reqwest_client" } rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } -rules_library = { path = "crates/rules_library" } +skill_creator = { path = "crates/skill_creator" } scheduler = { path = "crates/scheduler" } +sandbox = { path = "crates/sandbox" } search = { path = "crates/search" } session = { path = "crates/session" } sidebar = { path = "crates/sidebar" } @@ -500,9 +501,13 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -agent-client-protocol = { version = "=0.11.1", features = ["unstable"] } +accesskit = "0.24.0" +accesskit_macos = "0.26.0" +accesskit_unix = "0.21.0" +accesskit_windows = "0.32.1" +agent-client-protocol = { version = "=0.12.1", features = ["unstable"] } aho-corasick = "1.1" -alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } +alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" } any_vec = "0.14" anyhow = "1.0.86" ashpd = { version = "0.13", default-features = false, features = [ @@ -554,14 +559,14 @@ clap = { version = "4.4", features = ["derive", "wrap_help"] } cocoa = "=0.26.0" cocoa-foundation = "=0.2.0" const_format = "0.2" -convert_case = "0.8.0" +convert_case = "0.11.0" core-foundation = "=0.10.0" core-foundation-sys = "0.8.6" core-video = { version = "0.5.2", features = ["metal"] } cpal = "0.17" crash-handler = "0.7" criterion = { version = "0.5", features = ["html_reports"] } -ctor = "0.4.0" +ctor = "1.0.6" dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "1b461b310481d01e02b2603c16d7144b926339f8" } dashmap = "6.0" derive_more = { version = "2.1.1", features = [ @@ -590,7 +595,7 @@ futures = "0.3.32" futures-concurrency = "7.7.1" futures-lite = "1.13" gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } -git2 = { version = "0.20.1", default-features = false, features = ["vendored-libgit2"] } +git2 = { version = "0.21.0", default-features = false, features = ["vendored-libgit2", "unstable-sha256"] } globset = "0.4" heapless = "0.9.2" handlebars = "4.3" @@ -620,6 +625,7 @@ linkify = "0.10.0" libwebrtc = "0.3.26" livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] } log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } +lru = "0.16" lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "f4dfa89a21ca35cd929b70354b1583fabae325f8" } mach2 = "0.5" markup5ever_rcdom = "0.3.0" @@ -721,6 +727,7 @@ serde_json_lenient = { version = "0.2", features = [ "preserve_order", "raw_value", ] } +serde_yaml = "0.9.34" serde_path_to_error = "0.1.17" serde_urlencoded = "0.7" sha2 = "0.10" @@ -762,7 +769,7 @@ toml_edit = { version = "0.22", default-features = false, features = [ "serde", ] } tower-http = "0.4.4" -tree-sitter = { version = "0.26.8", features = ["wasm"] } +tree-sitter = { version = "0.26.9", features = ["wasm"] } tree-sitter-bash = "0.25.1" tree-sitter-c = "0.24.1" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } @@ -812,7 +819,7 @@ which = "6.0.0" wasm-bindgen = "0.2.120" web-time = "1.1.0" webrtc-sys = "0.3.23" -wgpu = { git = "https://github.com/zed-industries/wgpu.git", branch = "v29" } +wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } windows-core = "0.61" yaml-rust2 = "0.8" yawc = "0.2.5" @@ -884,9 +891,9 @@ notify = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24c notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24cad542c28e04ced02e20325a4ec28a31d" } windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } calloop = { git = "https://github.com/zed-industries/calloop" } -livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" } -libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" } -webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" } +livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } +libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } +webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } [profile.dev] split-debuginfo = "unpacked" diff --git a/LICENSE-AGPL b/LICENSE-AGPL deleted file mode 100644 index 87a0dea90eb..00000000000 --- a/LICENSE-AGPL +++ /dev/null @@ -1,788 +0,0 @@ -Copyright 2022 - 2025 Zed Industries, Inc. - - - - -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. -This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. -You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - Preamble - - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - - The precise terms and conditions for copying, distribution and -modification follow. - - - TERMS AND CONDITIONS - - - 0. Definitions. - - - "This License" refers to version 3 of the GNU Affero General Public License. - - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - - A "covered work" means either the unmodified Program or a work based -on the Program. - - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - - 1. Source Code. - - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - - The Corresponding Source for a work in source code form is that -same work. - - - 2. Basic Permissions. - - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - - 4. Conveying Verbatim Copies. - - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - - 5. Conveying Modified Source Versions. - - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - - 6. Conveying Non-Source Forms. - - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - - 7. Additional Terms. - - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - - 8. Termination. - - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - - 9. Acceptance Not Required for Having Copies. - - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - - 10. Automatic Licensing of Downstream Recipients. - - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - - 11. Patents. - - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - - 12. No Surrender of Others' Freedom. - - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - - 13. Remote Network Interaction; Use with the GNU General Public License. - - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - - 14. Revised Versions of this License. - - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - - 15. Disclaimer of Warranty. - - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - - 16. Limitation of Liability. - - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - - 17. Interpretation of Sections 15 and 16. - - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - - END OF TERMS AND CONDITIONS - - - How to Apply These Terms to Your New Programs - - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - - Copyright (C) - - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - - -Also add information on how to contact you by electronic and paper mail. - - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/README.md b/README.md index d0e87696ae8..9f641fb3841 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/dow Other platforms are not yet available: -- Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396)) +- Web ([tracking discussion](https://github.com/zed-industries/zed/discussions/26195)) ### Developing Zed @@ -29,6 +29,8 @@ Also... we're hiring! Check out our [jobs](https://zed.dev/jobs) page for open r ### Licensing +Zed source code is licensed primarily under GPL-3.0-or-later, with Apache-2.0 components where marked. + License information for third party dependencies must be correctly provided for CI to pass. We use [`cargo-about`](https://github.com/EmbarkStudios/cargo-about) to automatically comply with open source licenses. If CI is failing, check the following: diff --git a/assets/icons/acp_registry.svg b/assets/icons/acp_registry.svg index fb64ea6fbcf..d98728fbbd0 100644 --- a/assets/icons/acp_registry.svg +++ b/assets/icons/acp_registry.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/assets/icons/ai_lm_studio.svg b/assets/icons/ai_lm_studio.svg index 5cfdeb5578c..eef6bfcdb86 100644 --- a/assets/icons/ai_lm_studio.svg +++ b/assets/icons/ai_lm_studio.svg @@ -1,15 +1,15 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/assets/icons/ai_ollama.svg b/assets/icons/ai_ollama.svg index 36a88c1ad6d..93071a78730 100644 --- a/assets/icons/ai_ollama.svg +++ b/assets/icons/ai_ollama.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/ai_open_ai.svg b/assets/icons/ai_open_ai.svg index e45ac315a01..857a03091bd 100644 --- a/assets/icons/ai_open_ai.svg +++ b/assets/icons/ai_open_ai.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/ai_x_ai.svg b/assets/icons/ai_x_ai.svg index d3400fbe9cd..dabee6f54df 100644 --- a/assets/icons/ai_x_ai.svg +++ b/assets/icons/ai_x_ai.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/ai_zed.svg b/assets/icons/ai_zed.svg index 6d78efacd5f..5ba2dbed183 100644 --- a/assets/icons/ai_zed.svg +++ b/assets/icons/ai_zed.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/bitbucket.svg b/assets/icons/bitbucket.svg new file mode 100644 index 00000000000..823ffc00c3c --- /dev/null +++ b/assets/icons/bitbucket.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/circle.svg b/assets/icons/circle.svg index 1d80edac09e..c33c37f5f9d 100644 --- a/assets/icons/circle.svg +++ b/assets/icons/circle.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/codeberg.svg b/assets/icons/codeberg.svg new file mode 100644 index 00000000000..52be5909b31 --- /dev/null +++ b/assets/icons/codeberg.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/editor_atom.svg b/assets/icons/editor_atom.svg index cc5fa83843f..ca9c3380c43 100644 --- a/assets/icons/editor_atom.svg +++ b/assets/icons/editor_atom.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_cursor.svg b/assets/icons/editor_cursor.svg index e20013917d3..28eea301f7b 100644 --- a/assets/icons/editor_cursor.svg +++ b/assets/icons/editor_cursor.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_emacs.svg b/assets/icons/editor_emacs.svg index 951d7b2be16..3dbb2683969 100644 --- a/assets/icons/editor_emacs.svg +++ b/assets/icons/editor_emacs.svg @@ -1,10 +1,8 @@ - - + + + + + - - - - - diff --git a/assets/icons/editor_jet_brains.svg b/assets/icons/editor_jet_brains.svg index 7d9cf0c65cd..94d30903f6c 100644 --- a/assets/icons/editor_jet_brains.svg +++ b/assets/icons/editor_jet_brains.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/editor_sublime.svg b/assets/icons/editor_sublime.svg index 95a04f6b541..92bf14977d4 100644 --- a/assets/icons/editor_sublime.svg +++ b/assets/icons/editor_sublime.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/editor_vs_code.svg b/assets/icons/editor_vs_code.svg index 2a71ad52af2..d1aef6fce4b 100644 --- a/assets/icons/editor_vs_code.svg +++ b/assets/icons/editor_vs_code.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/file_icons/ballerina.svg b/assets/icons/file_icons/ballerina.svg new file mode 100644 index 00000000000..4a8287252c6 --- /dev/null +++ b/assets/icons/file_icons/ballerina.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/forgejo.svg b/assets/icons/forgejo.svg new file mode 100644 index 00000000000..b818af4e020 --- /dev/null +++ b/assets/icons/forgejo.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/gitea.svg b/assets/icons/gitea.svg new file mode 100644 index 00000000000..c3c6abec2dd --- /dev/null +++ b/assets/icons/gitea.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/gitlab.svg b/assets/icons/gitlab.svg new file mode 100644 index 00000000000..d7c5c6b2b49 --- /dev/null +++ b/assets/icons/gitlab.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/share.svg b/assets/icons/share.svg new file mode 100644 index 00000000000..00d2d09b93b --- /dev/null +++ b/assets/icons/share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/text_unwrap.svg b/assets/icons/text_unwrap.svg new file mode 100644 index 00000000000..1dda70014be --- /dev/null +++ b/assets/icons/text_unwrap.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/text_wrap.svg b/assets/icons/text_wrap.svg new file mode 100644 index 00000000000..64ec35a2941 --- /dev/null +++ b/assets/icons/text_wrap.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 0988611644f..47bd6f05aca 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -380,15 +380,7 @@ "shift-backspace": "agent::ArchiveSelectedThread", }, }, - { - "context": "RulesLibrary", - "bindings": { - "new": "rules_library::NewRule", - "ctrl-n": "rules_library::NewRule", - "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow", - }, - }, + { "context": "BufferSearchBar", "bindings": { @@ -729,6 +721,7 @@ "use_key_equivalents": true, "bindings": { "space": "menu::Confirm", + "shift-r": "agent::RenameSelectedThread", }, }, { @@ -1558,4 +1551,22 @@ "shift-tab": "git_graph::FocusPreviousTabStop", }, }, + { + "context": "SkillCreator", + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "ctrl-enter": "skill_creator::SaveSkill", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, + { + "context": "SkillCreator > Editor", + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "ctrl-enter": "skill_creator::SaveSkill", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4c9aa4c0bce..dbd6d64a719 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -427,15 +427,6 @@ "backspace": "agent::ArchiveSelectedThread", }, }, - { - "context": "RulesLibrary", - "use_key_equivalents": true, - "bindings": { - "cmd-n": "rules_library::NewRule", - "cmd-shift-s": "rules_library::ToggleDefaultRule", - "cmd-w": "workspace::CloseWindow", - }, - }, { "context": "BufferSearchBar", "use_key_equivalents": true, @@ -785,6 +776,7 @@ "use_key_equivalents": true, "bindings": { "space": "menu::Confirm", + "shift-r": "agent::RenameSelectedThread", }, }, { @@ -1651,4 +1643,24 @@ "shift-tab": "git_graph::FocusPreviousTabStop", }, }, + { + "context": "SkillCreator", + "use_key_equivalents": true, + "bindings": { + "cmd-w": "workspace::CloseWindow", + "cmd-enter": "skill_creator::SaveSkill", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, + { + "context": "SkillCreator > Editor", + "use_key_equivalents": true, + "bindings": { + "cmd-w": "workspace::CloseWindow", + "cmd-enter": "skill_creator::SaveSkill", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ea11c7f423b..1996fe19f66 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -383,15 +383,6 @@ "shift-backspace": "agent::ArchiveSelectedThread", }, }, - { - "context": "RulesLibrary", - "use_key_equivalents": true, - "bindings": { - "ctrl-n": "rules_library::NewRule", - "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow", - }, - }, { "context": "BufferSearchBar", "use_key_equivalents": true, @@ -732,6 +723,7 @@ "use_key_equivalents": true, "bindings": { "space": "menu::Confirm", + "shift-r": "agent::RenameSelectedThread", }, }, { @@ -1577,4 +1569,24 @@ "shift-tab": "git_graph::FocusPreviousTabStop", }, }, + { + "context": "SkillCreator", + "use_key_equivalents": true, + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "ctrl-enter": "skill_creator::SaveSkill", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, + { + "context": "SkillCreator > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "ctrl-enter": "skill_creator::SaveSkill", + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, ] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 396c6e40852..ef414018681 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -338,7 +338,7 @@ "ctrl-x": "vim::Decrement", "shift-j": "vim::JoinLines", "i": "vim::InsertBefore", - "a": "vim::InsertAfter", + "a": "vim::HelixAppend", "o": "vim::InsertLineBelow", "shift-o": "vim::InsertLineAbove", "p": "vim::Paste", @@ -494,6 +494,10 @@ "n": "vim::HelixSelectNext", "shift-n": "vim::HelixSelectPrevious", + // Macros — Helix swaps Vim's q/Q: Q records, q replays + "q": "vim::ReplayLastRecording", + "shift-q": "vim::ToggleRecord", + // Goto mode "g e": "vim::EndOfDocument", "g h": "vim::StartOfLine", @@ -503,6 +507,8 @@ "g c": "vim::WindowMiddle", "g b": "vim::WindowBottom", "g r": "editor::FindAllReferences", + "g i": "editor::GoToImplementation", + "g a": "pane::AlternateFile", "g n": "pane::ActivateNextItem", "shift-l": "pane::ActivateNextItem", // not a helix default "g p": "pane::ActivatePreviousItem", @@ -943,7 +949,7 @@ "space w j": "workspace::ActivatePaneDown", "space w k": "workspace::ActivatePaneUp", "space w l": "workspace::ActivatePaneRight", - "space w q": "pane::CloseActiveItem", + "space w q": "pane::CloseActiveItem", }, }, { @@ -1056,8 +1062,8 @@ "ctrl-d": "git_graph::ScrollDown", "ctrl-u": "git_graph::ScrollUp", "shift-g": "menu::SelectLast", - "g g": "menu::SelectFirst" - } + "g g": "menu::SelectFirst", + }, }, { "context": "GitPanel && ChangesList && !GitBranchSelector", @@ -1205,4 +1211,18 @@ "enter": "editor::Newline", }, }, + { + "context": "SkillCreator", + "bindings": { + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, + { + "context": "SkillCreator > Editor", + "bindings": { + "tab": "skill_creator::FocusNextField", + "shift-tab": "skill_creator::FocusPreviousField", + }, + }, ] diff --git a/assets/settings/default.json b/assets/settings/default.json index b22e8589183..37c06960555 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -71,6 +71,8 @@ "agent_ui_font_size": null, // The default font size for user messages in the agent panel. "agent_buffer_font_size": 12, + // The default font size for the commit editor in the git panel and commit modal. + "git_commit_buffer_font_size": 12, // How much to fade out unused code. "unnecessary_code_fade": 0.3, // Active pane styling settings. @@ -1133,6 +1135,7 @@ "spawn_agent": true, "terminal": true, "update_plan": true, + "update_title": true, "search_web": true, }, }, @@ -1153,6 +1156,7 @@ "skill": true, "spawn_agent": true, "update_plan": true, + "update_title": true, "search_web": true, }, }, @@ -1496,6 +1500,10 @@ // 4. Draw a background behind the color text.. // "lsp_document_colors": "background", "lsp_document_colors": "inlay", + // Whether to query and display LSP `textDocument/documentLink` links in the editor. + // + // Default: true + "lsp_document_links": true, // Diagnostics configuration. "diagnostics": { // Whether to show the project diagnostics button in the status bar. diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 987db1dcf8e..c22259f94b1 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -13,7 +13,7 @@ path = "src/acp_thread.rs" doctest = false [features] -test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot", "dep:image"] +test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"] [dependencies] action_log.workspace = true @@ -35,10 +35,10 @@ language_model.workspace = true log.workspace = true markdown.workspace = true parking_lot = { workspace = true, optional = true } -image = { workspace = true, optional = true } +image.workspace = true portable-pty.workspace = true project.workspace = true -prompt_store.workspace = true +sandbox.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index afd8aeda5f3..c42694cd8c6 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -16,7 +16,9 @@ use gpui::{ }; use itertools::Itertools; use language::language_settings::FormatOnSave; -use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff}; +use language::{ + Anchor, Buffer, BufferEditSource, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff, +}; use markdown::{Markdown, MarkdownOptions}; pub use mention::*; use project::lsp_store::{FormatTrigger, LspFormatTarget}; @@ -648,9 +650,16 @@ impl Display for ToolCallStatus { #[derive(Debug, PartialEq, Clone)] pub enum ContentBlock { Empty, - Markdown { markdown: Entity }, - ResourceLink { resource_link: acp::ResourceLink }, - Image { image: Arc }, + Markdown { + markdown: Entity, + }, + ResourceLink { + resource_link: acp::ResourceLink, + }, + Image { + image: Arc, + dimensions: Option>, + }, } impl ContentBlock { @@ -692,8 +701,8 @@ impl ContentBlock { }; } (ContentBlock::Empty, acp::ContentBlock::Image(image_content)) => { - if let Some(image) = Self::decode_image(image_content) { - *self = ContentBlock::Image { image }; + if let Some((image, dimensions)) = Self::decode_image(image_content) { + *self = ContentBlock::Image { image, dimensions }; } else { let new_content = Self::image_md(image_content); *self = Self::create_markdown_block(new_content, language_registry, cx); @@ -721,14 +730,36 @@ impl ContentBlock { } } - fn decode_image(image_content: &acp::ImageContent) -> Option> { + fn decode_image( + image_content: &acp::ImageContent, + ) -> Option<(Arc, Option>)> { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD .decode(image_content.data.as_bytes()) .ok()?; let format = gpui::ImageFormat::from_mime_type(&image_content.mime_type)?; - Some(Arc::new(gpui::Image::from_bytes(format, bytes))) + let dimensions = Self::image_dimensions(&bytes, format); + Some((Arc::new(gpui::Image::from_bytes(format, bytes)), dimensions)) + } + + fn image_dimensions(bytes: &[u8], format: gpui::ImageFormat) -> Option> { + let format = match format { + gpui::ImageFormat::Png => image::ImageFormat::Png, + gpui::ImageFormat::Jpeg => image::ImageFormat::Jpeg, + gpui::ImageFormat::Webp => image::ImageFormat::WebP, + gpui::ImageFormat::Gif => image::ImageFormat::Gif, + gpui::ImageFormat::Svg => return None, + gpui::ImageFormat::Bmp => image::ImageFormat::Bmp, + gpui::ImageFormat::Tiff => image::ImageFormat::Tiff, + gpui::ImageFormat::Ico => image::ImageFormat::Ico, + gpui::ImageFormat::Pnm => image::ImageFormat::Pnm, + }; + + image::ImageReader::with_format(std::io::Cursor::new(bytes), format) + .into_dimensions() + .ok() + .map(|(width, height)| gpui::Size { width, height }) } fn create_markdown_block( @@ -744,6 +775,7 @@ impl ContentBlock { None, MarkdownOptions { render_mermaid_diagrams: true, + render_metadata_blocks: true, ..Default::default() }, cx, @@ -808,9 +840,9 @@ impl ContentBlock { } } - pub fn image(&self) -> Option<&Arc> { + pub fn image(&self) -> Option<(&Arc, Option>)> { match self { - ContentBlock::Image { image } => Some(image), + ContentBlock::Image { image, dimensions } => Some((image, *dimensions)), _ => None, } } @@ -895,7 +927,7 @@ impl ToolCallContent { } } - pub fn image(&self) -> Option<&Arc> { + pub fn image(&self) -> Option<(&Arc, Option>)> { match self { Self::ContentBlock(content) => content.image(), _ => None, @@ -2883,7 +2915,9 @@ impl AcpThread { }); let format_on_save = buffer.update(cx, |buffer, cx| { + buffer.start_transaction(); buffer.edit(edits, None, cx); + buffer.end_transaction_with_source(BufferEditSource::Agent, cx); let settings = language::language_settings::LanguageSettings::for_buffer(buffer, cx); @@ -2926,6 +2960,7 @@ impl AcpThread { extra_env: Vec, cwd: Option, output_byte_limit: Option, + sandbox_wrap: Option, cx: &mut Context, ) -> Task>> { let env = match &cwd { @@ -2966,6 +3001,8 @@ impl AcpThread { ShellBuilder::new(&Shell::Program(shell), is_windows) .redirect_stdin_to_dev_null() .build(Some(command.clone()), &args); + let (task_command, task_args, sandbox_config) = + apply_sandbox_wrap(task_command, task_args, sandbox_wrap)?; let terminal = project .update(cx, |project, cx| { project.create_terminal_task( @@ -2989,6 +3026,7 @@ impl AcpThread { output_byte_limit.map(|l| l as usize), terminal, language_registry, + sandbox_config, cx, ) })) @@ -3068,6 +3106,9 @@ impl AcpThread { output_byte_limit.map(|l| l as usize), terminal, language_registry, + // External terminal providers manage their own sandboxing + // (if any). We don't wrap their commands. + None, cx, ) }); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 41cdd1250b3..7085d45746b 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -7,14 +7,14 @@ use gpui::{Entity, SharedString, Task}; use language_model::LanguageModelProviderId; use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; -use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc, sync::Arc}; +use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc}; use task::{HideStrategy, SpawnInTerminal, TaskId}; use ui::{App, IconName}; use util::path_list::PathList; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] -pub struct UserMessageId(Arc); +pub struct UserMessageId(SharedString); impl UserMessageId { pub fn new() -> Self { @@ -115,6 +115,11 @@ pub trait AgentConnection { self.supports_load_session() || self.supports_resume_session() } + /// Whether this agent supports additional session directories. + fn supports_session_additional_directories(&self) -> bool { + false + } + fn auth_methods(&self) -> &[acp::AuthMethod]; fn terminal_auth_task( @@ -127,6 +132,14 @@ pub trait AgentConnection { fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task>; + fn supports_logout(&self) -> bool { + false + } + + fn logout(&self, _cx: &mut App) -> Task> { + Task::ready(Err(anyhow::Error::msg("Logout is not supported"))) + } + fn prompt( &self, user_message_id: UserMessageId, @@ -310,7 +323,7 @@ pub trait AgentSessionList { cx: &mut App, ) -> Task>; - fn supports_delete(&self) -> bool { + fn supports_delete(&self, _cx: &App) -> bool { false } @@ -694,6 +707,7 @@ mod test_support { permission_requests: HashMap, next_prompt_updates: Arc>>, supports_load_session: bool, + supports_session_additional_directories: bool, agent_id: AgentId, telemetry_id: SharedString, } @@ -716,6 +730,7 @@ mod test_support { permission_requests: HashMap::default(), sessions: Arc::default(), supports_load_session: false, + supports_session_additional_directories: false, agent_id: AgentId::new("stub"), telemetry_id: "stub".into(), } @@ -738,6 +753,14 @@ mod test_support { self } + pub fn with_supports_session_additional_directories( + mut self, + supports_session_additional_directories: bool, + ) -> Self { + self.supports_session_additional_directories = supports_session_additional_directories; + self + } + pub fn with_agent_id(mut self, agent_id: AgentId) -> Self { self.agent_id = agent_id; self @@ -855,6 +878,10 @@ mod test_support { self.supports_load_session } + fn supports_session_additional_directories(&self) -> bool { + self.supports_session_additional_directories + } + fn load_session( self: Rc, session_id: acp::SessionId, diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 6fc2cc50c1f..f2423858523 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -1,7 +1,6 @@ use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result, bail}; use file_icons::FileIcons; -use prompt_store::{PromptId, UserPromptId}; use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, @@ -37,10 +36,6 @@ pub enum MentionUri { id: acp::SessionId, name: String, }, - Rule { - id: PromptId, - name: String, - }, Diagnostics { #[serde(default = "default_include_errors")] include_errors: bool, @@ -51,6 +46,8 @@ pub enum MentionUri { #[serde(default, skip_serializing_if = "Option::is_none")] abs_path: Option, line_range: RangeInclusive, + #[serde(default, skip_serializing_if = "Option::is_none")] + column: Option, }, Fetch { url: Url, @@ -105,6 +102,17 @@ impl MentionUri { Ok(start_line..=end_line) } + let parse_column = + |input: Option| -> Option { input?.parse::().ok()?.checked_sub(1) }; + let validate_query_params = |url: &Url, allowed: &[&str]| -> Result<()> { + for (key, _) in url.query_pairs() { + if !allowed.contains(&key.as_ref()) { + bail!("invalid query parameter") + } + } + Ok(()) + }; + let parse_absolute_path = |input: &str| -> Result { let (path_input, fragment) = input .split_once('#') @@ -114,6 +122,7 @@ impl MentionUri { return Ok(MentionUri::Selection { abs_path: Some(path_input.into()), line_range: fragment, + column: None, }); } @@ -123,10 +132,12 @@ impl MentionUri { let line = row .checked_sub(1) .context("Line numbers should be 1-based")?; - // TODO: Preserve column info too. Ok(MentionUri::Selection { abs_path: Some(abs_path), line_range: line..=line, + column: path_with_position + .column + .map(|column| column.saturating_sub(1)), }) } else { Ok(MentionUri::File { abs_path }) @@ -156,8 +167,10 @@ impl MentionUri { let path = normalized.as_ref(); if let Some(fragment) = url.fragment() { + validate_query_params(&url, &["symbol", "column"])?; let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1); - if let Some(name) = single_query_param(&url, "symbol")? { + let column = parse_column(query_param(&url, "column")); + if let Some(name) = query_param(&url, "symbol") { Ok(Self::Symbol { name, abs_path: path.into(), @@ -167,6 +180,7 @@ impl MentionUri { Ok(Self::Selection { abs_path: Some(path.into()), line_range, + column, }) } } else if input.ends_with("/") { @@ -186,13 +200,6 @@ impl MentionUri { id: acp::SessionId::new(thread_id), name, }) - } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") { - let name = single_query_param(&url, "name")?.context("Missing rule name")?; - let rule_id = UserPromptId(rule_id.parse()?); - Ok(Self::Rule { - id: rule_id.into(), - name, - }) } else if path == "/agent/diagnostics" { let mut include_errors = default_include_errors(); let mut include_warnings = false; @@ -216,9 +223,11 @@ impl MentionUri { .fragment() .context("Missing fragment for untitled buffer selection")?; let line_range = parse_line_range(fragment)?; + validate_query_params(&url, &["column"])?; Ok(Self::Selection { abs_path: None, line_range, + column: parse_column(query_param(&url, "column")), }) } else if let Some(name) = path.strip_prefix("/agent/symbol/") { let fragment = url @@ -245,13 +254,15 @@ impl MentionUri { abs_path: path.into(), }) } else if path.starts_with("/agent/selection") { + validate_query_params(&url, &["path", "column"])?; let fragment = url.fragment().context("Missing fragment for selection")?; let line_range = parse_line_range(fragment)?; - let path = - single_query_param(&url, "path")?.context("Missing path for selection")?; + let column = parse_column(query_param(&url, "column")); + let path = query_param(&url, "path").context("Missing path for selection")?; Ok(Self::Selection { abs_path: Some(path.into()), line_range, + column, }) } else if path.starts_with("/agent/terminal-selection") { let line_count = single_query_param(&url, "lines")? @@ -319,7 +330,6 @@ impl MentionUri { MentionUri::PastedImage { name } => name.clone(), MentionUri::Symbol { name, .. } => name.clone(), MentionUri::Thread { name, .. } => name.clone(), - MentionUri::Rule { name, .. } => name.clone(), MentionUri::Diagnostics { .. } => "Diagnostics".to_string(), MentionUri::TerminalSelection { line_count } => { if *line_count == 1 { @@ -342,13 +352,33 @@ impl MentionUri { .. } => selection_name(path.as_deref(), line_range), MentionUri::Fetch { url } => url.to_string(), + MentionUri::Skill { name, .. } => name.clone(), + } + } + + /// Returns a label for this mention at the given disambiguation `detail` + /// level. `detail == 0` is the base name returned by [`Self::name`]; higher + /// levels include progressively more context (e.g. additional parent path + /// components for files, or the source for skills) until a fixed point is + /// reached. Intended to be driven by [`util::disambiguate::compute_disambiguation_details`]. + pub fn disambiguated_name(&self, detail: usize) -> String { + if detail == 0 { + return self.name(); + } + + match self { MentionUri::Skill { name, source, .. } => { if source.is_empty() { + // Must match `SkillSource::display_label()` in agent_skills. format!("{} (global)", name) } else { format!("{} ({})", name, source) } } + MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => { + project::path_suffix(abs_path, detail) + } + _ => self.name(), } } @@ -400,7 +430,6 @@ impl MentionUri { .unwrap_or_else(|| IconName::Folder.path().into()), MentionUri::Symbol { .. } => IconName::Code.path().into(), MentionUri::Thread { .. } => IconName::Thread.path().into(), - MentionUri::Rule { .. } => IconName::Reader.path().into(), MentionUri::Diagnostics { .. } => IconName::Warning.path().into(), MentionUri::TerminalSelection { .. } => IconName::Terminal.path().into(), MentionUri::Selection { .. } => IconName::Reader.path().into(), @@ -440,6 +469,7 @@ impl MentionUri { abs_path, name, line_range, + .. } => { let mut url = Url::parse("file:///").unwrap(); url.set_path(&abs_path.to_string_lossy()); @@ -454,6 +484,7 @@ impl MentionUri { MentionUri::Selection { abs_path, line_range, + column, } => { let mut url = if let Some(path) = abs_path { let mut url = Url::parse("file:///").unwrap(); @@ -464,6 +495,10 @@ impl MentionUri { url.set_path("/agent/untitled-buffer"); url }; + if let Some(column) = column { + url.query_pairs_mut() + .append_pair("column", &(column + 1).to_string()); + } url.set_fragment(Some(&format!( "L{}:{}", line_range.start() + 1, @@ -477,12 +512,6 @@ impl MentionUri { url.query_pairs_mut().append_pair("name", name); url } - MentionUri::Rule { name, id } => { - let mut url = Url::parse("zed:///").unwrap(); - url.set_path(&format!("/agent/rule/{id}")); - url.query_pairs_mut().append_pair("name", name); - url - } MentionUri::Diagnostics { include_errors, include_warnings, @@ -544,6 +573,11 @@ fn default_include_errors() -> bool { true } +fn query_param(url: &Url, name: &'static str) -> Option { + url.query_pairs() + .find_map(|(key, value)| (key == name).then(|| value.to_string())) +} + fn single_query_param(url: &Url, name: &'static str) -> Result> { let pairs = url.query_pairs().collect::>(); match pairs.as_slice() { @@ -678,6 +712,7 @@ mod tests { abs_path: path, name, line_range, + .. } => { assert_eq!(path, Path::new(path!("/path/to/file.rs"))); assert_eq!(name, "MySymbol"); @@ -697,6 +732,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(line_range.start(), &4); @@ -728,6 +764,7 @@ mod tests { MentionUri::Selection { abs_path: None, line_range, + .. } => { assert_eq!(line_range.start(), &0); assert_eq!(line_range.end(), &9); @@ -754,20 +791,6 @@ mod tests { assert_eq!(parsed.to_uri().to_string(), thread_uri); } - #[test] - fn test_parse_rule_uri() { - let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule"; - let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap(); - match &parsed { - MentionUri::Rule { id, name } => { - assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52"); - assert_eq!(name, "Some rule"); - } - _ => panic!("Expected Rule variant"), - } - assert_eq!(parsed.to_uri().to_string(), rule_uri); - } - #[test] fn test_parse_skill_uri_round_trip() { let skill_uri = MentionUri::Skill { @@ -875,6 +898,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); assert_eq!(line_range.start(), &41); @@ -884,6 +908,29 @@ mod tests { } } + #[test] + fn test_parse_absolute_file_path_with_row_and_column() { + let file_path = "/path/to/file.rs:42:5"; + let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap(); + match &parsed { + MentionUri::Selection { + abs_path: path, + line_range, + column, + } => { + assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); + assert_eq!(line_range.start(), &41); + assert_eq!(line_range.end(), &41); + assert_eq!(column, &Some(4)); + + let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Posix) + .expect("selection URI with column should parse"); + assert_eq!(parsed_again, parsed.clone()); + } + _ => panic!("Expected Selection variant"), + } + } + #[test] fn test_parse_absolute_file_path_with_fragment_line() { let file_path = "/path/to/file.rs#L42"; @@ -892,6 +939,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); assert_eq!(line_range.start(), &41); @@ -921,6 +969,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!( path.as_ref().unwrap(), @@ -941,6 +990,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!( path.as_ref().unwrap(), @@ -973,6 +1023,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs")); assert_eq!(line_range.start(), &41); @@ -990,6 +1041,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!( path.as_ref().unwrap(), @@ -1011,6 +1063,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(line_range.start(), &1871); @@ -1028,6 +1081,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(line_range.start(), &9); @@ -1043,6 +1097,7 @@ mod tests { MentionUri::Selection { abs_path: path, line_range, + .. } => { assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); assert_eq!(line_range.start(), &9); @@ -1070,4 +1125,68 @@ mod tests { let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap(); assert_eq!(parsed_single.name(), "Terminal (1 line)"); } + + #[test] + fn test_disambiguated_name() { + // Two files with the same name — should disambiguate with parent dir + let file_a = MentionUri::File { + abs_path: PathBuf::from(path!("/project/src/README.md")), + }; + let file_b = MentionUri::File { + abs_path: PathBuf::from(path!("/project/docs/README.md")), + }; + assert_eq!(file_a.name(), "README.md"); + assert_eq!(file_b.name(), "README.md"); + assert_eq!(file_a.disambiguated_name(0), "README.md"); + assert_eq!(file_a.disambiguated_name(1), "src/README.md"); + assert_eq!(file_b.disambiguated_name(1), "docs/README.md"); + + // Files that still collide at one parent should grow further. + let deep_a = MentionUri::File { + abs_path: PathBuf::from(path!("/a/src/foo.rs")), + }; + let deep_b = MentionUri::File { + abs_path: PathBuf::from(path!("/b/src/foo.rs")), + }; + assert_eq!(deep_a.disambiguated_name(1), "src/foo.rs"); + assert_eq!(deep_b.disambiguated_name(1), "src/foo.rs"); + assert_eq!(deep_a.disambiguated_name(2), "a/src/foo.rs"); + assert_eq!(deep_b.disambiguated_name(2), "b/src/foo.rs"); + + // Two skills with the same name — should disambiguate with source + let global_skill = MentionUri::Skill { + name: "create-skill".into(), + source: "".into(), + skill_file_path: PathBuf::from("/global/create-skill/SKILL.md"), + }; + let project_skill = MentionUri::Skill { + name: "create-skill".into(), + source: "my-project".into(), + skill_file_path: PathBuf::from("/project/create-skill/SKILL.md"), + }; + assert_eq!(global_skill.name(), "create-skill"); + assert_eq!(global_skill.disambiguated_name(0), "create-skill"); + assert_eq!(global_skill.disambiguated_name(1), "create-skill (global)"); + assert_eq!( + project_skill.disambiguated_name(1), + "create-skill (my-project)" + ); + + // A type without special disambiguation (Thread) — detail has no effect + // (the value is a fixed point so the disambiguation loop terminates). + let thread = MentionUri::Thread { + id: acp::SessionId::new("123"), + name: "My Thread".into(), + }; + assert_eq!(thread.disambiguated_name(0), "My Thread"); + assert_eq!(thread.disambiguated_name(1), "My Thread"); + assert_eq!(thread.disambiguated_name(5), "My Thread"); + + // Edge case: file at filesystem root has no parent to show + let root_file = MentionUri::File { + abs_path: PathBuf::from(path!("/README.md")), + }; + assert_eq!(root_file.disambiguated_name(1), "README.md"); + assert_eq!(root_file.disambiguated_name(5), "README.md"); + } } diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 2fe769cb737..32496b8f446 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -17,6 +17,82 @@ use std::{ use task::Shell; use util::get_default_system_shell_preferring_bash; +/// Request to run a terminal command inside an OS-level sandbox. +/// +/// Passed to [`super::AcpThread::create_terminal`]. The actual sandboxing +/// mechanism is platform-specific (today: macOS Seatbelt; nothing on other +/// platforms — the wrap is silently a no-op there), so callers describe the +/// *intent* with plain data here rather than constructing platform-specific +/// types directly. +/// +/// All-zero defaults are the fully-sandboxed run. Setting `allow_network` / +/// `allow_fs_write` requests a relaxation; the caller is responsible for +/// having obtained user approval before reaching this point. +#[derive(Clone, Debug, Default)] +pub struct SandboxWrap { + /// Directory subtrees the sandbox should allow writes to. Pass the + /// project's worktree paths (and any per-command scratch directory) + /// here — *not* the command's working directory, which is model- + /// controlled and would let the model widen its own writable scope. + pub writable_paths: Vec, + /// Allow outbound network access for this command. + pub allow_network: bool, + /// Allow unrestricted filesystem writes (ignores `writable_paths`). + pub allow_fs_write: bool, +} + +/// Opaque RAII handle the sandbox implementation hands back to keep its +/// per-command resources (e.g. an on-disk Seatbelt config file) alive for +/// the duration of the spawned command. `Terminal` holds it in a field +/// whose only job is to drop with the entity. +pub type SandboxConfigHandle = Box; + +/// Apply a [`SandboxWrap`] to a `(program, args)` pair, substituting the +/// platform's sandbox-launcher invocation in place of the original. The +/// returned `SandboxConfigHandle` (when `Some`) must be kept alive for the +/// duration of the spawned command — dropping it deletes any on-disk +/// config the launcher reads at startup. +/// +/// On non-macOS hosts this is a no-op: the inputs pass through unchanged +/// and the returned handle is `None`. (We don't yet have a sandbox +/// integration for other platforms.) +pub(crate) fn apply_sandbox_wrap( + program: String, + args: Vec, + sandbox_wrap: Option, +) -> anyhow::Result<(String, Vec, Option)> { + let Some(sandbox_wrap) = sandbox_wrap else { + return Ok((program, args, None)); + }; + + #[cfg(target_os = "macos")] + { + let writable: Vec<&std::path::Path> = sandbox_wrap + .writable_paths + .iter() + .map(|p| p.as_path()) + .collect(); + let permissions = sandbox::macos_seatbelt::SandboxPermissions { + allow_network: sandbox_wrap.allow_network, + allow_fs_write: sandbox_wrap.allow_fs_write, + }; + let (new_program, new_args, config_file) = + sandbox::macos_seatbelt::wrap_invocation(&program, &args, &writable, permissions)?; + Ok(( + new_program, + new_args, + Some(Box::new(config_file) as SandboxConfigHandle), + )) + } + #[cfg(not(target_os = "macos"))] + { + // No sandbox integration available; ignore the wrap request and + // let the command run with the agent's ambient permissions. + let _ = sandbox_wrap; + Ok((program, args, None)) + } +} + pub struct Terminal { id: acp::TerminalId, command: Entity, @@ -30,6 +106,10 @@ pub struct Terminal { /// (e.g., clicking the Stop button). This is set before kill() is called /// so that code awaiting wait_for_exit() can check it deterministically. user_stopped: Arc, + /// RAII handle kept alive for the duration of the sandboxed command. + /// `None` when the command isn't sandboxed (the common case for + /// terminals not created by the agent). + _sandbox_config: Option, } pub struct TerminalOutput { @@ -48,11 +128,13 @@ impl Terminal { output_byte_limit: Option, terminal: Entity, language_registry: Arc, + sandbox_config: Option, cx: &mut Context, ) -> Self { let command_task = terminal.read(cx).wait_for_completed_task(cx); Self { id, + _sandbox_config: sandbox_config, command: cx.new(|cx| { Markdown::new( format!("```\n{}\n```", command_label).into(), diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 8801379578f..695e2beb440 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -508,6 +508,7 @@ impl AcpTools { } else { CopyButtonVisibility::Hidden }, + wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }, ), diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 89b91cb7501..99cc0a2d79b 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -936,7 +936,11 @@ impl ActionLog { let mut undo_buffers = Vec::new(); let mut futures = Vec::new(); - for buffer in self.changed_buffers(cx).into_keys() { + for buffer in self + .changed_buffers(cx) + .map(|(buffer, _)| buffer) + .collect::>() + { let buffer_ranges = vec![Anchor::min_max_range_for_buffer( buffer.read(cx).remote_id(), )]; @@ -1023,17 +1027,19 @@ impl ActionLog { } /// Returns the set of buffers that contain edits that haven't been reviewed by the user. - pub fn changed_buffers(&self, cx: &App) -> BTreeMap, Entity> { + pub fn changed_buffers( + &self, + cx: &App, + ) -> impl Iterator, Entity)> { self.tracked_buffers .iter() .filter(|(_, tracked)| tracked.has_edits(cx)) .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone())) - .collect() } /// Returns the total number of lines added and removed across all unreviewed buffers. pub fn diff_stats(&self, cx: &App) -> DiffStats { - DiffStats::all_files(&self.changed_buffers(cx), cx) + DiffStats::all_files(self.changed_buffers(cx), cx) } /// Iterate over buffers changed since last read or edited by the model @@ -1079,7 +1085,7 @@ impl DiffStats { } pub fn all_files( - changed_buffers: &BTreeMap, Entity>, + changed_buffers: impl IntoIterator, Entity)>, cx: &App, ) -> Self { let mut total = DiffStats::default(); @@ -1334,7 +1340,7 @@ mod tests { use std::env; use util::{RandomCharIter, path}; - #[ctor::ctor] + #[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } @@ -3254,21 +3260,21 @@ mod tests { child_log_1 .read(cx) .changed_buffers(cx) - .into_keys() + .map(|(buffer, _)| buffer) .collect() }); let child_2_changed: Vec<_> = cx.read(|cx| { child_log_2 .read(cx) .changed_buffers(cx) - .into_keys() + .map(|(buffer, _)| buffer) .collect() }); let parent_changed: Vec<_> = cx.read(|cx| { parent_log .read(cx) .changed_buffers(cx) - .into_keys() + .map(|(buffer, _)| buffer) .collect() }); @@ -3494,7 +3500,6 @@ mod tests { action_log .read(cx) .changed_buffers(cx) - .into_iter() .map(|(buffer, diff)| { let snapshot = buffer.read(cx).snapshot(); ( diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 77a9cd33f42..d1f9877af3e 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -65,6 +65,7 @@ streaming_diff.workspace = true strsim.workspace = true task.workspace = true telemetry.workspace = true +tempfile.workspace = true text.workspace = true thiserror.workspace = true ui.workspace = true @@ -77,11 +78,13 @@ zed_env_vars.workspace = true zstd.workspace = true [dev-dependencies] +assets.workspace = true async-io.workspace = true agent_servers = { workspace = true, "features" = ["test-support"] } client = { workspace = true, "features" = ["test-support"] } clock = { workspace = true, "features" = ["test-support"] } context_server = { workspace = true, "features" = ["test-support"] } +criterion.workspace = true ctor.workspace = true db = { workspace = true, "features" = ["test-support"] } editor = { workspace = true, "features" = ["test-support"] } @@ -99,10 +102,15 @@ project = { workspace = true, "features" = ["test-support"] } rand.workspace = true reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } -tempfile.workspace = true theme = { workspace = true, "features" = ["test-support"] } +theme_settings.workspace = true unindent = { workspace = true } zlog.workspace = true + +[[bench]] +name = "edit_file_tool" +harness = false +required-features = ["test-support"] diff --git a/crates/agent/benches/edit_file_tool.rs b/crates/agent/benches/edit_file_tool.rs new file mode 100644 index 00000000000..7080b01200e --- /dev/null +++ b/crates/agent/benches/edit_file_tool.rs @@ -0,0 +1,743 @@ +use std::{ + any::Any, + future::Future, + path::Path, + sync::Arc, + task::{Context, Poll}, +}; + +use action_log::ActionLog; +use agent::{ + AgentTool, ContextServerRegistry, EditFileTool, EditFileToolInput, EditFileToolOutput, + Templates, Thread, ToolCallEventStream, ToolInput, +}; +use agent_settings::{AgentSettings, ToolRules}; +use criterion::{ + BatchSize, BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main, +}; +use editor::{Editor, EditorStyle}; +use futures::{StreamExt as _, pin_mut, task::noop_waker}; +use gpui::{ + AnyWindowHandle, AppContext as _, BackgroundExecutor, Entity, Focusable as _, TestAppContext, + UpdateGlobal as _, +}; +use language::{FakeLspAdapter, rust_lang}; +use language_model::fake_provider::FakeLanguageModel; +use project::{FakeFs, Project}; +use prompt_store::ProjectContext; +use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; +use serde_json::{Value, json}; +use settings::{Settings as _, SettingsStore}; +use ui::IntoElement as _; + +const SEED: u64 = 0x5EED_5EED; +const OLD_TEXT_CHUNK_SIZE: usize = 512; +const NEW_TEXT_CHUNK_SIZE: usize = 512; + +const FILE_PROJECT_PATH: &str = "root/src/workspace_snapshot.rs"; +const FILE_ABS_PATH: &str = "/root/src/workspace_snapshot.rs"; + +#[derive(Clone)] +struct EditOp { + old_text: String, + new_text: String, +} + +#[derive(Clone)] +struct EditFixture { + name: &'static str, + old_file_text: String, + expected_file_text: String, + edits: Vec, +} + +struct BenchmarkHarness { + cx: Option, + edit_tool: Option>, + thread: Option>, + partial_payloads: Vec, + final_payload: Value, + expected_file_text: String, + editor: Option>, + window: Option, + // Keeps the LSP buffer-registration handle and the fake language server alive + // for the lifetime of the benchmark so `didChange`/diagnostics keep flowing + // while edits are applied. + keep_alive: Vec>, +} + +impl Drop for BenchmarkHarness { + fn drop(&mut self) { + // Release our handles to the entities first. + self.edit_tool.take(); + self.thread.take(); + self.editor.take(); + self.keep_alive.clear(); + + if let Some(mut cx) = self.cx.take() { + // Close the editor window so the editor entity and the buffer handles + // it holds are released, then pump the executor so cancelled editor / + // action-log background tasks drop their captured handles before the + // leak detector runs on `TestAppContext` drop. + if let Some(window) = self.window.take() { + cx.update_window(window, |_, window, _| window.remove_window()) + .ok(); + } + cx.update(|_| {}); + cx.executor().run_until_parked(); + cx.quit(); + } + } +} + +fn edit_file_tool_streaming(c: &mut Criterion) { + let fixtures = fixtures(); + let mut group = c.benchmark_group("edit_file_tool_streaming"); + group.sample_size(10); + + for fixture in fixtures { + let new_bytes: usize = fixture.edits.iter().map(|edit| edit.new_text.len()).sum(); + group.throughput(Throughput::Bytes(new_bytes as u64)); + group.bench_with_input( + BenchmarkId::new(fixture.name, fixture.old_file_text.len()), + &fixture, + |bench, fixture| { + bench.iter_batched( + || setup_harness(fixture.clone()), + |mut harness| { + let output = run_streamed_edit(&mut harness); + let EditFileToolOutput::Success { new_text, .. } = &output else { + panic!("expected edit_file tool to succeed"); + }; + assert_eq!(new_text, &harness.expected_file_text); + // Return the harness as part of the output so its teardown (which has + // to pump the executor to release `Entity` handles captured by + // background tasks) runs in criterion's drop phase after the timer has + // stopped, rather than inside the timed region. + (black_box(output), harness) + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + +fn setup_harness(fixture: EditFixture) -> BenchmarkHarness { + let mut cx = init_context(); + let executor = cx.executor(); + let parts = block_on_executor( + &executor, + setup_editor_and_tool(&mut cx, fixture.old_file_text.clone()), + ); + // Let the LSP handshake, initial parse, and first layout settle before timing. + cx.executor().run_until_parked(); + + let partial_payloads = streamed_partial_payloads(&fixture.edits); + let final_payload = json!({ + "path": FILE_PROJECT_PATH, + "edits": fixture + .edits + .iter() + .map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text })) + .collect::>(), + }); + + BenchmarkHarness { + cx: Some(cx), + edit_tool: Some(parts.edit_tool), + thread: Some(parts.thread), + partial_payloads, + final_payload, + expected_file_text: fixture.expected_file_text, + editor: Some(parts.editor), + window: Some(parts.window), + keep_alive: parts.keep_alive, + } +} + +struct HarnessParts { + edit_tool: Arc, + thread: Entity, + editor: Entity, + window: AnyWindowHandle, + keep_alive: Vec>, +} + +/// Builds a project + edit tool, opens the target buffer in an editor view inside +/// a window, and attaches a fake Rust language server. This mirrors the real app: +/// the edited file is open in a pane with a language server, so each buffer edit +/// drives the editor's observer cascade (matching brackets, code actions, outline, +/// bracket colorization), a tree-sitter reparse, and an LSP `didChange` + +/// diagnostics round-trip — the costs that dominate a real agent edit. +async fn setup_editor_and_tool(cx: &mut TestAppContext, file_text: String) -> HarnessParts { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "src": { + "workspace_snapshot.rs": file_text, + }, + }), + ) + .await; + + let project = Project::test(fs, [Path::new("/root")], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + text_document_sync: Some(lsp::TextDocumentSyncCapability::Kind( + lsp::TextDocumentSyncKind::INCREMENTAL, + )), + ..Default::default() + }, + ..Default::default() + }, + ); + + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let action_log: Entity = + thread.read_with(cx, |thread, _cx| thread.action_log().clone()); + let edit_tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + action_log, + language_registry, + )); + + // Open the same buffer the tool will edit and register it with the language + // servers so edits produce `didChange` notifications. + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(FILE_ABS_PATH, cx) + }) + .await + .expect("failed to open buffer"); + let lsp_handle = project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + + let fake_server = fake_servers + .next() + .await + .expect("fake language server should start"); + // Publish diagnostics on every edit, mirroring a real server reacting to + // `didChange`, so the editor's diagnostics path runs per edit. + let server = fake_server.clone(); + fake_server.handle_notification::( + move |params, _cx| { + server.notify::(lsp::PublishDiagnosticsParams { + uri: params.text_document.uri.clone(), + version: Some(params.text_document.version), + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: "bench diagnostic".to_string(), + ..Default::default() + }], + }); + }, + ); + + // Attach an editor view in a window and lay it out once so the viewport-gated + // observers (bracket colorization, selection highlights) have a visible range. + let window = cx.add_window(|window, cx| { + let mut editor = Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx); + editor.set_style(EditorStyle::default(), window, cx); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + let editor = window.root(cx).expect("window should have an editor root"); + let window: AnyWindowHandle = window.into(); + // Lay out and paint a real frame so the editor establishes a viewport (this + // is what makes the viewport-gated observers like bracket colorization run). + { + let mut visual_cx = gpui::VisualTestContext::from_window(window, &*cx); + visual_cx.draw( + gpui::point(gpui::px(0.0), gpui::px(0.0)), + gpui::size(gpui::px(1024.0), gpui::px(768.0)), + |_, _| editor.clone().into_any_element(), + ); + } + + let keep_alive: Vec> = vec![ + Box::new(lsp_handle), + Box::new(fake_server), + Box::new(fake_servers), + Box::new(buffer), + ]; + + HarnessParts { + edit_tool, + thread, + editor, + window, + keep_alive, + } +} + +fn init_context() -> TestAppContext { + let cx = TestAppContext::single(); + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + assets::Assets.load_test_fonts(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .ensure_final_newline_on_save = Some(false); + settings.project.all_languages.defaults.colorize_brackets = Some(true); + }); + }); + + let mut agent_settings = AgentSettings::get_global(cx).clone(); + agent_settings.tool_permissions.tools.insert( + EditFileTool::NAME.into(), + ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + AgentSettings::override_global(agent_settings, cx); + }); + cx +} + +fn run_streamed_edit(harness: &mut BenchmarkHarness) -> EditFileToolOutput { + let (mut sender, input): (_, ToolInput) = ToolInput::test(); + for payload in &harness.partial_payloads { + sender.send_partial(payload.clone()); + } + sender.send_full(harness.final_payload.clone()); + + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let cx = harness + .cx + .as_ref() + .expect("benchmark harness should have a cx"); + let task = cx.update(|cx| { + harness + .edit_tool + .as_ref() + .expect("benchmark harness should have an edit tool") + .clone() + .run(input, event_stream, cx) + }); + + let executor = harness + .cx + .as_ref() + .expect("benchmark harness should have a cx") + .executor(); + block_on_executor(&executor, task).unwrap() +} + +fn block_on_executor(executor: &BackgroundExecutor, future: impl Future) -> R { + pin_mut!(future); + let waker = noop_waker(); + let mut task_context = Context::from_waker(&waker); + + for _ in 0..10_000 { + if let Poll::Ready(output) = future.as_mut().poll(&mut task_context) { + return output; + } + executor.run_until_parked(); + } + + panic!("future did not complete while running edit_file_tool benchmark"); +} + +/// Builds the streamed partial payloads for a (possibly multi-edit) session, +/// mirroring how the agent reveals one edit at a time: earlier edits stay +/// complete in the array while the current edit streams its `old_text` then its +/// `new_text` in chunks. +fn streamed_partial_payloads(edits: &[EditOp]) -> Vec { + let path = FILE_PROJECT_PATH; + let mut payloads = vec![json!({ "path": path }), json!({ "path": path })]; + + for index in 0..edits.len() { + let completed: Vec = edits[..index] + .iter() + .map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text })) + .collect(); + let edit = &edits[index]; + + for old_end in chunk_ends(&edit.old_text, OLD_TEXT_CHUNK_SIZE) { + let mut arr = completed.clone(); + arr.push(json!({ "old_text": &edit.old_text[..old_end] })); + payloads.push(json!({ "path": path, "edits": arr })); + } + + let mut arr = completed.clone(); + arr.push(json!({ "old_text": edit.old_text, "new_text": "" })); + payloads.push(json!({ "path": path, "edits": arr })); + + for new_end in chunk_ends(&edit.new_text, NEW_TEXT_CHUNK_SIZE) { + let mut arr = completed.clone(); + arr.push(json!({ "old_text": edit.old_text, "new_text": &edit.new_text[..new_end] })); + payloads.push(json!({ "path": path, "edits": arr })); + } + } + + payloads +} + +fn chunk_ends(text: &str, chunk_size: usize) -> impl Iterator + '_ { + let mut end = 0; + std::iter::from_fn(move || { + if end == text.len() { + return None; + } + + end = (end + chunk_size).min(text.len()); + while !text.is_char_boundary(end) { + end -= 1; + } + Some(end) + }) +} + +fn fixtures() -> Vec { + vec![ + make_fixture( + "tiny_function_rewrite", + 2, + EditPattern::LocalizedRewrite { + start_line: 12, + line_count: 6, + }, + SEED, + ), + make_fixture( + "small_function_rewrite", + 5, + EditPattern::LocalizedRewrite { + start_line: 22, + line_count: 12, + }, + SEED + 1, + ), + make_fixture( + "medium_many_small_changes", + 8, + EditPattern::ManySmallChanges { every_nth_line: 7 }, + SEED + 2, + ), + make_fixture( + "medium_insertions", + 8, + EditPattern::InsertHelperBlocks { every_nth_line: 9 }, + SEED + 3, + ), + make_large_multi_edit_fixture("large_multi_edit", 80, 16, SEED + 4), + ] +} + +enum EditPattern { + LocalizedRewrite { + start_line: usize, + line_count: usize, + }, + ManySmallChanges { + every_nth_line: usize, + }, + InsertHelperBlocks { + every_nth_line: usize, + }, +} + +fn make_fixture( + name: &'static str, + function_count: usize, + pattern: EditPattern, + seed: u64, +) -> EditFixture { + let mut rng = StdRng::seed_from_u64(seed); + let old_lines = random_rust_module(&mut rng, function_count); + let edit_range = edit_range(&old_lines, &pattern); + let old_text = old_lines[edit_range.clone()].join("\n"); + let mut new_lines = old_lines.clone(); + + match pattern { + EditPattern::LocalizedRewrite { .. } => { + rewrite_local_block(&mut new_lines[edit_range.clone()], &mut rng) + } + EditPattern::ManySmallChanges { every_nth_line } => { + rewrite_many_small_lines(&mut new_lines[edit_range.clone()], every_nth_line, &mut rng) + } + EditPattern::InsertHelperBlocks { every_nth_line } => { + insert_helper_blocks(&mut new_lines, edit_range.clone(), every_nth_line, &mut rng) + } + } + + let new_text_end = edit_range.end + new_lines.len().saturating_sub(old_lines.len()); + let old_file_text = old_lines.join("\n"); + let expected_file_text = new_lines.join("\n"); + let new_text = new_lines[edit_range.start..new_text_end].join("\n"); + + EditFixture { + name, + old_file_text, + expected_file_text, + edits: vec![EditOp { old_text, new_text }], + } +} + +fn make_large_multi_edit_fixture( + name: &'static str, + function_count: usize, + edit_count: usize, + seed: u64, +) -> EditFixture { + const HEADER_LINES: usize = 10; + const FUNCTION_LINES: usize = 12; + const FUNCTION_BODY_LINES: usize = 11; + + let mut rng = StdRng::seed_from_u64(seed); + let old_lines = random_rust_module(&mut rng, function_count); + let old_file_text = old_lines.join("\n"); + + let step = (function_count / edit_count).max(1); + let mut picks: Vec = (0..edit_count) + .map(|k| (k * step).min(function_count - 1)) + .collect(); + picks.dedup(); + + let replacements: Vec<(usize, Vec)> = picks + .iter() + .map(|&function_index| { + ( + function_index, + large_function_lines(&mut rng, function_index), + ) + }) + .collect(); + + let edits = replacements + .iter() + .map(|(function_index, new_function)| { + let start = HEADER_LINES + function_index * FUNCTION_LINES; + let end = start + FUNCTION_BODY_LINES; + EditOp { + old_text: old_lines[start..end].join("\n"), + new_text: new_function.join("\n"), + } + }) + .collect(); + + let mut new_lines = old_lines; + for (function_index, new_function) in replacements.iter().rev() { + let start = HEADER_LINES + function_index * FUNCTION_LINES; + let end = start + FUNCTION_BODY_LINES; + new_lines.splice(start..end, new_function.iter().cloned()); + } + let expected_file_text = new_lines.join("\n"); + + EditFixture { + name, + old_file_text, + expected_file_text, + edits, + } +} + +fn large_function_lines(rng: &mut StdRng, index: usize) -> Vec { + let function_name = identifier(rng, index + 40_000); + let argument_name = identifier(rng, index + 41_000); + + let mut lines = vec![ + format!( + " pub fn {function_name}(&mut self, {argument_name}: usize) -> Result {{" + ), + format!(" let mut accumulator = {argument_name};"), + ]; + + let body_lines = rng.random_range(30..42); + for body_index in 0..body_lines { + let local_name = identifier(rng, index + 50_000 + body_index); + let multiplier = rng.random_range(2..19); + let offset = rng.random_range(1..256); + match body_index % 4 { + 0 => lines.push(format!( + " let {local_name} = accumulator.saturating_mul({multiplier}).saturating_add({offset});" + )), + 1 => lines.push(format!( + " accumulator = {local_name}.saturating_sub(self.version % {offset}.max(1));" + )), + 2 => lines.push(format!( + " if {local_name} % {multiplier} == 0 {{ accumulator = accumulator.saturating_add({local_name}); }}" + )), + _ => lines.push(format!( + " self.buffers.insert(\"{local_name}\".to_string(), accumulator);" + )), + } + } + + lines.push(" self.version = self.version.saturating_add(accumulator);".to_string()); + lines.push(" Ok(accumulator)".to_string()); + lines.push(" }".to_string()); + lines +} + +fn edit_range(lines: &[String], pattern: &EditPattern) -> std::ops::Range { + let mut range = match pattern { + EditPattern::LocalizedRewrite { + start_line, + line_count, + } => *start_line..(*start_line + *line_count).min(lines.len()), + EditPattern::ManySmallChanges { .. } | EditPattern::InsertHelperBlocks { .. } => { + 10..lines.len().saturating_sub(5) + } + }; + + while range.end > range.start && lines[range.end - 1].is_empty() { + range.end -= 1; + } + + range +} + +fn random_rust_module(rng: &mut StdRng, function_count: usize) -> Vec { + let mut lines = vec![ + "use anyhow::{Context as _, Result};".to_string(), + "use collections::HashMap;".to_string(), + "".to_string(), + "#[derive(Clone, Debug)]".to_string(), + "pub struct WorkspaceSnapshot {".to_string(), + " buffers: HashMap,".to_string(), + " version: usize,".to_string(), + "}".to_string(), + "".to_string(), + "impl WorkspaceSnapshot {".to_string(), + ]; + + for function_index in 0..function_count { + let function_name = identifier(rng, function_index); + let argument_name = identifier(rng, function_index + 1_000); + let local_name = identifier(rng, function_index + 2_000); + let branch_name = identifier(rng, function_index + 3_000); + let multiplier = rng.random_range(2..17); + let offset = rng.random_range(1..128); + + lines.extend([ + format!( + " pub fn {function_name}(&mut self, {argument_name}: usize) -> Result {{" + ), + format!(" let mut {local_name} = {argument_name}.saturating_mul({multiplier});"), + format!(" if {local_name} % 2 == 0 {{"), + format!( + " {local_name} = {local_name}.saturating_add(self.version + {offset});" + ), + " } else {".to_string(), + format!(" {local_name} = {local_name}.saturating_sub({offset});"), + " }".to_string(), + format!(" let {branch_name} = self.buffers.len().saturating_add({local_name});"), + format!(" self.version = self.version.saturating_add({branch_name});"), + format!(" Ok({branch_name})"), + " }".to_string(), + "".to_string(), + ]); + } + + lines.push("}".to_string()); + lines.push("".to_string()); + lines.push("pub fn normalize_path(path: &str) -> String {".to_string()); + lines.push(" path.replace('\\\\', \"/\")".to_string()); + lines.push("}".to_string()); + lines +} + +fn rewrite_local_block(lines: &mut [String], rng: &mut StdRng) { + for (line_index, line) in lines.iter_mut().enumerate() { + let suffix = identifier(rng, line_index + 10_000); + if line.contains("saturating_add") { + *line = format!( + " let {suffix} = self.version.checked_add({line_index}).context(\"version overflow\")?;" + ); + } else if line.contains("saturating_sub") { + *line = format!( + " {suffix}.saturating_sub({});", + rng.random_range(8..256) + ); + } else if line.trim().is_empty() { + *line = + format!(" tracing::trace!(target: \"agent_bench\", value = {line_index});"); + } else { + *line = format!("{line} // updated {suffix}"); + } + } +} + +fn rewrite_many_small_lines(lines: &mut [String], every_nth_line: usize, rng: &mut StdRng) { + for (line_index, line) in lines.iter_mut().enumerate() { + if line_index.is_multiple_of(every_nth_line) || line.trim().is_empty() { + continue; + } + + let suffix = identifier(rng, line_index + 20_000); + *line = format!("{line} // audited {suffix}"); + } +} + +fn insert_helper_blocks( + lines: &mut Vec, + range: std::ops::Range, + every_nth_line: usize, + rng: &mut StdRng, +) { + let mut line_index = range.start; + while line_index < range.end.min(lines.len()) { + if line_index.is_multiple_of(every_nth_line) && !lines[line_index].trim().is_empty() { + let suffix = identifier(rng, line_index + 30_000); + lines.splice( + line_index..line_index, + [ + format!(" let {suffix}_before = self.version;"), + format!(" tracing::debug!(version = {suffix}_before);"), + ], + ); + line_index += 2; + } + line_index += 1; + } +} + +fn identifier(rng: &mut StdRng, salt: usize) -> String { + const PARTS: &[&str] = &[ + "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "theta", "lambda", "sigma", "omega", + ]; + format!( + "{}_{}_{}", + PARTS[rng.random_range(0..PARTS.len())], + salt, + rng.random_range(0..10_000) + ) +} + +criterion_group!(benches, edit_file_tool_streaming); +criterion_main!(benches); diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index a8b70c80b47..9f8dc9f242e 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -3,6 +3,7 @@ mod legacy_thread; mod native_agent_server; pub mod outline; mod pattern_extraction; +mod sandboxing; mod templates; #[cfg(test)] mod tests; @@ -10,7 +11,6 @@ mod thread; mod thread_store; mod tool_permissions; mod tools; -mod user_agents_md; use context_server::ContextServerId; pub use db::*; @@ -23,7 +23,6 @@ pub use thread::*; pub use thread_store::*; pub use tool_permissions::*; pub use tools::*; -pub use user_agents_md::{UserAgentsMd, UserAgentsMdState, init as init_user_agents_md}; use acp_thread::{ AcpThread, AgentModelSelector, AgentSessionInfo, AgentSessionList, AgentSessionListRequest, @@ -31,13 +30,14 @@ use acp_thread::{ }; use agent_client_protocol::schema as acp; use agent_skills::{ - MAX_SKILL_DESCRIPTIONS_SIZE, Skill, SkillLoadError, SkillScopeId, SkillSource, SkillSummary, - global_skills_dir, load_skills_from_directory, project_skills_relative_path, + MAX_SKILL_DESCRIPTIONS_SIZE, ProjectSkillGroup, Skill, SkillIndex, SkillLoadError, + SkillScopeId, SkillSource, SkillSummary, builtin_skills, global_skills_dir, + load_skills_from_directory, project_skills_relative_path, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; -use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag}; + use fs::Fs; use futures::channel::{mpsc, oneshot}; use futures::future::Shared; @@ -50,10 +50,7 @@ use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageMo use project::{ AgentId, Project, ProjectItem, ProjectPath, Worktree, trusted_worktrees::TrustedWorktrees, }; -use prompt_store::{ - ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, - WorktreeContext, -}; +use prompt_store::{ProjectContext, RULES_FILE_NAMES, RulesFileContext, WorktreeContext}; use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; use std::any::Any; @@ -104,7 +101,7 @@ impl From<&Skill> for NativeAvailableSkill { Self { name: skill.name.clone(), description: skill.description.clone(), - source: skill.source.scope_prefix().to_string().into(), + source: skill.source.display_label().to_string().into(), skill_file_path: skill.skill_file_path.clone(), } } @@ -307,7 +304,6 @@ pub struct NativeAgent { templates: Arc, /// Cached model information models: LanguageModels, - prompt_store: Option>, fs: Arc, _subscriptions: Vec, /// Tracks the lifecycle of global skills directory observation. We @@ -354,19 +350,19 @@ impl NativeAgent { pub fn new( thread_store: Entity, templates: Arc, - prompt_store: Option>, fs: Arc, cx: &mut App, ) -> Entity { log::debug!("Creating new NativeAgent"); cx.new(|cx| { - let mut subscriptions = vec![cx.subscribe( + let subscriptions = vec![cx.subscribe( &LanguageModelRegistry::global(cx), Self::handle_models_updated_event, )]; - if let Some(prompt_store) = prompt_store.as_ref() { - subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) + + if !cx.has_global::() { + cx.set_global(SkillIndex::default()); } Self { @@ -376,7 +372,6 @@ impl NativeAgent { projects: HashMap::default(), templates, models: LanguageModels::new(cx), - prompt_store, fs, _subscriptions: subscriptions, skills_state: SkillsState::default(), @@ -387,11 +382,11 @@ impl NativeAgent { /// Kicks off a one-time scan of the global skills directory if one /// isn't already in progress and a watch isn't already active. /// - /// Idempotent and cheap: returns immediately if the user lacks the - /// skills feature flag, or if a scan or watch is already running. - /// The expected callers are user-interaction events from the agent - /// panel (input focus, slash autocomplete, conversation submit); - /// firing this from any of them is equivalent and safe to repeat. + /// Idempotent and cheap: returns immediately if a scan or watch is + /// already running. The expected callers are user-interaction events + /// from the agent panel (input focus, slash autocomplete, conversation + /// submit); firing this from any of them is equivalent and safe to + /// repeat. /// /// The scan itself runs detached on the foreground executor. If /// `~/.agents/skills/` exists it transitions state to @@ -400,9 +395,6 @@ impl NativeAgent { /// next trigger retries (covering the case where the user creates /// the directory after the first scan). pub fn ensure_skills_scan_started(&mut self, cx: &mut Context) { - if !cx.has_flag::() { - return; - } if !matches!(self.skills_state, SkillsState::Idle) { return; } @@ -593,12 +585,10 @@ impl NativeAgent { // after the thread is constructed are still visible to the // model — without this, the catalog and tool would drift out // of sync until the session was reopened. - if cx.has_flag::() { - thread.add_tool(SkillTool::new( - skills_resolver_for_project(weak.clone(), project_id), - self.fs.clone(), - )); - } + thread.add_tool(SkillTool::new( + skills_resolver_for_project(weak.clone(), project_id), + self.fs.clone(), + )); }); let subscriptions = vec![ @@ -640,7 +630,7 @@ impl NativeAgent { return project_id; } - let project_context = cx.new(|_| ProjectContext::new(vec![], vec![])); + let project_context = cx.new(|_| ProjectContext::new(vec![])); self.register_project_with_initial_context(project.clone(), project_context, cx); if let Some(state) = self.projects.get_mut(&project_id) { state.project_context_needs_refresh.send(()).ok(); @@ -733,7 +723,6 @@ impl NativeAgent { .context("project state not found")?; anyhow::Ok(Self::build_project_context( &state.project, - this.prompt_store.as_ref(), this.fs.clone(), cx, )) @@ -796,6 +785,7 @@ impl NativeAgent { // the available commands) can change without affecting the // skill error list. this.update_available_commands_for_project(project_id, cx); + this.publish_skill_index(cx); })?; } @@ -804,7 +794,6 @@ impl NativeAgent { fn build_project_context( project: &Entity, - prompt_store: Option<&Entity>, fs: Arc, cx: &mut App, ) -> Task<(ProjectContext, Vec, Vec)> { @@ -816,12 +805,8 @@ impl NativeAgent { }) .collect::>(); - // Skills are gated behind the "skills" feature flag. Without it we - // skip all on-disk lookups so users see no behavior change. - let skills_enabled = cx.has_flag::(); - // Load global skills - let global_skills_task = if skills_enabled { + let global_skills_task = { let global_skills_dir = global_skills_dir(); let global_skills_fs = fs.clone(); cx.background_spawn(async move { @@ -832,8 +817,6 @@ impl NativeAgent { ) .await }) - } else { - Task::ready(Vec::new()) }; // Load project-local skills, but only from worktrees the user has @@ -846,7 +829,7 @@ impl NativeAgent { // worktrees pick up their skills without restarting. let trusted_worktrees = TrustedWorktrees::try_get_global(cx); let worktree_store = project.read(cx).worktree_store(); - let project_skills_task = if skills_enabled { + let project_skills_task = { let project_skills_futures: Vec< futures::future::BoxFuture<'static, Vec>>, > = worktrees @@ -891,25 +874,9 @@ impl NativeAgent { }) .collect(); cx.background_spawn(async move { future::join_all(project_skills_futures).await }) - } else { - Task::ready(Vec::new()) }; - let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() { - prompt_store.read_with(cx, |prompt_store, cx| { - let prompts = prompt_store.default_prompt_metadata(); - let load_tasks = prompts.into_iter().map(|prompt_metadata| { - let contents = prompt_store.load(prompt_metadata.id, cx); - async move { (contents.await, prompt_metadata) } - }); - cx.background_spawn(future::join_all(load_tasks)) - }) - } else { - Task::ready(vec![]) - }; - cx.spawn(async move |_cx| { - let (worktrees, default_user_rules) = - future::join(future::join_all(worktree_tasks), default_user_rules_task).await; + let worktrees = future::join_all(worktree_tasks).await; let worktrees = worktrees .into_iter() @@ -922,27 +889,6 @@ impl NativeAgent { }) .collect::>(); - let default_user_rules = default_user_rules - .into_iter() - .flat_map(|(contents, prompt_metadata)| match contents { - Ok(contents) => Some(UserRulesContext { - uuid: prompt_metadata.id.as_user()?, - title: prompt_metadata.title.map(|title| title.to_string()), - contents, - }), - Err(_err) => { - // TODO: show error message - // this.update(cx, |_, cx| { - // cx.emit(RulesLoadingError { - // message: format!("{err:?}").into(), - // }); - // }) - // .ok(); - None - } - }) - .collect::>(); - // Load and combine skills. `combine_skills` deliberately // does NOT deduplicate — the autocomplete popup needs to // see every entry so users can disambiguate same-named @@ -966,8 +912,7 @@ impl NativeAgent { let (catalog_skills, budget_errors) = select_catalog_skills(&overridden); skill_errors.extend(budget_errors); - let project_context = - ProjectContext::new(worktrees, default_user_rules).with_skills(catalog_skills); + let project_context = ProjectContext::new(worktrees).with_skills(catalog_skills); (project_context, skills, skill_errors) }) } @@ -1101,7 +1046,7 @@ impl NativeAgent { &mut self, project: Entity, event: &project::Event, - cx: &mut Context, + _cx: &mut Context, ) { let project_id = project.entity_id(); let Some(state) = self.projects.get_mut(&project_id) else { @@ -1112,16 +1057,14 @@ impl NativeAgent { state.project_context_needs_refresh.send(()).ok(); } project::Event::WorktreeUpdatedEntries(_, items) => { - let skills_enabled = cx.has_flag::(); if items.iter().any(|(path, _, _)| { let path_ref = path.as_ref(); RULES_FILE_REL_PATHS .iter() .any(|rules_path| path_ref == rules_path.as_ref()) - || (skills_enabled - && SKILLS_PREFIX - .as_ref() - .is_some_and(|prefix| path_ref.starts_with(prefix))) + || SKILLS_PREFIX + .as_ref() + .is_some_and(|prefix| path_ref.starts_with(prefix)) }) { state.project_context_needs_refresh.send(()).ok(); } @@ -1130,17 +1073,6 @@ impl NativeAgent { } } - fn handle_prompts_updated_event( - &mut self, - _prompt_store: Entity, - _event: &prompt_store::PromptsUpdatedEvent, - _cx: &mut Context, - ) { - for state in self.projects.values_mut() { - state.project_context_needs_refresh.send(()).ok(); - } - } - fn handle_models_updated_event( &mut self, _registry: Entity, @@ -1213,6 +1145,50 @@ impl NativeAgent { } } + fn publish_skill_index(&self, cx: &mut Context) { + let mut global_skills = Vec::new(); + let mut project_groups: Vec = Vec::new(); + let mut seen_global = false; + + for state in self.projects.values() { + for skill in state.skills.iter() { + match &skill.source { + SkillSource::BuiltIn => {} + SkillSource::Global => { + if !seen_global { + global_skills.push(skill.clone()); + } + } + SkillSource::ProjectLocal { + worktree_id, + worktree_root_name, + } => { + if let Some(group) = project_groups + .iter_mut() + .find(|g| g.worktree_id == *worktree_id) + { + group.skills.push(skill.clone()); + } else { + project_groups.push(ProjectSkillGroup { + worktree_id: *worktree_id, + worktree_root_name: SharedString::from(worktree_root_name.clone()), + skills: vec![skill.clone()], + }); + } + } + } + } + if !global_skills.is_empty() { + seen_global = true; + } + } + + cx.set_global(SkillIndex { + global_skills, + project_skills: project_groups, + }); + } + fn update_available_commands_for_project(&self, project_id: EntityId, cx: &mut Context) { let available_commands = Self::build_available_commands_for_project(self.projects.get(&project_id), cx); @@ -1450,6 +1426,7 @@ impl NativeAgent { let has_remaining = self.sessions.values().any(|s| s.project_id == project_id); if !has_remaining { self.projects.remove(&project_id); + self.publish_skill_index(cx); } session.pending_save @@ -1644,14 +1621,18 @@ impl NativeAgent { // Read the body on demand here — bodies live on disk between // materializations to keep memory cost O(total frontmatter) // rather than O(total file size). - let body = agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path) - .await - .with_context(|| { - format!( - "Failed to read skill body from {}", - skill.skill_file_path.display() - ) - })?; + let body = if let Some(embedded) = skill.embedded_body { + embedded.to_string() + } else { + agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path) + .await + .with_context(|| { + format!( + "Failed to read skill body from {}", + skill.skill_file_path.display() + ) + })? + }; let envelope = crate::tools::render_skill_envelope(&skill, &body); let envelope_block = acp::ContentBlock::Text(acp::TextContent::new(envelope)); @@ -1726,6 +1707,16 @@ impl NativeAgentConnection { .update(cx, |agent, cx| agent.ensure_skills_scan_started(cx)); } + pub fn refresh_skills_for_project(&self, project: Entity, cx: &mut App) { + self.0.update(cx, |agent, cx| { + let project_id = agent.get_or_create_project_state(&project, cx); + agent.ensure_skills_scan_started(cx); + if let Some(state) = agent.projects.get_mut(&project_id) { + state.project_context_needs_refresh.send(()).ok(); + } + }); + } + pub fn available_skills( &self, session_id: &acp::SessionId, @@ -1794,10 +1785,10 @@ impl NativeAgentConnection { match event { ThreadEvent::UserMessage(message) => { acp_thread.update(cx, |thread, cx| { - for content in message.content { + for content in &*message.content { thread.push_user_content_block( Some(message.id.clone()), - content.into(), + content.clone().into(), cx, ); } @@ -2245,9 +2236,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { // we don't clone the entire skill list on every prompt // (including prompts like `/help` that aren't skills at // all). The resolution rule matches the override-applied - // view: prefer a project-local with the matching name, - // falling back to a global, so the slash command picks the - // same entry the model sees in its catalog. + // view: among skills with the matching name, pick the one + // with the highest source precedence, so the slash command + // picks the same entry the model sees in its catalog. + // Ties (e.g. two project-local skills from different + // worktrees) resolve to the first in iteration order to + // match `apply_skill_overrides`. if parsed_command.explicit_server_id.is_none() && parsed_command.skill_scope.is_none() && !project_state.skills.is_empty() @@ -2256,15 +2250,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let resolved = project_state .skills .iter() - .find(|skill| { - skill.name == prompt_name - && matches!(skill.source, SkillSource::ProjectLocal { .. }) - }) - .or_else(|| { - project_state - .skills - .iter() - .find(|skill| skill.name == prompt_name) + .filter(|skill| skill.name == prompt_name) + .reduce(|best, candidate| { + if candidate.source.precedence() > best.source.precedence() { + candidate + } else { + best + } }); if let Some(skill) = resolved { let skill = skill.clone(); @@ -2428,7 +2420,7 @@ impl AgentSessionList for NativeAgentSessionList { Task::ready(Ok(AgentSessionListResponse::new(sessions))) } - fn supports_delete(&self) -> bool { + fn supports_delete(&self, _cx: &App) -> bool { true } @@ -2618,12 +2610,54 @@ impl ThreadEnvironment for NativeThreadEnvironment { fn create_terminal( &self, command: String, + extra_env: Vec, cwd: Option, output_byte_limit: Option, + sandbox_wrap: Option, cx: &mut AsyncApp, ) -> Task>> { + // Use a per-thread temp directory for all terminal commands, even when + // sandboxing is disabled, so the model can't infer sandbox state from + // `$TMPDIR` changing between conversations. + let mut extra_env = extra_env; + let mut sandbox_wrap = sandbox_wrap; + match self + .thread + .update(cx, |thread, cx| thread.sandboxed_terminal_temp_dir(cx)) + { + Ok(Ok(temp_dir)) => { + // Canonicalize so the path matches what the sandbox resolves + // symlinks to (e.g. `/var` -> `/private/var` on macOS). + // `$TMPDIR` and the writable-scope entry below must agree, and + // they must agree with the path the kernel actually checks. + let temp_dir = temp_dir.canonicalize().unwrap_or(temp_dir); + let temp_dir_string = temp_dir.to_string_lossy().into_owned(); + extra_env.extend([ + acp::EnvVariable::new("TMPDIR", &temp_dir_string), + acp::EnvVariable::new("TMP", &temp_dir_string), + acp::EnvVariable::new("TEMP", &temp_dir_string), + ]); + // The command's `$TMPDIR` must live inside the sandbox's + // writable scope. The per-thread temp directory is owned here + // (not in the terminal tool that assembles the rest of the + // writable set), so add it whenever the command is sandboxed. + if let Some(sandbox_wrap) = &mut sandbox_wrap { + sandbox_wrap.writable_paths.push(temp_dir); + } + } + Ok(Err(error)) => return Task::ready(Err(error)), + Err(error) => return Task::ready(Err(error)), + }; let task = self.acp_thread.update(cx, |thread, cx| { - thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx) + thread.create_terminal( + command, + vec![], + extra_env, + cwd, + output_byte_limit, + sandbox_wrap, + cx, + ) }); let acp_thread = self.acp_thread.clone(); @@ -2960,7 +2994,9 @@ fn combine_skills( global: Vec>, project: impl Iterator>, ) -> (Vec, Vec) { - let mut skills = Vec::new(); + // Built-in skills go first (lowest priority) so that global and + // project-local skills with the same name shadow them. + let mut skills = builtin_skills(); let mut errors = Vec::new(); for result in global.into_iter().chain(project) { match result { @@ -2979,17 +3015,16 @@ fn log_skill_conflicts(skills: &[Skill]) { let mut by_name: HashMap<&str, &Skill> = HashMap::default(); for skill in skills { match by_name.get(skill.name.as_str()) { - Some(existing) => match (&existing.source, &skill.source) { - (SkillSource::Global, SkillSource::ProjectLocal { .. }) => { + Some(existing) => { + if skill.source.precedence() > existing.source.precedence() { log::warn!( - "Project skill '{}' at '{}' overrides global skill at '{}' for the model; both appear in the slash-command popup with their source", + "Skill '{}' at '{}' overrides skill at '{}' for the model; both appear in the slash-command popup with their source", skill.name, skill.skill_file_path.display(), existing.skill_file_path.display(), ); by_name.insert(skill.name.as_str(), skill); - } - _ => { + } else { log::warn!( "Skill '{}' at '{}' conflicts with skill at '{}'; the model will see the first one, but both appear in the slash-command popup with their source", skill.name, @@ -2997,7 +3032,7 @@ fn log_skill_conflicts(skills: &[Skill]) { existing.skill_file_path.display(), ); } - }, + } None => { by_name.insert(skill.name.as_str(), skill); } @@ -3024,9 +3059,7 @@ fn apply_skill_overrides(skills: &[Skill]) -> Vec { for skill in skills { match indices.get(skill.name.as_str()).copied() { Some(idx) => { - if matches!(result[idx].source, SkillSource::Global) - && matches!(skill.source, SkillSource::ProjectLocal { .. }) - { + if skill.source.precedence() > result[idx].source.precedence() { result[idx] = skill.clone(); } } @@ -3064,6 +3097,7 @@ mod internal_tests { directory_path: PathBuf::from(format!("/home/user/.agents/skills/{name}")), skill_file_path: PathBuf::from(format!("/home/user/.agents/skills/{name}/SKILL.md")), disable_model_invocation: false, + embedded_body: None, } } @@ -3078,9 +3112,30 @@ mod internal_tests { directory_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}")), skill_file_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}/SKILL.md")), disable_model_invocation: false, + embedded_body: None, } } + fn make_builtin_skill(name: &str, description: &str) -> Skill { + Skill { + name: name.to_string(), + description: description.to_string(), + source: SkillSource::BuiltIn, + directory_path: PathBuf::from(format!("/builtin/{name}")), + skill_file_path: PathBuf::from(format!("/builtin/{name}/SKILL.md")), + disable_model_invocation: false, + embedded_body: Some("built-in body"), + } + } + + /// Filter to only user-defined (non-built-in) skills for test assertions. + fn user_skills(skills: &[Skill]) -> Vec<&Skill> { + skills + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect() + } + #[test] fn test_combine_skills_keeps_every_entry_for_autocomplete() { // The autocomplete popup needs both same-named entries so the @@ -3092,9 +3147,10 @@ mod internal_tests { let (skills, errors) = combine_skills(vec![Ok(global)], vec![Ok(project)].into_iter()); assert!(errors.is_empty()); - assert_eq!(skills.len(), 2); - assert!(matches!(skills[0].source, SkillSource::Global)); - assert!(matches!(skills[1].source, SkillSource::ProjectLocal { .. })); + let user = user_skills(&skills); + assert_eq!(user.len(), 2); + assert!(matches!(user[0].source, SkillSource::Global)); + assert!(matches!(user[1].source, SkillSource::ProjectLocal { .. })); } #[test] @@ -3130,6 +3186,51 @@ mod internal_tests { assert_eq!(resolved[0].description, "First"); } + #[test] + fn test_apply_skill_overrides_global_wins_over_builtin() { + // A global skill with the same name as a built-in must shadow + // the built-in in the model-facing projection, regardless of + // iteration order. + let built_in = make_builtin_skill("create-skill", "Built-in version"); + let global = make_global_skill("create-skill", "User override"); + + let resolved = apply_skill_overrides(&[built_in, global]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].description, "User override"); + assert!(matches!(resolved[0].source, SkillSource::Global)); + } + + #[test] + fn test_apply_skill_overrides_project_wins_over_builtin() { + let built_in = make_builtin_skill("create-skill", "Built-in version"); + let project = make_project_skill("create-skill", "Project override", "my-project"); + + let resolved = apply_skill_overrides(&[built_in, project]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].description, "Project override"); + assert!(matches!( + resolved[0].source, + SkillSource::ProjectLocal { .. } + )); + } + + #[test] + fn test_apply_skill_overrides_project_wins_over_builtin_and_global() { + // All three sources present — the project-local must win and + // both lower-precedence entries must be dropped from the + // model-facing projection. + let built_in = make_builtin_skill("create-skill", "Built-in"); + let global = make_global_skill("create-skill", "Global"); + let project = make_project_skill("create-skill", "Project", "my-project"); + + let resolved = apply_skill_overrides(&[built_in, global, project]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].description, "Project"); + } + #[test] fn test_apply_skill_overrides_preserves_unique_skills() { let global_a = make_global_skill("alpha", "a"); @@ -3201,6 +3302,7 @@ mod internal_tests { directory_path: PathBuf::from(format!("/skills/{name}")), skill_file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")), disable_model_invocation: false, + embedded_body: None, }); } @@ -3275,6 +3377,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/skill-01-first"), skill_file_path: PathBuf::from("/skills/skill-01-first/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let second = Skill { name: "skill-02-overflows".to_string(), @@ -3283,6 +3386,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/skill-02-overflows"), skill_file_path: PathBuf::from("/skills/skill-02-overflows/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let third = Skill { name: "skill-03-would-fit".to_string(), @@ -3291,6 +3395,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/skill-03-would-fit"), skill_file_path: PathBuf::from("/skills/skill-03-would-fit/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; // Sanity-check the test setup: the third skill is small enough @@ -3346,6 +3451,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/hidden-huge"), skill_file_path: PathBuf::from("/skills/hidden-huge/SKILL.md"), disable_model_invocation: true, + embedded_body: None, }; let visible = Skill { name: "visible".to_string(), @@ -3354,6 +3460,7 @@ mod internal_tests { directory_path: PathBuf::from("/skills/visible"), skill_file_path: PathBuf::from("/skills/visible/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let (kept, errors) = select_catalog_skills(&[hidden, visible]); @@ -3377,7 +3484,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // Creating a session registers the project and triggers context building. let connection = NativeAgentConnection(agent.clone()); @@ -3454,9 +3561,6 @@ mod internal_tests { #[gpui::test] async fn test_global_skills_load_and_reload(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); let initial_skill_dir = skills_dir.join("my-skill"); @@ -3471,7 +3575,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // Simulate the user-interaction trigger that the agent panel // fires (input focus, slash autocomplete, or submit). In tests @@ -3496,9 +3600,10 @@ mod internal_tests { // The pre-existing skill should be loaded into the project state. agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].name, "my-skill"); - assert_eq!(state.skills[0].description, "First version"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "my-skill"); + assert_eq!(user[0].description, "First version"); }); // Modify the SKILL.md and verify the project context refreshes. @@ -3512,17 +3617,15 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].description, "Second version"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].description, "Second version"); }); } #[gpui::test] async fn test_global_skills_dir_created_after_startup(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); @@ -3535,7 +3638,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // First scan trigger: nothing on disk yet, state stays idle. cx.update(|cx| { @@ -3559,8 +3662,8 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); assert!( - state.skills.is_empty(), - "expected no skills before the global skills dir exists, got {:?}", + user_skills(&state.skills).is_empty(), + "expected no user skills before the global skills dir exists, got {:?}", state.skills ); }); @@ -3585,9 +3688,10 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project.entity_id()).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].name, "late-skill"); - assert_eq!(state.skills[0].description, "Created after startup"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "late-skill"); + assert_eq!(user[0].description, "Created after startup"); }); } @@ -3603,9 +3707,6 @@ mod internal_tests { #[gpui::test] async fn test_skills_added_after_session_visible_to_skill_tool(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); @@ -3614,7 +3715,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // First scan trigger: nothing on disk yet. cx.update(|cx| { @@ -3638,8 +3739,8 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project_id).unwrap(); assert!( - state.skills.is_empty(), - "expected no skills before the global skills dir exists, got {:?}", + user_skills(&state.skills).is_empty(), + "expected no user skills before the global skills dir exists, got {:?}", state.skills ); }); @@ -3656,7 +3757,12 @@ mod internal_tests { // empty list — NOT the snapshot that `Thread::new` would have // captured. cx.update(|cx| { - assert!(resolve(cx).is_empty()); + let all = resolve(cx); + let user: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); + assert!(user.is_empty()); }); // Now create a SKILL.md AFTER the session was registered. With @@ -3681,15 +3787,20 @@ mod internal_tests { // `state.skills` reflects the new skill (the watcher ran). agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project_id).unwrap(); - assert_eq!(state.skills.len(), 1); - assert_eq!(state.skills[0].name, "my-skill"); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "my-skill"); }); // The resolver the `SkillTool` uses must see it too. This is the // crux of the regression test: the tool's view of skills is // resolved at invocation time, not at thread-construction time. cx.update(|cx| { - let snapshot = resolve(cx); + let all = resolve(cx); + let snapshot: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); assert_eq!( snapshot.len(), 1, @@ -3737,9 +3848,6 @@ mod internal_tests { #[gpui::test] async fn test_subagent_skills_lookup_matches_parent(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); let skill_dir = skills_dir.join("shared-skill"); @@ -3753,7 +3861,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); // Open a parent session through the connection, the same way // production does. This triggers project-context refresh which @@ -3777,7 +3885,11 @@ mod internal_tests { let parent_resolve = cx.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), project_id)); cx.update(|cx| { - let parent_skills = parent_resolve(cx); + let all = parent_resolve(cx); + let parent_skills: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); assert_eq!(parent_skills.len(), 1); assert_eq!(parent_skills[0].name, "shared-skill"); }); @@ -3823,7 +3935,11 @@ mod internal_tests { let subagent_resolve = cx .update(|_cx| super::skills_resolver_for_project(agent.downgrade(), parent_project_id)); cx.update(|cx| { - let subagent_skills = subagent_resolve(cx); + let all = subagent_resolve(cx); + let subagent_skills: Vec<_> = all + .iter() + .filter(|s| !matches!(s.source, SkillSource::BuiltIn)) + .collect(); assert_eq!(subagent_skills.len(), 1); assert_eq!(subagent_skills[0].name, "shared-skill"); }); @@ -3832,9 +3948,6 @@ mod internal_tests { #[gpui::test] async fn test_skills_appear_as_available_skills(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); - }); let fs = FakeFs::new(cx.executor()); let skills_dir = global_skills_dir(); @@ -3861,7 +3974,7 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); let acp_thread = cx @@ -3919,7 +4032,14 @@ mod internal_tests { .iter() .map(|s| s.name.as_str()) .collect(); - assert_eq!(catalog, vec!["visible-skill"]); + assert!( + catalog.contains(&"visible-skill"), + "visible skill missing from catalog: {catalog:?}" + ); + assert!( + !catalog.contains(&"deploy"), + "deploy should be excluded from catalog: {catalog:?}" + ); }); } @@ -3930,7 +4050,6 @@ mod internal_tests { init_test(cx); cx.update(|cx| { - cx.update_flags(true, vec!["skills".to_string()]); // The trust global isn't created by `init_test`. We need it // for `Project::test_with_worktree_trust` to actually wire up // trust tracking and for our subscription in @@ -3960,7 +4079,7 @@ mod internal_tests { Project::test_with_worktree_trust(fs.clone(), [Path::new("/project")], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); let acp_thread = cx @@ -3986,7 +4105,7 @@ mod internal_tests { agent.read_with(cx, |agent, cx| { let state = agent.projects.get(&project_id).unwrap(); assert!( - state.skills.is_empty(), + user_skills(&state.skills).is_empty(), "untrusted worktree skills should not load: {:?}", state .skills @@ -4019,7 +4138,8 @@ mod internal_tests { agent.read_with(cx, |agent, _cx| { let state = agent.projects.get(&project_id).unwrap(); - let names: Vec<&str> = state.skills.iter().map(|s| s.name.as_str()).collect(); + let user = user_skills(&state.skills); + let names: Vec<&str> = user.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names, vec!["my-skill"]); }); @@ -4040,10 +4160,9 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let connection = - NativeAgentConnection(cx.update(|cx| { - NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx) - })); + let connection = NativeAgentConnection( + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)), + ); // Create a thread/session let acp_thread = cx @@ -4117,7 +4236,7 @@ mod internal_tests { // Create the agent and connection let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread/session @@ -4214,7 +4333,7 @@ mod internal_tests { let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); let acp_thread = cx @@ -4305,7 +4424,7 @@ mod internal_tests { let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = - cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4356,9 +4475,8 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a thinking model. @@ -4459,9 +4577,8 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a model where id() != name(), like real Anthropic models @@ -4575,9 +4692,8 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4757,9 +4873,8 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4838,9 +4953,8 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -4922,9 +5036,8 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5067,9 +5180,8 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index a34290742ad..aeeca37c170 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -16,7 +16,7 @@ use sqlez::{ connection::Connection, statement::Statement, }; -use std::sync::Arc; +use std::{io::ErrorKind, path::PathBuf, sync::Arc}; use ui::{App, SharedString}; use util::path_list::PathList; use zed_env_vars::ZED_STATELESS; @@ -53,7 +53,7 @@ impl From<&DbThreadMetadata> for acp_thread::AgentSessionInfo { #[derive(Debug, Serialize, Deserialize)] pub struct DbThread { pub title: SharedString, - pub messages: Vec, + pub messages: Vec>, pub updated_at: DateTime, #[serde(default)] pub detailed_summary: Option, @@ -81,6 +81,8 @@ pub struct DbThread { pub draft_prompt: Option>, #[serde(default)] pub ui_scroll_position: Option, + #[serde(default)] + pub sandboxed_terminal_temp_dir: Option, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -92,7 +94,7 @@ pub struct SerializedScrollPosition { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SharedThread { pub title: SharedString, - pub messages: Vec, + pub messages: Vec>, pub updated_at: DateTime, #[serde(default)] pub model: Option, @@ -130,6 +132,7 @@ impl SharedThread { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, } } @@ -206,7 +209,7 @@ impl DbThread { crate::Message::User(UserMessage { // MessageId from old format can't be meaningfully converted, so generate a new one id, - content, + content: Arc::from(content), }) } language_model::Role::Assistant => { @@ -285,7 +288,7 @@ impl DbThread { } }; - messages.push(message); + messages.push(Arc::new(message)); } Ok(Self { @@ -309,6 +312,7 @@ impl DbThread { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, }) } } @@ -569,15 +573,7 @@ impl ThreadsDatabase { let rows = select(id.0)?; if let Some((data_type, data)) = rows.into_iter().next() { - let json_data = match data_type { - DataType::Zstd => { - let decompressed = zstd::decode_all(&data[..])?; - String::from_utf8(decompressed)? - } - DataType::Json => String::from_utf8(data)?, - }; - let thread = DbThread::from_json(json_data.as_bytes())?; - Ok(Some(thread)) + Ok(Some(Self::deserialize_thread(data_type, data)?)) } else { Ok(None) } @@ -596,17 +592,71 @@ impl ThreadsDatabase { .spawn(async move { Self::save_thread_sync(&connection, id, thread, &folder_paths) }) } + fn deserialize_thread(data_type: DataType, data: Vec) -> Result { + let json_data = match data_type { + DataType::Zstd => { + let decompressed = zstd::decode_all(&data[..])?; + String::from_utf8(decompressed)? + } + DataType::Json => String::from_utf8(data)?, + }; + DbThread::from_json(json_data.as_bytes()) + } + + fn sandboxed_terminal_temp_dir(data_type: DataType, data: Vec) -> Option { + match Self::deserialize_thread(data_type, data) { + Ok(thread) => thread.sandboxed_terminal_temp_dir, + Err(error) => { + log::warn!("failed to deserialize thread before deleting it: {error:#}"); + None + } + } + } + + fn remove_sandboxed_terminal_temp_dir(temp_dir: PathBuf) { + match std::fs::remove_dir_all(&temp_dir) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + log::warn!( + "failed to remove sandboxed terminal temp directory {}: {error}", + temp_dir.display() + ); + } + } + } + pub fn delete_thread(&self, id: acp::SessionId) -> Task> { let connection = self.connection.clone(); self.executor.spawn(async move { - let connection = connection.lock(); + let sandboxed_terminal_temp_dir = { + let connection = connection.lock(); - let mut delete = connection.exec_bound::>(indoc! {" - DELETE FROM threads WHERE id = ? - "})?; + let mut select = + connection.select_bound::, (DataType, Vec)>(indoc! {" + SELECT data_type, data FROM threads WHERE id = ? LIMIT 1 + "})?; - delete(id.0)?; + let sandboxed_terminal_temp_dir = select(id.0.clone())? + .into_iter() + .next() + .and_then(|(data_type, data)| { + Self::sandboxed_terminal_temp_dir(data_type, data) + }); + + let mut delete = connection.exec_bound::>(indoc! {" + DELETE FROM threads WHERE id = ? + "})?; + + delete(id.0)?; + + sandboxed_terminal_temp_dir + }; + + if let Some(temp_dir) = sandboxed_terminal_temp_dir { + Self::remove_sandboxed_terminal_temp_dir(temp_dir); + } Ok(()) }) @@ -616,13 +666,32 @@ impl ThreadsDatabase { let connection = self.connection.clone(); self.executor.spawn(async move { - let connection = connection.lock(); + let sandboxed_terminal_temp_dirs = { + let connection = connection.lock(); - let mut delete = connection.exec_bound::<()>(indoc! {" - DELETE FROM threads - "})?; + let mut select = connection.select_bound::<(), (DataType, Vec)>(indoc! {" + SELECT data_type, data FROM threads + "})?; - delete(())?; + let sandboxed_terminal_temp_dirs = select(())? + .into_iter() + .filter_map(|(data_type, data)| { + Self::sandboxed_terminal_temp_dir(data_type, data) + }) + .collect::>(); + + let mut delete = connection.exec_bound::<()>(indoc! {" + DELETE FROM threads + "})?; + + delete(())?; + + sandboxed_terminal_temp_dirs + }; + + for temp_dir in sandboxed_terminal_temp_dirs { + Self::remove_sandboxed_terminal_temp_dir(temp_dir); + } Ok(()) }) @@ -694,6 +763,7 @@ mod tests { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, } } @@ -797,6 +867,78 @@ mod tests { ); } + #[test] + fn test_sandboxed_terminal_temp_dir_defaults_to_none() { + let json = r#"{ + "title": "Old Thread", + "messages": [], + "updated_at": "2024-01-01T00:00:00Z" + }"#; + + let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize"); + + assert!( + db_thread.sandboxed_terminal_temp_dir.is_none(), + "Legacy threads without sandboxed_terminal_temp_dir should default to None" + ); + } + + #[gpui::test] + async fn test_sandboxed_terminal_temp_dir_roundtrips_through_save_load( + cx: &mut TestAppContext, + ) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + let thread_id = session_id("sandbox-temp-dir-thread"); + let temp_dir = tempfile::Builder::new() + .prefix("zed-agent-terminal-test-") + .tempdir() + .unwrap() + .keep(); + let mut thread = make_thread( + "Sandbox Temp Dir Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + thread.sandboxed_terminal_temp_dir = Some(temp_dir.clone()); + + database + .save_thread(thread_id.clone(), thread, PathList::default()) + .await + .unwrap(); + + let loaded = database + .load_thread(thread_id) + .await + .unwrap() + .expect("thread should exist"); + assert_eq!(loaded.sandboxed_terminal_temp_dir, Some(temp_dir.clone())); + std::fs::remove_dir_all(temp_dir).unwrap(); + } + + #[gpui::test] + async fn test_delete_thread_removes_sandboxed_terminal_temp_dir(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + let thread_id = session_id("sandbox-temp-dir-delete-thread"); + let temp_dir = tempfile::Builder::new() + .prefix("zed-agent-terminal-test-") + .tempdir() + .unwrap() + .keep(); + std::fs::write(temp_dir.join("sentinel"), b"content").unwrap(); + let mut thread = make_thread( + "Sandbox Temp Dir Delete Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + thread.sandboxed_terminal_temp_dir = Some(temp_dir.clone()); + + database + .save_thread(thread_id.clone(), thread, PathList::default()) + .await + .unwrap(); + database.delete_thread(thread_id).await.unwrap(); + + assert!(!temp_dir.exists()); + } + #[gpui::test] async fn test_subagent_context_roundtrips_through_save_load(cx: &mut TestAppContext) { let database = ThreadsDatabase::new(cx.executor()).unwrap(); diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index bc0f75bcff5..b6752239fe7 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -9,9 +9,7 @@ use fs::Fs; use gpui::{App, Entity, Task}; use language_model::{LanguageModelId, LanguageModelProviderId, LanguageModelRegistry}; use project::{AgentId, Project}; -use prompt_store::PromptStore; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; -use util::ResultExt as _; use crate::{NativeAgent, NativeAgentConnection, ThreadStore, templates::Templates}; @@ -45,15 +43,12 @@ impl AgentServer for NativeAgentServer { log::debug!("NativeAgentServer::connect"); let fs = self.fs.clone(); let thread_store = self.thread_store.clone(); - let prompt_store = PromptStore::global(cx); cx.spawn(async move |cx| { log::debug!("Creating templates for native agent"); let templates = Templates::new(); - let prompt_store = prompt_store.await.log_err(); log::debug!("Creating native agent entity"); - let agent = - cx.update(|cx| NativeAgent::new(thread_store, templates, prompt_store, fs, cx)); + let agent = cx.update(|cx| NativeAgent::new(thread_store, templates, fs, cx)); // Create the connection wrapper let connection = NativeAgentConnection(agent); diff --git a/crates/agent/src/outline.rs b/crates/agent/src/outline.rs index 6a204e7694a..82592355295 100644 --- a/crates/agent/src/outline.rs +++ b/crates/agent/src/outline.rs @@ -11,10 +11,15 @@ pub const AUTO_OUTLINE_SIZE: usize = 16384; /// Result of getting buffer content, which can be either full content or an outline. pub struct BufferContent { - /// The actual content (either full text or outline) + /// The actual content (either full text, a symbol outline, or a + /// truncated fallback — see `is_synthetic`). pub text: String, - /// Whether this is an outline (true) or full content (false) - pub is_outline: bool, + /// `true` when `text` is not the file's full content — either a symbol + /// outline or the truncated first-1KB fallback used when no outline is + /// available. Callers that prefix line numbers to file content must + /// skip prefixing in this case, because line numbers in `text` would + /// not correspond to the file's real line numbers. + pub is_synthetic: bool, } /// Returns either the full content of a buffer or its outline, depending on size. @@ -44,7 +49,10 @@ pub async fn get_buffer_content_or_outline( .collect::>() }); - // If no outline exists, fall back to first 1KB so the agent has some context + // If no outline exists, fall back to first 1KB so the agent has some context. + // This is reported as `is_synthetic: true` because the returned text is not + // the file's full content — it has a synthetic header and is truncated — so + // callers must not attach real-file line numbers to it. if outline_items.is_empty() { let text = buffer.read_with(cx, |buffer, _| { let snapshot = buffer.snapshot(); @@ -59,7 +67,7 @@ pub async fn get_buffer_content_or_outline( return Ok(BufferContent { text, - is_outline: false, + is_synthetic: true, }); } @@ -72,14 +80,14 @@ pub async fn get_buffer_content_or_outline( }; Ok(BufferContent { text, - is_outline: true, + is_synthetic: true, }) } else { // File is small enough, return full content let text = buffer.read_with(cx, |buffer, _| buffer.text()); Ok(BufferContent { text, - is_outline: false, + is_synthetic: false, }) } } @@ -196,10 +204,13 @@ mod tests { "Result did not contain content subset" ); - // Should be marked as not an outline (it's truncated content) + // Should be marked synthetic: the returned text is not the file's full + // content (it's a truncated first-1KB fallback with a synthetic header), so + // callers must treat it the same as the symbol-outline case and not attach + // real-file line numbers to it. assert!( - !result.is_outline, - "Large file without outline should not be marked as outline" + result.is_synthetic, + "Truncated fallback should be reported as synthetic so callers skip line numbering" ); // Should be reasonably sized (much smaller than original) diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs new file mode 100644 index 00000000000..a0a6a17377f --- /dev/null +++ b/crates/agent/src/sandboxing.rs @@ -0,0 +1,25 @@ +//! Agent-side glue for the [`sandbox`] crate. +//! +//! Centralizes the "should agent-run terminal commands be sandboxed for this +//! process?" check so the system prompt, the terminal tool, and any other +//! caller see the same answer (and so the `target_os` gate lives in one +//! place instead of scattered across the agent crate). +//! +//! The current policy is: enabled iff we're on macOS *and* the user has the +//! `sandboxing` feature flag turned on. There's deliberately no settings or +//! env-var override yet — the flag is the only switch. +//! +//! On non-macOS hosts we don't have a sandbox integration today, so this +//! returns `false` regardless of the flag. +//! +//! Naming note: this module is about agent terminal sandboxing specifically. +//! Other agent operations (e.g. file edits) are gated separately. + +use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag}; +use gpui::App; + +/// Whether agent-run terminal commands should be wrapped in an OS-level +/// sandbox for this process. See module docs for the policy. +pub(crate) fn sandboxing_enabled(cx: &App) -> bool { + cfg!(target_os = "macos") && cx.has_flag::() +} diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index b369b07b81f..0d3e617d01c 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -43,6 +43,12 @@ pub struct SystemPromptTemplate<'a> { /// Contents of the user-global `~/.config/zed/AGENTS.md` file (or the /// platform equivalent), if present and non-empty. pub user_agents_md: Option, + /// Whether agent-run terminal commands are wrapped in an OS-level + /// sandbox for this conversation. When `true`, the rendered prompt + /// describes the sandbox's read/write/network rules and the + /// per-command flags the model can request to relax them. When + /// `false`, the prompt omits the sandbox section entirely. + pub sandboxing: bool, } impl Template for SystemPromptTemplate<'_> { @@ -83,10 +89,11 @@ mod tests { let project = prompt_store::ProjectContext::default(); let template = SystemPromptTemplate { project: &project, - available_tools: vec!["echo".into(), "update_plan".into()], + available_tools: vec!["echo".into(), "update_plan".into(), "update_title".into()], model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, + sandboxing: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -94,6 +101,7 @@ mod tests { assert!(rendered.contains("Today's Date: 2026-01-01")); assert!(rendered.contains("## Fixing Diagnostics")); assert!(rendered.contains("## Planning")); + assert!(rendered.contains("## Session Title")); assert!(rendered.contains("test-model")); } @@ -111,13 +119,14 @@ mod tests { project_entry_id: 1, }), }]; - let project = ProjectContext::new(worktrees, Vec::new()); + let project = ProjectContext::new(worktrees); let template = SystemPromptTemplate { project: &project, available_tools: vec!["echo".into()], model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: Some("always be concise".into()), + sandboxing: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); @@ -135,6 +144,78 @@ mod tests { ); } + #[test] + fn test_system_prompt_omits_sandbox_section_when_sandboxing_disabled() { + let project = prompt_store::ProjectContext::default(); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: false, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + assert!(!rendered.contains("## Terminal sandbox")); + assert!(!rendered.contains("allow_network")); + } + + #[test] + fn test_system_prompt_renders_sandbox_section_with_worktrees_when_enabled() { + use prompt_store::{ProjectContext, WorktreeContext}; + + let worktrees = vec![ + WorktreeContext { + root_name: "alpha".to_string(), + abs_path: std::path::Path::new("/tmp/alpha").into(), + rules_file: None, + }, + WorktreeContext { + root_name: "beta".to_string(), + abs_path: std::path::Path::new("/tmp/beta").into(), + rules_file: None, + }, + ]; + let project = ProjectContext::new(worktrees); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: true, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + + assert!(rendered.contains("## Terminal sandbox")); + assert!(rendered.contains("`/tmp/alpha`")); + assert!(rendered.contains("`/tmp/beta`")); + assert!(rendered.contains("allow_network: true")); + assert!(rendered.contains("allow_fs_write: true")); + assert!(rendered.contains("unsandboxed: true")); + assert!(rendered.contains("remain in effect for the entire duration")); + } + + #[test] + fn test_system_prompt_sandbox_section_handles_zero_worktrees() { + let project = prompt_store::ProjectContext::default(); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: true, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + + assert!(rendered.contains("## Terminal sandbox")); + assert!(rendered.contains("No project directories are currently writable")); + } + #[test] fn test_system_prompt_omits_user_agents_md_section_when_absent() { let project = prompt_store::ProjectContext::default(); @@ -144,9 +225,28 @@ mod tests { model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, + sandboxing: false, }; let templates = Templates::new(); let rendered = template.render(&templates).unwrap(); assert!(!rendered.contains("### Personal `AGENTS.md`")); } + + #[test] + fn test_system_prompt_does_not_render_legacy_zed_rules_section() { + let project = prompt_store::ProjectContext::default(); + let template = SystemPromptTemplate { + project: &project, + available_tools: vec!["echo".into()], + model_name: Some("test-model".to_string()), + date: "2026-01-01".to_string(), + user_agents_md: None, + sandboxing: false, + }; + let templates = Templates::new(); + let rendered = template.render(&templates).unwrap(); + + assert!(!rendered.contains("The user has specified the following rules")); + assert!(!rendered.contains("Rules title:")); + } } diff --git a/crates/agent/src/templates/experimental_system_prompt.hbs b/crates/agent/src/templates/experimental_system_prompt.hbs index 2611efc671c..63a34ccdcad 100644 --- a/crates/agent/src/templates/experimental_system_prompt.hbs +++ b/crates/agent/src/templates/experimental_system_prompt.hbs @@ -52,6 +52,17 @@ Use a plan when: - The user asked you to do more than one thing in a single prompt. - You discover additional steps while working and intend to complete them before yielding to the user. +{{/if}} +{{#if (contains available_tools 'update_title') }} +## Session Title + +- Use the `update_title` tool to set the title shown to the user for the current session. +- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one. +- Update the title again whenever the goal changes materially. +- Titles are very important to communicate to the user what you are working on. A session should always have a title. +- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes. +- Do not mention that you changed the title unless it is directly relevant to the user. + {{/if}} ## Searching and Reading @@ -162,12 +173,11 @@ The current project contains the following root directories: You are powered by the model named {{model_name}}. {{/if}} -{{#if (or has_rules has_user_rules)}} +{{#if has_rules}} ## User's Custom Instructions The following additional instructions are provided by the user and should be followed to the best of your ability{{#if (gt (len available_tools) 0)}} without interfering with the tool use guidelines{{/if}}. -{{#if has_rules}} There are project rules that apply to these root directories: {{#each worktrees}} {{#if rules_file}} @@ -178,17 +188,3 @@ There are project rules that apply to these root directories: {{/if}} {{/each}} {{/if}} - -{{#if has_user_rules}} -The user has specified the following rules that should be applied: -{{#each user_rules}} - -{{#if title}} -Rules title: {{title}} -{{/if}} -`````` -{{contents}} -`````` -{{/each}} -{{/if}} -{{/if}} diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index c7128d9cb6b..7326937ab09 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -23,15 +23,13 @@ graph TD A[Start] --> B[End] ``` +The renderer supports the following diagram types: flowchart, sequence, class, state, ER, gantt, pie, gitgraph, mindmap, timeline, quadrant chart, xy chart, and journey. Other diagram types will only show as code. + Mermaid diagrams are automatically themed to match the user's editor theme. Do not include `%%{init}%%` directives or define your own `classDef` styles. Do *NOT* include inline HTML elements in mermaid diagrams, as they cannot be rendered. It is better to simply skip formatting (e.g. bold/italic/etc.). -When you need accent colors for emphasis (e.g. color-coding layers, categories, or states), use the pre-defined classes `accent0` through `accent7` with the `:::` syntax: - - A:::accent0 --> B:::accent1 --> C:::accent2 - -These classes automatically match the user's theme. Do not hardcode hex color values unless an exact color match is specifically required. Note that the rendered view may be narrow, so try to prioritize generating taller diagrams over wider ones. +Mermaid diagrams are automatically color-coded using the user's theme accent palette. Do not hardcode hex color values unless an exact color match is specifically required. Note that the rendered view may be narrow, so try to prioritize generating taller diagrams over wider ones. {{#if (gt (len available_tools) 0)}} ## Tool Use @@ -74,6 +72,17 @@ Use a plan when: - The user asked you to do more than one thing in a single prompt. - You discover additional steps while working and intend to complete them before yielding to the user. +{{/if}} +{{#if (contains available_tools 'update_title') }} +## Session Title + +- Use the `update_title` tool to set the title shown to the user for the current session. +- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one. +- Update the title again whenever the goal changes materially. +- Titles are very important to communicate to the user what you are working on. A session should always have a title. +- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes. +- Do not mention that you changed the title unless it is directly relevant to the user. + {{/if}} ## Searching and Reading @@ -178,6 +187,24 @@ The current project contains the following root directories: - `{{abs_path}}` {{/each}} +{{#if sandboxing}} +## Terminal sandbox + +The `terminal` tool runs commands inside a sandbox with these permissions: + +- Reads: any path on the filesystem is readable. +- Writes: a per-thread temporary directory exposed via `$TMPDIR`, `$TMP`, and `$TEMP` is writable and persists across `terminal` calls in this conversation{{#if worktrees}}, along with these project directories: +{{#each worktrees}} + - `{{abs_path}}` +{{/each}} + Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} +- Network: outbound network access is blocked. + +You can request elevated permissions on individual `terminal` calls by setting `allow_network: true`, `allow_fs_write: true`, or `unsandboxed: true`. The user will be prompted to approve before the command runs. + +These sandbox settings are guaranteed to remain in effect for the entire duration of this conversation. If they ever change, you will be told. + +{{/if}} {{#if model_name}} ## Model Information @@ -212,7 +239,7 @@ To use a Skill: 4. If the Skill references additional files, use `read_file` to access them. Paths inside a Skill resolve relative to that Skill's directory (the parent of its `SKILL.md`). {{/if}} -{{#if (or user_agents_md has_rules has_user_rules)}} +{{#if (or user_agents_md has_rules)}} ## User's Custom Instructions The following additional instructions are provided by the user and should be followed to the best of your ability{{#if (gt (len available_tools) 0)}} without interfering with the tool use guidelines{{/if}}. @@ -243,16 +270,4 @@ There are project rules that apply to these root directories: {{/each}} {{/if}} -{{#if has_user_rules}} -The user has specified the following rules that should be applied: -{{#each user_rules}} - -{{#if title}} -Rules title: {{title}} -{{/if}} -`````` -{{contents}} -`````` -{{/each}} -{{/if}} {{/if}} diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 7713907f893..16faa56c786 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -26,10 +26,10 @@ use gpui::{ use indoc::indoc; use language_model::{ CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRegistry, - LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolResult, - LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, Role, StopReason, - TokenUsage, + LanguageModelId, LanguageModelImageExt, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelToolResult, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, + Role, StopReason, TokenUsage, fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}, }; use pretty_assertions::assert_eq; @@ -200,8 +200,10 @@ impl crate::ThreadEnvironment for FakeThreadEnvironment { fn create_terminal( &self, _command: String, + _extra_env: Vec, _cwd: Option, _output_byte_limit: Option, + _sandbox_wrap: Option, _cx: &mut AsyncApp, ) -> Task>> { self.terminal_creations.fetch_add(1, Ordering::SeqCst); @@ -242,8 +244,10 @@ impl crate::ThreadEnvironment for MultiTerminalEnvironment { fn create_terminal( &self, _command: String, + _extra_env: Vec, _cwd: Option, _output_byte_limit: Option, + _sandbox_wrap: Option, cx: &mut AsyncApp, ) -> Task>> { let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); @@ -320,6 +324,7 @@ async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) { command: "sleep 1000".to_string(), cd: ".".to_string(), timeout_ms: Some(5), + ..Default::default() }), event_stream, cx, @@ -387,6 +392,7 @@ async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAp command: "sleep 1000".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1656,6 +1662,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); assert_eq!(tool_call_params.name, "screenshot"); + let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; tool_call_response .send(context_server::types::CallToolResponse { content: vec![ @@ -1663,7 +1670,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { text: "Some text".into(), }, context_server::types::ToolResponseContent::Image { - data: "aGVsbG8=".into(), + data: image_data.into(), mime_type: "image/png".into(), }, context_server::types::ToolResponseContent::Text { @@ -1691,13 +1698,25 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { }) .expect("expected a tool result"); assert_eq!(tool_result.tool_use_id, "tool_1".into()); - assert_eq!(tool_result.content.len(), 2); + assert_eq!(tool_result.content.len(), 3); + assert_eq!( + tool_result.content[0], + language_model::LanguageModelToolResultContent::Text(Arc::from("Some text")) + ); + let expected_image = + language_model::LanguageModelImage::from_base64_image(image_data, "image/png") + .expect("image conversion should not error") + .expect("image conversion should succeed"); assert_eq!( tool_result.content[0], language_model::LanguageModelToolResultContent::Text(Arc::from("Some text")) ); assert_eq!( tool_result.content[1], + language_model::LanguageModelToolResultContent::Image(expected_image) + ); + assert_eq!( + tool_result.content[2], language_model::LanguageModelToolResultContent::Text(Arc::from("Some more text")) ); fake_model.end_last_completion_stream(); @@ -3507,8 +3526,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create agent and connection - let agent = cx - .update(|cx| NativeAgent::new(thread_store, templates.clone(), None, fake_fs.clone(), cx)); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, templates.clone(), fake_fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread using new_thread @@ -3794,6 +3813,155 @@ async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_update_title_tool_sets_thread_title(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + let summary_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + cx.update_flags(true, vec!["update-title-tool".to_string()]); + }); + thread.update(cx, |thread, cx| { + thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + + let mut events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Explore title tooling"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let input = json!({ + "title": "Session title tool" + }); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "title_1".into(), + name: UpdateTitleTool::NAME.into(), + raw_input: input.to_string(), + input, + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let tool_call = expect_tool_call(&mut events).await; + assert_eq!( + tool_call, + acp::ToolCall::new("title_1", "Update title: Session title tool") + .kind(acp::ToolKind::Think) + .raw_input(json!({ + "title": "Session title tool" + })) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + "update_title".into() + )])) + ); + + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate::new( + "title_1", + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) + ) + ); + + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate::new( + "title_1", + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .raw_output("Session title updated") + ) + ); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("Session title tool".into())); + }); + assert_eq!(summary_model.pending_completions(), Vec::new()); +} + +#[gpui::test] +async fn test_update_title_availability_suppresses_summary_title_generation( + cx: &mut TestAppContext, +) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + let summary_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + cx.update_flags(true, vec!["update-title-tool".to_string()]); + }); + thread.update(cx, |thread, cx| { + thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + + let send = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Explore title tooling"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Done"); + fake_model.end_last_completion_stream(); + send.collect::>().await; + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), None); + }); + assert_eq!(summary_model.pending_completions(), Vec::new()); +} + +#[gpui::test] +async fn test_update_title_flag_without_available_tool_falls_back_to_summary_title_generation( + cx: &mut TestAppContext, +) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + let summary_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + cx.update_flags(true, vec!["update-title-tool".to_string()]); + }); + thread.update(cx, |thread, cx| { + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + + let send = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Explore title tooling"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Done"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + assert_eq!(summary_model.pending_completions().len(), 1); + + summary_model.send_last_completion_stream_text_chunk("Fallback title"); + summary_model.end_last_completion_stream(); + send.collect::>().await; + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("Fallback title".into())); + }); +} + #[gpui::test] async fn test_send_no_retry_on_success(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; @@ -3971,8 +4139,8 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { events.collect::>().await; thread.read_with(cx, |thread, _cx| { assert_eq!( - thread.last_received_or_pending_message(), - Some(Message::Agent(AgentMessage { + thread.last_received_or_pending_message().as_deref(), + Some(&Message::Agent(AgentMessage { content: vec![AgentMessageContent::Text("Done".into())], tool_results: IndexMap::default(), reasoning_details: None, @@ -4307,6 +4475,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { StreamingFailingEchoTool::NAME: true, TerminalTool::NAME: true, UpdatePlanTool::NAME: true, + UpdateTitleTool::NAME: true, } } } @@ -4389,7 +4558,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { } #[cfg(test)] -#[ctor::ctor] +#[ctor::ctor(unsafe)] fn init_logger() { if std::env::var("RUST_LOG").is_ok() { env_logger::init(); @@ -4729,6 +4898,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { command: "rm -rf /".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -4781,6 +4951,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { command: "echo hello".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -4839,6 +5010,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { command: "sudo rm file".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -4886,6 +5058,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { command: "echo hello".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -4928,9 +5101,8 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5063,9 +5235,8 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5211,9 +5382,8 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5341,9 +5511,8 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -5874,9 +6043,8 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -6000,9 +6168,8 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx @@ -6174,9 +6341,8 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = cx.update(|cx| { - NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) - }); + let agent = + cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index b2114fcfb1d..4a5efc2e1cc 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4,12 +4,14 @@ use crate::{ FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, - UpdatePlanTool, UserAgentsMd, WebSearchTool, WriteFileTool, decide_permission_from_settings, + UpdatePlanTool, UpdateTitleTool, WebSearchTool, WriteFileTool, decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; +use agent_settings::UserAgentsMd; use feature_flags::{ FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, UpdatePlanToolFeatureFlag, + UpdateTitleToolFeatureFlag, }; use agent_client_protocol::schema as acp; @@ -49,16 +51,16 @@ use serde::{Deserialize, Serialize}; use settings::{ LanguageModelSelection, Settings, SettingsStore, ToolPermissionMode, update_settings_file, }; +use std::fmt::Write; use std::{ collections::BTreeMap, marker::PhantomData, ops::RangeInclusive, - path::Path, + path::{Path, PathBuf}, rc::Rc, sync::Arc, time::{Duration, Instant}, }; -use std::{fmt::Write, path::PathBuf}; use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock, paths::PathStyle}; use uuid::Uuid; @@ -121,7 +123,7 @@ enum RetryStrategy { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Message { User(UserMessage), Agent(AgentMessage), @@ -174,13 +176,16 @@ impl Message { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct UserMessage { pub id: UserMessageId, - pub content: Vec, + pub content: Arc<[UserMessageContent]>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum UserMessageContent { Text(String), - Mention { uri: MentionUri, content: String }, + Mention { + uri: MentionUri, + content: SharedString, + }, Image(LanguageModelImage), } @@ -188,7 +193,7 @@ impl UserMessage { pub fn to_markdown(&self) -> String { let mut markdown = String::new(); - for content in &self.content { + for content in &*self.content { match content { UserMessageContent::Text(text) => { markdown.push_str(text); @@ -248,7 +253,7 @@ impl UserMessage { let mut merge_conflict_context = MERGE_CONFLICT_TAG.to_string(); let mut skills_context = OPEN_SKILLS_TAG.to_string(); - for chunk in &self.content { + for chunk in &*self.content { let chunk = match chunk { UserMessageContent::Text(text) => { language_model::MessageContent::Text(text.clone()) @@ -264,7 +269,7 @@ impl UserMessage { "\n{}", MarkdownCodeBlock { tag: &codeblock_tag(abs_path, None), - text: &content.to_string(), + text: content, } ) .ok(); @@ -311,17 +316,6 @@ impl UserMessage { MentionUri::Thread { .. } => { write!(&mut thread_context, "\n{}\n", content).ok(); } - MentionUri::Rule { .. } => { - write!( - &mut rules_context, - "\n{}", - MarkdownCodeBlock { - tag: "", - text: content - } - ) - .ok(); - } MentionUri::Fetch { url } => { write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok(); } @@ -364,11 +358,7 @@ impl UserMessage { .ok(); } MentionUri::Skill { name, source, .. } => { - let label = if source.is_empty() { - format!("{} (global)", name) - } else { - format!("{} ({})", name, source) - }; + let label = format!("{} ({})", name, source); write!(&mut skills_context, "\nSkill: {}\n{}\n", label, content).ok(); } } @@ -629,9 +619,9 @@ impl AgentMessage { #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentMessage { - pub content: Vec, - pub tool_results: IndexMap, - pub reasoning_details: Option, + pub(crate) content: Vec, + pub(crate) tool_results: IndexMap, + pub(crate) reasoning_details: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -667,8 +657,10 @@ pub trait ThreadEnvironment { fn create_terminal( &self, command: String, + extra_env: Vec, cwd: Option, output_byte_limit: Option, + sandbox_wrap: Option, cx: &mut AsyncApp, ) -> Task>>; @@ -965,7 +957,7 @@ pub struct Thread { title_generation_failed: bool, pending_summary_generation: Option>>>, summary: Option, - messages: Vec, + messages: Vec>, user_store: Entity, /// Holds the task that handles agent interaction until the end of the turn. /// Survives across multiple requests as the model performs tool calls and @@ -1004,6 +996,7 @@ pub struct Thread { /// Weak references to running subagent threads for cancellation propagation running_subagents: Vec>, inherits_parent_model_settings: bool, + sandboxed_terminal_temp_dir: Option, } impl Thread { @@ -1130,6 +1123,7 @@ impl Thread { ui_scroll_position: None, running_subagents: Vec::new(), inherits_parent_model_settings: true, + sandboxed_terminal_temp_dir: None, } } @@ -1173,6 +1167,30 @@ impl Thread { &self.id } + pub(crate) fn sandboxed_terminal_temp_dir( + &mut self, + cx: &mut Context, + ) -> Result { + if let Some(temp_dir) = &self.sandboxed_terminal_temp_dir { + std::fs::create_dir_all(temp_dir).with_context(|| { + format!( + "failed to recreate sandboxed terminal temp directory {}", + temp_dir.display() + ) + })?; + return Ok(temp_dir.clone()); + } + + let temp_dir = tempfile::Builder::new() + .prefix("zed-agent-terminal-") + .tempdir() + .context("failed to create sandboxed terminal temp directory")?; + let temp_dir = temp_dir.keep(); + self.sandboxed_terminal_temp_dir = Some(temp_dir.clone()); + cx.notify(); + Ok(temp_dir) + } + /// Returns true if this thread was imported from a shared thread. pub fn is_imported(&self) -> bool { self.imported @@ -1185,7 +1203,7 @@ impl Thread { let (tx, rx) = mpsc::unbounded(); let stream = ThreadEventStream(tx); for message in &self.messages { - match message { + match &**message { Message::User(user_message) => stream.send_user_message(user_message), Message::Agent(assistant_message) => { for content in &assistant_message.content { @@ -1219,10 +1237,10 @@ impl Thread { stream: &ThreadEventStream, cx: &mut Context, ) { - // Extract saved output and status first, so they're available even if tool is not found let output = tool_result .as_ref() .and_then(|result| result.output.clone()); + let replay_content = tool_result.and_then(Self::tool_result_content_for_replay); let status = tool_result .as_ref() .map_or(acp::ToolCallStatus::Failed, |result| { @@ -1251,21 +1269,25 @@ impl Thread { // but still display the saved result if available. // We need to send both ToolCall and ToolCallUpdate events because the UI // only converts raw_output to displayable content in update_fields, not from_acp. + let title = Self::title_for_replayed_tool_use(tool_use); stream .0 .unbounded_send(Ok(ThreadEvent::ToolCall( - acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) + acp::ToolCall::new(tool_use.id.to_string(), title.clone()) .status(status) .raw_input(tool_use.input.clone()), ))) .ok(); - stream.update_tool_call_fields( - &tool_use.id, - acp::ToolCallUpdateFields::new() - .status(status) - .raw_output(output), - None, - ); + let mut fields = acp::ToolCallUpdateFields::new() + .status(status) + .raw_output(output); + if tool_use.name.as_ref() == UpdateTitleTool::NAME { + fields = fields.title(title); + } + if let Some(content) = replay_content { + fields = fields.content(content); + } + stream.update_tool_call_fields(&tool_use.id, fields, None); return; }; @@ -1279,6 +1301,14 @@ impl Thread { tool_use.input.clone(), ); + if let Some(content) = replay_content { + stream.update_tool_call_fields( + &tool_use.id, + acp::ToolCallUpdateFields::new().content(content), + None, + ); + } + if let Some(output) = output.clone() { // For replay, we use a dummy cancellation receiver since the tool already completed let (_cancellation_tx, cancellation_rx) = watch::channel(false); @@ -1301,6 +1331,55 @@ impl Thread { ); } + fn title_for_replayed_tool_use(tool_use: &LanguageModelToolUse) -> String { + if tool_use.name.as_ref() == UpdateTitleTool::NAME { + let input = serde_json::from_value(tool_use.input.clone()) + .map_err(|_| serde_json::Value::String(tool_use.raw_input.clone())); + UpdateTitleTool::title_for_input(input).to_string() + } else { + tool_use.name.to_string() + } + } + + fn tool_result_content_for_replay( + tool_result: &LanguageModelToolResult, + ) -> Option> { + let has_image = tool_result + .content + .iter() + .any(|part| matches!(part, LanguageModelToolResultContent::Image(_))); + if !has_image && tool_result.output.is_some() { + return None; + } + + let content = tool_result + .content + .iter() + .filter_map(|part| match part { + LanguageModelToolResultContent::Text(text) => { + if text.is_empty() { + None + } else { + Some(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new(text.to_string())), + ))) + } + } + LanguageModelToolResultContent::Image(image) => Some( + acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image( + acp::ImageContent::new(image.source.clone(), "image/png"), + ))), + ), + }) + .collect::>(); + + if content.is_empty() { + None + } else { + Some(content) + } + } + pub fn from_db( id: acp::SessionId, db_thread: DbThread, @@ -1387,6 +1466,7 @@ impl Thread { }), running_subagents: Vec::new(), inherits_parent_model_settings: true, + sandboxed_terminal_temp_dir: db_thread.sandboxed_terminal_temp_dir, } } @@ -1417,6 +1497,7 @@ impl Thread { offset_in_item: lo.offset_in_item.as_f32(), } }), + sandboxed_terminal_temp_dir: self.sandboxed_terminal_temp_dir.clone(), }; cx.background_spawn(async move { @@ -1580,13 +1661,13 @@ impl Thread { } pub fn last_message(&self) -> Option<&Message> { - self.messages.last() + self.messages.last().map(std::ops::Deref::deref) } #[cfg(any(test, feature = "test-support"))] - pub fn last_received_or_pending_message(&self) -> Option { + pub fn last_received_or_pending_message(&self) -> Option> { if let Some(message) = self.pending_message.clone() { - Some(Message::Agent(message)) + Some(Arc::new(Message::Agent(message))) } else { self.messages.last().cloned() } @@ -1627,6 +1708,9 @@ impl Thread { if cx.has_flag::() { self.add_tool(UpdatePlanTool); } + if cx.has_flag::() { + self.add_tool(UpdateTitleTool::new(cx.weak_entity())); + } self.add_tool(ReadFileTool::new( self.project.clone(), self.action_log.clone(), @@ -1740,13 +1824,13 @@ impl Thread { // and we don't want that content to be added after we truncate self.pending_message.take(); let Some(position) = self.messages.iter().position( - |msg| matches!(msg, Message::User(UserMessage { id, .. }) if id == &message_id), + |msg| matches!(&**msg, Message::User(UserMessage { id, .. }) if id == &message_id), ) else { return Err(anyhow!("Message not found")); }; for message in self.messages.drain(position..) { - match message { + match &*message { Message::User(message) => { self.request_token_usage.remove(&message.id); } @@ -1788,7 +1872,7 @@ impl Thread { let mut previous_user_message_id: Option<&UserMessageId> = None; for message in &self.messages { - if let Message::User(user_msg) = message { + if let Message::User(user_msg) = &**message { if &user_msg.id == target_id { let prev_id = previous_user_message_id?; let usage = self.request_token_usage.get(prev_id)?; @@ -1833,7 +1917,7 @@ impl Thread { &mut self, cx: &mut Context, ) -> Result>> { - self.messages.push(Message::Resume); + self.messages.push(Arc::new(Message::Resume)); cx.notify(); log::debug!("Total messages in thread: {}", self.messages.len()); @@ -1852,11 +1936,11 @@ impl Thread { where T: Into, { - let content = content.into_iter().map(Into::into).collect::>(); + let content = content.into_iter().map(Into::into).collect::>(); log::debug!("Thread::send content: {:?}", content); self.messages - .push(Message::User(UserMessage { id, content })); + .push(Arc::new(Message::User(UserMessage { id, content }))); cx.notify(); self.send_existing(cx) @@ -1887,9 +1971,9 @@ impl Thread { let content = blocks .into_iter() .map(|block| UserMessageContent::from_content_block(block, path_style)) - .collect::>(); + .collect::>(); self.messages - .push(Message::User(UserMessage { id, content })); + .push(Arc::new(Message::User(UserMessage { id, content }))); cx.notify(); } @@ -1907,10 +1991,10 @@ impl Thread { _ => "[unknown]".to_string(), }; - self.messages.push(Message::Agent(AgentMessage { + self.messages.push(Arc::new(Message::Agent(AgentMessage { content: vec![AgentMessageContent::Text(text)], ..Default::default() - })); + }))); cx.notify(); } @@ -2140,7 +2224,7 @@ impl Thread { this.update(cx, |this, cx| { this.flush_pending_message(cx); - if this.title.is_none() && this.pending_title_generation.is_none() { + if this.title.is_none() { this.generate_title(cx); } })?; @@ -2168,10 +2252,10 @@ impl Thread { } } this.update(cx, |this, _cx| { - if let Some(Message::Agent(message)) = this.messages.last() { + if let Some(Message::Agent(message)) = this.last_message() { if message.tool_results.is_empty() { intent = CompletionIntent::UserPrompt; - this.messages.push(Message::Resume); + this.messages.push(Arc::new(Message::Resume)); } } })?; @@ -2294,12 +2378,12 @@ impl Thread { let last_message = self.pending_message(); // Store the last non-empty reasoning_details (overwrites earlier ones) // This ensures we keep the encrypted reasoning with signatures, not the early text reasoning - if let serde_json::Value::Array(ref arr) = details { + if let serde_json::Value::Array(arr) = &details { if !arr.is_empty() { - last_message.reasoning_details = Some(details); + last_message.reasoning_details = Some(Arc::new(details)); } } else { - last_message.reasoning_details = Some(details); + last_message.reasoning_details = Some(Arc::new(details)); } } ToolUse(tool_use) => { @@ -2669,6 +2753,20 @@ impl Thread { self.title_generation_failed } + pub fn can_generate_title(&self, cx: &App) -> bool { + self.pending_title_generation.is_none() + && self.summarization_model.is_some() + && !self.update_title_tool_available(cx) + } + + fn update_title_tool_available(&self, cx: &App) -> bool { + if let Some(running_turn) = self.running_turn.as_ref() { + running_turn.tools.contains_key(UpdateTitleTool::NAME) + } else { + self.enabled_tools(cx).contains_key(UpdateTitleTool::NAME) + } + } + pub fn summary(&mut self, cx: &mut Context) -> Shared>> { if let Some(summary) = self.summary.as_ref() { return Task::ready(Some(summary.clone())).shared(); @@ -2730,6 +2828,10 @@ impl Thread { } pub fn generate_title(&mut self, cx: &mut Context) { + if !self.can_generate_title(cx) { + return; + } + self.title_generation_failed = false; let Some(model) = self.summarization_model.clone() else { return; @@ -2815,7 +2917,7 @@ impl Thread { self.messages .iter() .rev() - .find_map(|message| match message { + .find_map(|message| match &**message { Message::User(user_message) => Some(user_message), Message::Agent(_) => None, Message::Resume => None, @@ -2856,7 +2958,7 @@ impl Thread { } } - self.messages.push(Message::Agent(message)); + self.messages.push(Arc::new(Message::Agent(message))); self.updated_at = Utc::now(); self.clear_summary(); cx.notify() @@ -3088,6 +3190,7 @@ impl Thread { model_name: self.model.as_ref().map(|m| m.name().0.to_string()), date: Local::now().format("%Y-%m-%d").to_string(), user_agents_md, + sandboxing: crate::sandboxing::sandboxing_enabled(cx), } .render(&self.templates) .context("failed to build system prompt") @@ -3119,7 +3222,7 @@ impl Thread { if ix > 0 { markdown.push('\n'); } - match message { + match &**message { Message::User(_) => markdown.push_str("## User\n\n"), Message::Agent(_) => markdown.push_str("## Assistant\n\n"), Message::Resume => {} @@ -4331,7 +4434,7 @@ impl UserMessageContent { match MentionUri::parse(&resource_link.uri, path_style) { Ok(uri) => Self::Mention { uri, - content: String::new(), + content: SharedString::default(), }, Err(err) => { log::error!("Failed to parse mention link: {}", err); @@ -4344,7 +4447,7 @@ impl UserMessageContent { match MentionUri::parse(&resource.uri, path_style) { Ok(uri) => Self::Mention { uri, - content: resource.text, + content: resource.text.into(), }, Err(err) => { log::error!("Failed to parse mention link: {}", err); @@ -4394,7 +4497,6 @@ impl From for acp::ContentBlock { fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { LanguageModelImage { source: image_content.data.into(), - size: None, } } @@ -4459,6 +4561,259 @@ mod tests { }) } + struct ReplayImageTool; + + impl AgentTool for ReplayImageTool { + type Input = (); + type Output = String; + + const NAME: &'static str = "registered_image_tool"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { + "Registered Image Tool".into() + } + + fn run( + self: Arc, + _input: ToolInput, + _event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(String::new())) + } + } + + #[gpui::test] + async fn test_replay_tool_call_replays_image_content(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + + let registered_tool_use_id = LanguageModelToolUseId::from("registered_tool_id"); + let missing_tool_use_id = LanguageModelToolUseId::from("missing_tool_id"); + let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + let image = LanguageModelImage { + source: image_data.into(), + }; + + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.add_tool(ReplayImageTool); + + let registered_tool_use = LanguageModelToolUse { + id: registered_tool_use_id.clone(), + name: ReplayImageTool::NAME.into(), + raw_input: "null".to_string(), + input: json!(null), + is_input_complete: true, + thought_signature: None, + }; + let missing_tool_use = LanguageModelToolUse { + id: missing_tool_use_id.clone(), + name: "missing_image_tool".into(), + raw_input: "{}".to_string(), + input: json!({}), + is_input_complete: true, + thought_signature: None, + }; + + let mut tool_results = IndexMap::default(); + tool_results.insert( + registered_tool_use_id.clone(), + LanguageModelToolResult { + tool_use_id: registered_tool_use_id.clone(), + tool_name: ReplayImageTool::NAME.into(), + is_error: false, + content: vec![ + LanguageModelToolResultContent::Text("before".into()), + LanguageModelToolResultContent::Image(image.clone()), + LanguageModelToolResultContent::Text("after".into()), + ], + output: Some(json!("raw output")), + }, + ); + tool_results.insert( + missing_tool_use_id.clone(), + LanguageModelToolResult { + tool_use_id: missing_tool_use_id.clone(), + tool_name: "missing_image_tool".into(), + is_error: false, + content: vec![LanguageModelToolResultContent::Image(image.clone())], + output: Some(json!("raw output")), + }, + ); + + thread.messages.push(Arc::new(Message::Agent(AgentMessage { + content: vec![ + AgentMessageContent::ToolUse(registered_tool_use), + AgentMessageContent::ToolUse(missing_tool_use), + ], + tool_results, + reasoning_details: None, + }))); + + thread.replay(cx) + }) + }); + + let mut tool_use_ids_with_image_content = HashSet::default(); + while let Some(event) = replay_events.next().await { + let event = event.unwrap(); + if let ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) = + event + && let Some(content) = &update.fields.content + && content.iter().any(|content| { + matches!( + content, + acp::ToolCallContent::Content(acp::Content { + content: acp::ContentBlock::Image(_), + .. + }) + ) + }) + { + tool_use_ids_with_image_content.insert(update.tool_call_id.to_string()); + } + } + + assert!(tool_use_ids_with_image_content.contains(®istered_tool_use_id.to_string())); + assert!(tool_use_ids_with_image_content.contains(&missing_tool_use_id.to_string())); + } + + #[gpui::test] + async fn test_update_title_tool_replay_does_not_reenter_thread(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + + let tool_use_id = LanguageModelToolUseId::from("title_tool_id"); + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); + push_completed_update_title_tool_call(thread, tool_use_id.clone()); + + thread.replay(cx) + }) + }); + + let mut saw_tool_call_title = false; + let mut saw_replayed_title_update = false; + let mut saw_completed_update = false; + while let Some(event) = replay_events.next().await { + let event = event.unwrap(); + match event { + ThreadEvent::ToolCall(tool_call) + if tool_call.tool_call_id.to_string() == tool_use_id.to_string() + && tool_call.title == "Update title: Replayed title" => + { + saw_tool_call_title = true; + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) + if update.tool_call_id.to_string() == tool_use_id.to_string() => + { + if update.fields.title == Some("Update title: Replayed title".to_string()) { + saw_replayed_title_update = true; + } + if update.fields.status == Some(acp::ToolCallStatus::Completed) { + saw_completed_update = true; + } + } + _ => {} + } + } + + assert!(saw_tool_call_title); + assert!(saw_replayed_title_update); + assert!(saw_completed_update); + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.title(), None); + }); + } + + #[gpui::test] + async fn test_update_title_tool_replay_title_when_tool_not_registered(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + + let tool_use_id = LanguageModelToolUseId::from("title_tool_id"); + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + push_completed_update_title_tool_call(thread, tool_use_id.clone()); + thread.replay(cx) + }) + }); + + let mut saw_tool_call_title = false; + let mut saw_replayed_title_update = false; + let mut saw_completed_update = false; + while let Some(event) = replay_events.next().await { + let event = event.unwrap(); + match event { + ThreadEvent::ToolCall(tool_call) + if tool_call.tool_call_id.to_string() == tool_use_id.to_string() + && tool_call.title == "Update title: Replayed title" => + { + saw_tool_call_title = true; + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) + if update.tool_call_id.to_string() == tool_use_id.to_string() => + { + if update.fields.title == Some("Update title: Replayed title".to_string()) { + saw_replayed_title_update = true; + } + if update.fields.status == Some(acp::ToolCallStatus::Completed) { + saw_completed_update = true; + } + } + _ => {} + } + } + + assert!(saw_tool_call_title); + assert!(saw_replayed_title_update); + assert!(saw_completed_update); + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.title(), None); + }); + } + + fn push_completed_update_title_tool_call( + thread: &mut Thread, + tool_use_id: LanguageModelToolUseId, + ) { + let tool_use = LanguageModelToolUse { + id: tool_use_id.clone(), + name: UpdateTitleTool::NAME.into(), + raw_input: json!({ "title": "Replayed title" }).to_string(), + input: json!({ "title": "Replayed title" }), + is_input_complete: true, + thought_signature: None, + }; + + let mut tool_results = IndexMap::default(); + tool_results.insert( + tool_use_id.clone(), + LanguageModelToolResult { + tool_use_id, + tool_name: UpdateTitleTool::NAME.into(), + is_error: false, + content: vec![LanguageModelToolResultContent::Text( + "Session title updated".into(), + )], + output: Some(json!("Session title updated")), + }, + ); + + thread.messages.push(Arc::new(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::ToolUse(tool_use)], + tool_results, + reasoning_details: None, + }))); + } + #[gpui::test] async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) { let (parent, _event_stream) = setup_thread_for_test(cx).await; diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index e3c8b186454..f5aecdbf682 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -167,6 +167,7 @@ mod tests { thinking_effort: None, draft_prompt: None, ui_scroll_position: None, + sandboxed_terminal_temp_dir: None, } } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 5440fe149f8..187ce7f6578 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -24,6 +24,7 @@ mod symbol_locator; mod terminal_tool; mod tool_permissions; mod update_plan_tool; +mod update_title_tool; mod web_search_tool; mod write_file_tool; @@ -80,6 +81,7 @@ pub use symbol_locator::*; pub use terminal_tool::*; pub use tool_permissions::*; pub use update_plan_tool::*; +pub use update_title_tool::*; pub use web_search_tool::*; pub use write_file_tool::*; @@ -172,6 +174,7 @@ tools! { SpawnAgentTool, TerminalTool, UpdatePlanTool, + UpdateTitleTool, WebSearchTool, WriteFileTool, } diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 01601679c90..d9dc972e24f 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -5,7 +5,7 @@ use collections::{BTreeMap, HashMap}; use context_server::{ContextServerId, client::NotificationSubscription}; use futures::FutureExt as _; use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; -use language_model::LanguageModelToolResultContent; +use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent}; use project::context_server_store::{ContextServerStatus, ContextServerStore}; use std::sync::Arc; use util::ResultExt; @@ -261,7 +261,8 @@ impl ContextServerRegistry { } ContextServerStatus::Stopped | ContextServerStatus::Error(_) - | ContextServerStatus::AuthRequired => { + | ContextServerStatus::AuthRequired + | ContextServerStatus::ClientSecretRequired { .. } => { if let Some(registered_server) = self.registered_servers.remove(server_id) { if !registered_server.tools.is_empty() { cx.emit(ContextServerRegistryEvent::ToolsChanged); @@ -346,7 +347,7 @@ impl AnyAgentTool for ContextServerTool { let authorize = event_stream.authorize_third_party_tool(initial_title, tool_id, display_name, cx); - cx.spawn(async move |_cx| { + cx.spawn(async move |cx| { let input = input .recv() .await @@ -394,15 +395,50 @@ impl AnyAgentTool for ContextServerTool { } let mut llm_output = Vec::new(); + let mut tool_call_content = Vec::new(); let mut concatenated_text = String::new(); for content in response.content { match content { context_server::types::ToolResponseContent::Text { text } => { concatenated_text.push_str(&text); + tool_call_content.push(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new(text.clone())), + ))); llm_output.push(LanguageModelToolResultContent::Text(text.into())); } - context_server::types::ToolResponseContent::Image { .. } => { - log::warn!("Ignoring image content from tool response"); + context_server::types::ToolResponseContent::Image { data, mime_type } => { + tool_call_content.push(acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Image(acp::ImageContent::new( + data.clone(), + mime_type.clone(), + )), + ))); + let language_model_image = cx + .background_spawn({ + let mime_type = mime_type.clone(); + async move { + LanguageModelImage::from_base64_image(&data, &mime_type) + } + }) + .await; + match language_model_image { + Ok(Some(image)) => { + llm_output.push(LanguageModelToolResultContent::Image(image)); + } + Ok(None) => { + log::warn!( + "Skipping MCP tool response image with MIME type `{}` because it cannot be converted for language model input", + mime_type + ); + } + Err(error) => { + log::warn!( + "Failed to convert MCP tool response image with MIME type `{}` for language model input: {:#}", + mime_type, + error + ); + } + } } context_server::types::ToolResponseContent::Audio { .. } => { log::warn!("Ignoring audio content from tool response"); @@ -415,6 +451,10 @@ impl AnyAgentTool for ContextServerTool { } } } + if !tool_call_content.is_empty() { + event_stream + .update_fields(acp::ToolCallUpdateFields::new().content(tool_call_content)); + } let raw_output = serde_json::Value::String(concatenated_text); Ok(AgentToolOutput { raw_output, diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index ae3246d9994..6d300551a59 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -1,5 +1,6 @@ use super::tool_permissions::{ authorize_symlink_escapes, canonicalize_worktree_roots, collect_symlink_escapes, + resolve_creatable_global_skill_descendant_path, resolve_global_skill_descendant_path, sensitive_settings_kind, }; use crate::{ @@ -23,6 +24,7 @@ use util::markdown::MarkdownInlineCode; /// /// This tool should be used when it's desirable to create a copy of a file or directory without modifying the original. /// It's much more efficient than doing this by separately reading and then writing the file or directory's contents, so this tool should be preferred over that approach whenever copying is the goal. +/// The only supported paths outside the project are descendants of `~/.agents/skills`, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CopyPathToolInput { /// The source path of the file or directory to copy. @@ -100,6 +102,15 @@ impl AgentTool for CopyPathTool { let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; + let global_source_path = + resolve_global_skill_descendant_path(Path::new(&input.source_path), fs.as_ref()) + .await; + let global_destination_path = resolve_creatable_global_skill_descendant_path( + Path::new(&input.destination_path), + fs.as_ref(), + ) + .await; + let symlink_escapes: Vec<(&str, std::path::PathBuf)> = project.read_with(cx, |project, cx| { collect_symlink_escapes( @@ -160,6 +171,63 @@ impl AgentTool for CopyPathTool { authorize.await.map_err(|e| e.to_string())?; } + if global_source_path.is_some() || global_destination_path.is_some() { + let source_path = if let Some(global_source_path) = global_source_path { + global_source_path + } else { + project.read_with(cx, |project, cx| { + let project_path = project.find_project_path(&input.source_path, cx).ok_or_else(|| { + format!("Source path {} was not found in the project.", input.source_path) + })?; + project.entry_for_path(&project_path, cx).ok_or_else(|| { + format!("Source path {} was not found in the project.", input.source_path) + })?; + project.absolute_path(&project_path, cx).ok_or_else(|| { + format!("Source path {} could not be resolved.", input.source_path) + }) + })? + }; + + let destination_path = if let Some(global_destination_path) = global_destination_path + { + global_destination_path + } else { + project.read_with(cx, |project, cx| { + let project_path = project.find_project_path(&input.destination_path, cx).ok_or_else(|| { + format!( + "Destination path {} was outside the project.", + input.destination_path + ) + })?; + project.absolute_path(&project_path, cx).ok_or_else(|| { + format!( + "Destination path {} could not be resolved.", + input.destination_path + ) + }) + })? + }; + + futures::select! { + result = fs::copy_recursive( + fs.as_ref(), + &source_path, + &destination_path, + fs::CopyOptions::default(), + ).fuse() => { + result.map_err(|e| format!("Copying {} to {}: {e}", input.source_path, input.destination_path))?; + } + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Copy cancelled by user".to_string()); + } + } + + return Ok(format!( + "Copied {} to {}", + input.source_path, input.destination_path + )); + } + let copy_task = project.update(cx, |project, cx| { match project .find_project_path(&input.source_path, cx) @@ -222,6 +290,124 @@ mod tests { }); } + #[gpui::test] + async fn test_copy_path_global_skill_directory_to_project(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skill_dir = agent_skills::global_skills_dir().join("my-skill"); + fs.insert_tree(&skill_dir, json!({ "SKILL.md": "content" })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(CopyPathTool::new(project)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CopyPathToolInput { + source_path: input_path, + destination_path: path!("/root/project/my-skill").to_string(), + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should copy after approval: {result:?}"); + assert!(fs.is_dir(&skill_dir).await); + assert_eq!( + fs.load(path!("/root/project/my-skill/SKILL.md").as_ref()) + .await + .unwrap(), + "content" + ); + } + + #[gpui::test] + async fn test_copy_path_project_directory_to_global_skill_directory(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root/project"), + json!({ "exported-skill": { "SKILL.md": "content" } }), + ) + .await; + let skills_dir = agent_skills::global_skills_dir(); + fs.create_dir(&skills_dir).await.unwrap(); + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(CopyPathTool::new(project)); + let destination_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("exported-skill") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CopyPathToolInput { + source_path: path!("/root/project/exported-skill").to_string(), + destination_path, + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should copy after approval: {result:?}"); + assert!( + fs.is_dir(path!("/root/project/exported-skill").as_ref()) + .await + ); + assert_eq!( + fs.load(skills_dir.join("exported-skill").join("SKILL.md").as_ref()) + .await + .unwrap(), + "content" + ); + } + #[gpui::test] async fn test_copy_path_symlink_escape_source_requests_authorization(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index ca61a9e632e..dcd051c2a72 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -1,6 +1,6 @@ use super::tool_permissions::{ authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape, - sensitive_settings_kind, + resolve_creatable_global_skill_path, sensitive_settings_kind, }; use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; @@ -22,6 +22,7 @@ use std::path::Path; /// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created. /// /// This tool creates a directory and all necessary parent directories. It should be used whenever you need to create new directories within the project. +/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CreateDirectoryToolInput { /// The path of the new directory. @@ -34,6 +35,10 @@ pub struct CreateDirectoryToolInput { /// /// You can create a new directory by providing a path of "directory1/new_directory" /// + /// + /// + /// To create a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`. + /// pub path: String, } @@ -144,6 +149,21 @@ impl AgentTool for CreateDirectoryTool { authorize.await.map_err(|e| e.to_string())?; } + if let Some(global_skill_directory) = + resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await + { + futures::select! { + result = fs.create_dir(&global_skill_directory).fuse() => { + result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?; + } + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Create directory cancelled by user".to_string()); + } + } + + return Ok(format!("Created directory {destination_path}")); + } + let create_entry = project.update(cx, |project, cx| { match project.find_project_path(&input.path, cx) { Some(project_path) => Ok(project.create_entry(project_path, true, cx)), @@ -190,6 +210,96 @@ mod tests { }); } + #[gpui::test] + async fn test_create_directory_allows_global_skill_directory(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(CreateDirectoryTool::new(project)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .to_string_lossy() + .into_owned(); + let created_path = agent_skills::global_skills_dir().join("my-skill"); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CreateDirectoryToolInput { path: input_path }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!( + result.is_ok(), + "Tool should create global skill directory: {result:?}" + ); + assert!(fs.is_dir(&created_path).await); + } + + #[gpui::test] + async fn test_create_directory_rejects_other_global_paths(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(CreateDirectoryTool::new(project)); + let outside_path = agent_skills::global_skills_dir() + .parent() + .expect("global skills directory should have a parent") + .join("not-skills"); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let result = cx + .update(|cx| { + tool.run( + ToolInput::resolved(CreateDirectoryToolInput { + path: outside_path.to_string_lossy().into_owned(), + }), + event_stream, + cx, + ) + }) + .await; + + assert!( + result.is_err(), + "Tool should reject paths outside the project and global skills directory" + ); + assert!(!fs.is_dir(&outside_path).await); + assert!( + !matches!( + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) + ), + "Non-skill global path should not emit an agent-skills authorization prompt", + ); + } + #[gpui::test] async fn test_create_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index ef6a6f6ce81..e791e6feb51 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -1,6 +1,6 @@ use super::tool_permissions::{ authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape, - sensitive_settings_kind, + resolve_global_skill_descendant_path, resolves_to_global_skills_dir, sensitive_settings_kind, }; use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, @@ -20,6 +20,8 @@ use std::sync::Arc; use util::markdown::MarkdownInlineCode; /// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion. +/// +/// The only supported paths outside the project are descendants of `~/.agents/skills`, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct DeletePathToolInput { /// The path of the file or directory to delete. @@ -95,6 +97,16 @@ impl AgentTool for DeletePathTool { let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; + if resolves_to_global_skills_dir(Path::new(&path), fs.as_ref()).await { + return Err( + "Cannot delete the global agent skills directory itself. Delete a skill directory or file beneath it instead." + .to_string(), + ); + } + + let global_skill_path = + resolve_global_skill_descendant_path(Path::new(&path), fs.as_ref()).await; + let symlink_escape_target = project.read_with(cx, |project, cx| { detect_symlink_escape(project, &path, &canonical_roots, cx) .map(|(_, target)| target) @@ -147,6 +159,38 @@ impl AgentTool for DeletePathTool { authorize.await.map_err(|e| e.to_string())?; } + if let Some(global_skill_path) = global_skill_path { + let metadata = fs + .metadata(&global_skill_path) + .await + .map_err(|e| format!("Deleting {path}: {e}"))? + .ok_or_else(|| format!("Deleting {path}: path not found"))?; + + futures::select! { + result = async { + if metadata.is_dir { + fs.remove_dir( + &global_skill_path, + fs::RemoveOptions { + recursive: true, + ..fs::RemoveOptions::default() + }, + ) + .await + } else { + fs.remove_file(&global_skill_path, fs::RemoveOptions::default()).await + } + }.fuse() => { + result.map_err(|e| format!("Deleting {path}: {e}"))?; + } + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Delete cancelled by user".to_string()); + } + } + + return Ok(format!("Deleted {path}")); + } + let (project_path, worktree_snapshot) = project.read_with(cx, |project, cx| { let project_path = project.find_project_path(&path, cx).ok_or_else(|| { format!("Couldn't delete {path} because that path isn't in this project.") @@ -248,6 +292,145 @@ mod tests { }); } + #[gpui::test] + async fn test_delete_path_global_skill_directory(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skills_dir = agent_skills::global_skills_dir(); + let skill_dir = skills_dir.join("my-skill"); + fs.insert_tree(&skill_dir, json!({ "SKILL.md": "content" })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(DeletePathTool::new(project, action_log)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(DeletePathToolInput { path: input_path }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should delete after approval: {result:?}"); + assert!(fs.is_dir(&skills_dir).await); + assert!(!fs.is_dir(&skill_dir).await); + } + + #[gpui::test] + async fn test_delete_path_global_skill_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skill_file = agent_skills::global_skills_dir() + .join("my-skill") + .join("references") + .join("notes.md"); + fs.create_dir(skill_file.parent().unwrap()).await.unwrap(); + fs.insert_file(&skill_file, b"notes".to_vec()).await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(DeletePathTool::new(project, action_log)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .join("references") + .join("notes.md") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(DeletePathToolInput { path: input_path }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should delete after approval: {result:?}"); + assert!(!fs.is_file(&skill_file).await); + } + + #[gpui::test] + async fn test_delete_path_rejects_global_skills_root(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skills_dir = agent_skills::global_skills_dir(); + fs.create_dir(&skills_dir).await.unwrap(); + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(DeletePathTool::new(project, action_log)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let result = cx + .update(|cx| { + tool.run( + ToolInput::resolved(DeletePathToolInput { path: input_path }), + event_stream, + cx, + ) + }) + .await; + + assert!(result.is_err(), "should reject deleting skills root"); + assert!(fs.is_dir(&skills_dir).await); + assert!( + !matches!( + event_rx.try_recv(), + Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) + ), + "Deleting the skills root should fail before requesting authorization", + ); + } + #[gpui::test] async fn test_delete_path_symlink_escape_requests_authorization(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/diagnostics_tool.rs b/crates/agent/src/tools/diagnostics_tool.rs index 1d6528007d0..89d4ef54677 100644 --- a/crates/agent/src/tools/diagnostics_tool.rs +++ b/crates/agent/src/tools/diagnostics_tool.rs @@ -1,16 +1,18 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; use agent_client_protocol::schema as acp; -use anyhow::Result; -use futures::FutureExt as _; -use gpui::{App, Entity, Task}; +use futures::{Future, FutureExt as _}; +use gpui::{App, AsyncApp, Entity, Task}; use language::{DiagnosticSeverity, OffsetRangeExt}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::{fmt::Write, sync::Arc}; use ui::SharedString; use util::markdown::MarkdownInlineCode; +type Result = core::result::Result; + /// Get errors and warnings for the project or a specific file. /// /// This tool can be invoked after a series of edits to determine if further edits are necessary, or if the user asks to fix errors or warnings in their codebase. @@ -18,6 +20,11 @@ use util::markdown::MarkdownInlineCode; /// When a path is provided, shows all diagnostics for that specific file. /// When no path is provided, shows a summary of error and warning counts for all files in the project. /// +/// This tool attempts to refresh diagnostics before returning. +/// If refreshing diagnostics fails (for example, if the language server does not support pull-based diagnostics), it will return any diagnostics already present. +/// Note that, in this case, the results may be out-of-date, and may or may not reflect the most recent edits. +/// If this happens, do not attempt to re-run this tool in the hope that refreshing will later succeed. Failures are typically persistent. +/// /// /// To get diagnostics for a specific file: /// { @@ -60,6 +67,71 @@ impl DiagnosticsTool { } } +async fn with_cancellation(f: impl Future, s: &ToolCallEventStream) -> Result { + futures::select! { + result = f.fuse() => Ok(result), + _ = s.cancelled_by_user().fuse() => { + Err("Diagnostics cancelled by user".to_string()) + } + } +} + +fn freshness_message(refreshed: bool) -> &'static str { + if refreshed { + "Diagnostics successfully refreshed." + } else { + "Failed to refresh diagnostics. Diagnostics may be stale." + } +} + +/// Attempt to pull fresh diagnostics from the LSP before reading them. +/// +/// Returns `Ok(true)` if diagnostics were successfully refreshed, +/// `Ok(false)` if the pull failed (callers should fall through to +/// read cached diagnostics), or `Err` if cancelled by the user. +async fn pull_diagnostics( + project: &Entity, + path: Option<&Path>, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, +) -> Result { + match path { + Some(path) => { + let open_buffer_task = project.update(cx, |project, cx| { + let Some(project_path) = project.find_project_path(path, cx) else { + return Err(format!("Could not find path {} in project", path.display())); + }; + Ok(project.open_buffer(project_path, cx)) + })?; + + let buffer = with_cancellation(open_buffer_task, event_stream) + .await? + .map_err(|e| e.to_string())?; + + let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }); + let pull_result = with_cancellation(pull_task, event_stream).await?; + if let Err(error) = &pull_result { + log::warn!("Failed to pull diagnostics, using cached: {error:#}"); + } + Ok(pull_result.is_ok()) + } + None => { + let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + let succeeded = with_cancellation(pull_task, event_stream).await?; + if !succeeded { + log::warn!("Failed to pull workspace diagnostics, using cached"); + } + Ok(succeeded) + } + } +} + impl AgentTool for DiagnosticsTool { type Input = DiagnosticsToolInput; type Output = String; @@ -96,21 +168,22 @@ impl AgentTool for DiagnosticsTool { let input = input.recv().await.map_err(|e| e.to_string())?; match input.path { - Some(path) if !path.is_empty() => { - let (_project_path, open_buffer_task) = project.update(cx, |project, cx| { - let Some(project_path) = project.find_project_path(&path, cx) else { + Some(ref path) if !path.is_empty() => { + let refreshed = + pull_diagnostics(&project, Some(Path::new(path)), &event_stream, cx) + .await?; + + let open_buffer_task = project.update(cx, |project, cx| { + let Some(project_path) = project.find_project_path(path, cx) else { return Err(format!("Could not find path {path} in project")); }; - let task = project.open_buffer(project_path.clone(), cx); - Ok((project_path, task)) + Ok(project.open_buffer(project_path, cx)) })?; - let buffer = futures::select! { - result = open_buffer_task.fuse() => result.map_err(|e| e.to_string())?, - _ = event_stream.cancelled_by_user().fuse() => { - return Err("Diagnostics cancelled by user".to_string()); - } - }; + let buffer = with_cancellation(open_buffer_task, &event_stream) + .await? + .map_err(|e| e.to_string())?; + let mut output = String::new(); let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); @@ -133,13 +206,18 @@ impl AgentTool for DiagnosticsTool { .ok(); } + let freshness = freshness_message(refreshed); if output.is_empty() { - Ok("File doesn't have errors or warnings!".to_string()) + Ok(format!( + "{freshness}\n\nFile doesn't have errors or warnings!" + )) } else { - Ok(output) + Ok(format!("{freshness}\n\n{output}")) } } _ => { + let refreshed = pull_diagnostics(&project, None, &event_stream, cx).await?; + let (output, has_diagnostics) = project.read_with(cx, |project, cx| { let mut output = String::new(); let mut has_diagnostics = false; @@ -165,10 +243,13 @@ impl AgentTool for DiagnosticsTool { (output, has_diagnostics) }); + let freshness = freshness_message(refreshed); if has_diagnostics { - Ok(output) + Ok(format!("{freshness}\n\n{output}")) } else { - Ok("No errors or warnings found in the project.".into()) + Ok(format!( + "{freshness}\n\nNo errors or warnings found in the project." + )) } } } diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index e952741d9c0..2801c8878d1 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -23,13 +23,21 @@ const DEFAULT_UI_TEXT: &str = "Editing file"; /// This is a tool for applying edits to an existing file. /// -/// Before using this tool, use the `read_file` tool to understand the file's contents and context +/// Before using this tool, use the `read_file` tool to understand the file's contents and context. /// To create a new file or overwrite an existing one with completely new contents, use the `write_file` tool instead. +/// +/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills. +/// +/// `read_file` prefixes each line of its output with a line number right-aligned in a +/// 6-character field followed by a single tab, then the line's actual content. When you +/// derive `old_text` or `new_text` from that output, strip this prefix and keep only what +/// comes after the tab, preserving the original indentation (tabs and spaces) exactly. +/// Never include any part of the line number prefix in `old_text` or `new_text`. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct EditFileToolInput { /// The full path of the file to edit in the project. /// - /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories. + /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories, unless it's a global agent skill under `~/.agents/skills`. /// /// The following examples assume we have two root directories in the project: /// - /a/b/backend @@ -44,6 +52,10 @@ pub struct EditFileToolInput { /// /// `frontend/db.js` /// + /// + /// + /// To edit a global agent skill file, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill/SKILL.md`. + /// pub path: PathBuf, /// List of edit operations to apply sequentially. @@ -253,6 +265,7 @@ impl AgentTool for EditFileTool { run_session( self.process_streaming_edits(&mut input, &event_stream, cx) .await, + &event_stream, cx, ) .await @@ -457,6 +470,63 @@ mod tests { assert_eq!(input_path, None); } + #[gpui::test] + async fn test_streaming_edit_global_skill_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({})).await; + let skill_dir = agent_skills::global_skills_dir().join("my-skill"); + fs.insert_tree(&skill_dir, json!({ "SKILL.md": "old content\n" })) + .await; + let (edit_tool, _project, _action_log, fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; + + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .join("SKILL.md"); + let skill_file = agent_skills::global_skills_dir() + .join("my-skill") + .join("SKILL.md"); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: input_path, + edits: vec![Edit { + old_text: "old content".into(), + new_text: "new content".into(), + }], + }), + event_stream, + cx, + ) + }); + + event_rx.expect_update_fields().await; + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new content\n"); + assert_eq!(fs.load(&skill_file).await.unwrap(), "new content\n"); + } + #[gpui::test] async fn test_streaming_edit_failed_match(cx: &mut TestAppContext) { let (edit_tool, _project, _action_log, _fs, _thread) = @@ -486,6 +556,69 @@ mod tests { ); } + /// When the edit fails after a session is created but before any edits are + /// actually applied (e.g., the first `old_text` doesn't match), the empty + /// diff placeholder in the UI should be replaced with the error message. + #[gpui::test] + async fn test_streaming_edit_surfaces_error_when_no_edits_applied(cx: &mut TestAppContext) { + async fn find_first_text_content_in_events( + receiver: &mut crate::ToolCallEventStreamReceiver, + ) -> Option { + use futures::StreamExt as _; + while let Some(event) = receiver.next().await { + let Ok(crate::ThreadEvent::ToolCallUpdate( + acp_thread::ToolCallUpdate::UpdateFields(update), + )) = event + else { + continue; + }; + let Some(content) = update.fields.content else { + continue; + }; + for item in content { + if let acp::ToolCallContent::Content(c) = item + && let acp::ContentBlock::Text(text) = c.content + { + return Some(text.text); + } + } + } + None + } + + let (edit_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello world"})).await; + let (event_stream, mut receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: "root/file.txt".into(), + edits: vec![Edit { + old_text: "nonexistent text that is not in the file".into(), + new_text: "replacement".into(), + }], + }), + event_stream, + cx, + ) + }); + + let EditFileToolOutput::Error { error, diff, .. } = task.await.unwrap_err() else { + panic!("expected error"); + }; + assert!( + diff.is_empty(), + "sanity check: no edits should have been applied", + ); + + let content_text = find_first_text_content_in_events(&mut receiver).await; + assert_eq!( + content_text.as_deref(), + Some(error.as_str()), + "expected the failure message to be surfaced as tool call content", + ); + } + #[gpui::test] async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) { let (edit_tool, _project, _action_log, _fs, _thread) = @@ -2475,7 +2608,8 @@ mod tests { cx.run_until_parked(); - let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); + let changed = + action_log.read_with(cx, |log, cx| log.changed_buffers(cx).collect::>()); assert!( !changed.is_empty(), "action_log.changed_buffers() should be non-empty after streaming edit, diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index 7955144f8ee..016058318bf 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -2,6 +2,7 @@ mod reindent; mod streaming_fuzzy_matcher; mod streaming_parser; +use super::tool_permissions::resolve_creatable_global_skill_path; use crate::{Thread, ToolCallEventStream}; use acp_thread::Diff; use action_log::ActionLog; @@ -11,7 +12,7 @@ use collections::HashSet; use futures::{FutureExt, channel::oneshot}; use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; use language::language_settings::{self, FormatOnSave}; -use language::{Buffer, BufferEvent, LanguageRegistry}; +use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry}; use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{AgentLocation, Project, ProjectPath}; @@ -277,6 +278,7 @@ pub(crate) enum EditSessionResult { pub(crate) async fn run_session( result: EditSessionResult, + event_stream: &ToolCallEventStream, cx: &mut AsyncApp, ) -> Result { match result { @@ -302,6 +304,11 @@ pub(crate) async fn run_session( .ensure_buffer_saved(&session.buffer, cx) .await; let (_new_text, diff) = session.compute_new_text_and_diff(cx).await; + if diff.is_empty() { + event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new(error.clone())), + ])); + } Err(EditSessionOutput::Error { error, input_path: Some(session.input_path), @@ -311,11 +318,16 @@ pub(crate) async fn run_session( EditSessionResult::Failed { error, session: None, - } => Err(EditSessionOutput::Error { - error, - input_path: None, - diff: String::new(), - }), + } => { + event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new(error.clone())), + ])); + Err(EditSessionOutput::Error { + error, + input_path: None, + diff: String::new(), + }) + } } } @@ -352,6 +364,16 @@ pub(crate) struct EditSession { _finalize_diff_guard: Deferred>, } +/// The destination of an edit session, identified by its absolute path on +/// disk. `project_path` is `Some` for files that live inside one of the +/// project's worktrees (i.e. that the standard project-path machinery can +/// resolve), and `None` for global skill files reached through the +/// `~/.agents/skills` allowlist. +struct EditSessionTarget { + abs_path: PathBuf, + project_path: Option, +} + enum Pipeline { Write(WritePipeline), Edit(EditPipeline), @@ -598,21 +620,14 @@ impl EditPipeline { log::debug!("new_text_chunk: done=true, final_text='{}'", final_text); - if !final_text.is_empty() { - let char_ops = streaming_diff.push_new(&final_text); - apply_char_operations( - &char_ops, - buffer, - &original_snapshot, - &mut edit_cursor, - &context.action_log, - cx, - ); - } - - let remaining_ops = streaming_diff.finish(); + let mut char_ops = if final_text.is_empty() { + Vec::new() + } else { + streaming_diff.push_new(&final_text) + }; + char_ops.extend(streaming_diff.finish()); apply_char_operations( - &remaining_ops, + &char_ops, buffer, &original_snapshot, &mut edit_cursor, @@ -639,16 +654,34 @@ impl EditSession { event_stream: &ToolCallEventStream, cx: &mut AsyncApp, ) -> Result { - let project_path = cx.update(|cx| resolve_path(mode, &path, &context.project, cx))?; + let target = if let Some(abs_path) = + resolve_global_skill_path_for_edit_session(mode, &path, &context, cx).await? + { + EditSessionTarget { + abs_path, + project_path: None, + } + } else { + let project_path = cx.update(|cx| resolve_path(mode, &path, &context.project, cx))?; - let Some(abs_path) = - cx.update(|cx| context.project.read(cx).absolute_path(&project_path, cx)) - else { - return Err(format!( - "Worktree at '{}' does not exist", - path.to_string_lossy() - )); + let Some(abs_path) = + cx.update(|cx| context.project.read(cx).absolute_path(&project_path, cx)) + else { + return Err(format!( + "Worktree at '{}' does not exist", + path.to_string_lossy() + )); + }; + + EditSessionTarget { + abs_path, + project_path: Some(project_path), + } }; + let EditSessionTarget { + abs_path, + project_path, + } = target; event_stream.update_fields( ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]), @@ -658,11 +691,20 @@ impl EditSession { .await .map_err(|e| e.to_string())?; - let buffer = context - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - .await - .map_err(|e| e.to_string())?; + let buffer = match project_path { + Some(project_path) => context + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .map_err(|e| e.to_string())?, + None => context + .project + .update(cx, |project, cx| { + project.open_local_buffer(abs_path.clone(), cx) + }) + .await + .map_err(|e| e.to_string())?, + }; let file_changed_since_last_read = ensure_buffer_saved(&buffer, &abs_path, mode, &context, event_stream, cx).await?; @@ -853,16 +895,17 @@ fn apply_char_operations( action_log: &Entity, cx: &mut AsyncApp, ) { + let mut edits: Vec<_> = Vec::new(); for op in ops { match op { CharOperation::Insert { text } => { let anchor = snapshot.anchor_after(*edit_cursor); - agent_edit_buffer(&buffer, [(anchor..anchor, text.as_str())], action_log, cx); + edits.push((anchor..anchor, text.as_str().into())); } CharOperation::Delete { bytes } => { let delete_end = *edit_cursor + bytes; let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end); - agent_edit_buffer(&buffer, [(anchor_range, "")], action_log, cx); + edits.push((anchor_range, Arc::::from(""))); *edit_cursor = delete_end; } CharOperation::Keep { bytes } => { @@ -870,6 +913,9 @@ fn apply_char_operations( } } } + if !edits.is_empty() { + agent_edit_buffer(buffer, edits, action_log, cx); + } } fn extract_match( @@ -926,7 +972,9 @@ fn agent_edit_buffer( { cx.update(|cx| { buffer.update(cx, |buffer, cx| { + buffer.start_transaction(); buffer.edit(edits, None, cx); + buffer.end_transaction_with_source(BufferEditSource::Agent, cx); }); action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); }); @@ -1055,6 +1103,72 @@ async fn resolve_dirty_buffer( Ok(()) } +/// Mirrors [`resolve_path`]'s pre-auth validation for the global-skill +/// branch: returns `Ok(Some(abs_path))` if the path lives under +/// `~/.agents/skills` and is in a valid state for the requested mode, +/// `Ok(None)` if the path isn't a global skill at all (so the caller should +/// fall through to project-path resolution), or `Err(message)` if the path +/// is a global skill but can't be used (missing in Edit mode, parent +/// missing in Write mode, etc.). +/// +/// Errors returned from here surface to the model as tool-result errors +/// without prompting the user — same contract as [`resolve_path`]. The +/// idea is that "file doesn't exist" or "parent isn't a directory" are +/// model mistakes, not decisions the user should be asked to approve. +async fn resolve_global_skill_path_for_edit_session( + mode: EditSessionMode, + path: &PathBuf, + context: &EditSessionContext, + cx: &mut AsyncApp, +) -> Result, String> { + let fs = context + .project + .read_with(cx, |project, _cx| project.fs().clone()); + let Some(abs_path) = resolve_creatable_global_skill_path(path, fs.as_ref()).await else { + return Ok(None); + }; + + match mode { + EditSessionMode::Edit => { + let metadata = fs + .metadata(&abs_path) + .await + .map_err(|e| format!("Can't edit file: {e}"))? + .ok_or_else(|| "Can't edit file: path not found".to_string())?; + if metadata.is_dir { + return Err("Can't edit file: path is a directory".to_string()); + } + } + EditSessionMode::Write => { + if let Some(metadata) = fs + .metadata(&abs_path) + .await + .map_err(|e| format!("Can't write to file: {e}"))? + { + if metadata.is_dir { + return Err("Can't write to file: path is a directory".to_string()); + } + } else { + let parent_path = abs_path + .parent() + .ok_or_else(|| "Can't create file: incorrect path".to_string())?; + let parent_metadata = fs + .metadata(parent_path) + .await + .map_err(|e| format!("Can't create file: {e}"))? + .ok_or_else(|| { + "Can't create file: parent directory doesn't exist".to_string() + })?; + if !parent_metadata.is_dir { + return Err("Can't create file: parent is not a directory".to_string()); + } + } + } + } + + Ok(Some(abs_path)) +} + fn resolve_path( mode: EditSessionMode, path: &PathBuf, diff --git a/crates/agent/src/tools/edit_session/streaming_parser.rs b/crates/agent/src/tools/edit_session/streaming_parser.rs index 3961edf564c..71dbc2c9bba 100644 --- a/crates/agent/src/tools/edit_session/streaming_parser.rs +++ b/crates/agent/src/tools/edit_session/streaming_parser.rs @@ -113,7 +113,7 @@ impl StreamingParser { { if partial.new_text.is_some() && !state.buffer_new_text_until_old_text_done { // new_text appeared after old_text, so old_text is done — emit everything. - let start = state.old_text_emitted_len.min(old_text.len()); + let start = find_char_boundary(old_text, state.old_text_emitted_len); let chunk = normalize_done_chunk(old_text[start..].to_string()); state.old_text_done = true; state.old_text_emitted_len = old_text.len(); @@ -124,9 +124,10 @@ impl StreamingParser { }); } else { let safe_end = safe_emit_end_for_edit_text(old_text); + let safe_start = find_char_boundary(old_text, state.old_text_emitted_len); - if safe_end > state.old_text_emitted_len { - let chunk = old_text[state.old_text_emitted_len..safe_end].to_string(); + if safe_end > safe_start { + let chunk = old_text[safe_start..safe_end].to_string(); state.old_text_emitted_len = safe_end; events.push(EditEvent::OldTextChunk { edit_index: index, @@ -143,9 +144,10 @@ impl StreamingParser { && !state.new_text_done { let safe_end = safe_emit_end_for_edit_text(new_text); + let safe_start = find_char_boundary(new_text, state.new_text_emitted_len); - if safe_end > state.new_text_emitted_len { - let chunk = new_text[state.new_text_emitted_len..safe_end].to_string(); + if safe_end > safe_start { + let chunk = new_text[safe_start..safe_end].to_string(); state.new_text_emitted_len = safe_end; events.push(EditEvent::NewTextChunk { edit_index: index, @@ -343,8 +345,10 @@ impl StreamingParser { /// held back because it may be an artifact of the partial JSON fixer closing /// an incomplete escape sequence (e.g. turning a half-received `\n` into `\\`). /// The next partial will reveal the correct character. +/// +/// The returned position is always a valid UTF-8 character boundary. fn safe_emit_end(text: &str) -> usize { - if text.as_bytes().last() == Some(&b'\\') { + if text.ends_with('\\') { text.len() - 1 } else { text.len() @@ -353,13 +357,35 @@ fn safe_emit_end(text: &str) -> usize { fn safe_emit_end_for_edit_text(text: &str) -> usize { let safe_end = safe_emit_end(text); - if safe_end > 0 && text.as_bytes()[safe_end - 1] == b'\n' { + // Use string slicing to check the last character, ensuring we respect UTF-8 boundaries. + if safe_end > 0 && text[..safe_end].ends_with('\n') { safe_end - 1 } else { safe_end } } +/// Finds a valid UTF-8 character boundary at or before the target position. +/// +/// When streaming partial JSON, the text structure can change between updates +/// (e.g., an escape sequence being completed). This means a byte position that +/// was valid in one partial may land inside a multi-byte character in the next. +/// This function finds the nearest valid boundary at or before the target. +fn find_char_boundary(text: &str, target: usize) -> usize { + if target >= text.len() { + return text.len(); + } + if text.is_char_boundary(target) { + return target; + } + // Walk backwards to find a valid boundary. + let mut pos = target; + while pos > 0 && !text.is_char_boundary(pos) { + pos -= 1; + } + pos +} + fn normalize_done_chunk(mut chunk: String) -> String { if chunk.ends_with('\n') { chunk.pop(); @@ -1146,4 +1172,77 @@ mod tests { }] ); } + + #[test] + fn test_multibyte_char_with_trailing_backslash() { + // Reproduces a panic where the stored `old_text_emitted_len` from a previous + // partial lands inside a multi-byte UTF-8 character in the current partial. + // + // Scenario: The JSON fixer produces a literal backslash when the stream cuts + // mid-escape. If the *next* partial replaces that backslash with a multi-byte + // character (e.g., em-dash '—'), the stored byte position is no longer valid. + let mut parser = StreamingParser::default(); + + // First partial: text ends with backslash (held back by safe_emit_end). + // "abc" = 3 bytes, backslash held back, so emitted_len = 3. + let events = parser.push_edits(&[PartialEdit { + old_text: Some("abc\\".into()), + new_text: None, + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "abc".into(), + done: false, + }] + ); + + // Second partial: the backslash is replaced by em-dash '—' (3 bytes: E2 80 94). + // "ab—" = 2 + 3 = 5 bytes total, with em-dash at bytes 2..5. + // The stored emitted_len (3) is inside the em-dash! + // This should NOT panic. + let events = parser.push_edits(&[PartialEdit { + old_text: Some("ab—".into()), + new_text: None, + }]); + // The parser should handle this gracefully. + let _ = events; + } + + #[test] + fn test_emitted_len_inside_multibyte_char_boundary() { + // More direct reproduction: emitted_len points inside a multi-byte character. + // + // This can happen when: + // 1. First partial has text where byte N is a valid boundary + // 2. Second partial has *different* text where byte N is inside a multi-byte char + let mut parser = StreamingParser::default(); + + // First partial: "ab" (2 bytes), backslash held back. + // After processing: emitted_len = 2 + let events = parser.push_edits(&[PartialEdit { + old_text: Some("ab\\".into()), + new_text: None, + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "ab".into(), + done: false, + }] + ); + + // Second partial: "a—" where em-dash starts at byte 1 and spans bytes 1-3. + // Stored emitted_len = 2, but byte 2 is inside the em-dash! + // This should NOT panic. + let events = parser.push_edits(&[PartialEdit { + old_text: Some("a—".into()), + new_text: None, + }]); + // The parser should handle this gracefully. + // We don't care exactly what it emits, just that it doesn't panic. + let _ = events; + } } diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index 08bf869d14b..3a821737fad 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -361,7 +361,7 @@ impl EditToolTest { abs_path: Path::new("/path/to/root").into(), rules_file: None, }]; - let project_context = ProjectContext::new(worktrees, Vec::default()); + let project_context = ProjectContext::new(worktrees); let tool_names = tools .iter() .map(|tool| tool.name.clone().into()) @@ -372,6 +372,7 @@ impl EditToolTest { model_name: None, date: chrono::Local::now().format("%Y-%m-%d").to_string(), user_agents_md: None, + sandboxing: false, }; let templates = Templates::new(); template.render(&templates)? diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index c06ce08db44..2441b5014f5 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -220,7 +220,7 @@ impl TerminalToolTest { abs_path: Path::new("/path/to/root").into(), rules_file: None, }]; - let project_context = ProjectContext::new(worktrees, Vec::default()); + let project_context = ProjectContext::new(worktrees); let tool_names = tools .iter() .map(|tool| tool.name.clone().into()) @@ -231,6 +231,7 @@ impl TerminalToolTest { model_name: None, date: chrono::Local::now().format("%Y-%m-%d").to_string(), user_agents_md: None, + sandboxing: false, }; template.render(&Templates::new())? }; diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index b8f3de78587..b038bf1f8bf 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -191,7 +191,7 @@ impl WriteToolTest { abs_path: Path::new("/path/to/root").into(), rules_file: None, }]; - let project_context = ProjectContext::new(worktrees, Vec::default()); + let project_context = ProjectContext::new(worktrees); let tool_names = tools .iter() .map(|tool| tool.name.clone().into()) @@ -202,6 +202,7 @@ impl WriteToolTest { model_name: None, date: chrono::Local::now().format("%Y-%m-%d").to_string(), user_agents_md: None, + sandboxing: false, }; let templates = Templates::new(); template.render(&templates)? diff --git a/crates/agent/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs index b8c4dfd1c77..637c625bce0 100644 --- a/crates/agent/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -18,11 +18,13 @@ use std::sync::Arc; use util::markdown::MarkdownInlineCode; /// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase. +/// +/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ListDirectoryToolInput { /// The fully-qualified path of the directory to list in the project. /// - /// This path should never be absolute, and the first component of the path should always be a root directory in a project. + /// This path should never be absolute, and the first component of the path should always be a root directory in a project, unless it's a global agent skill directory under `~/.agents/skills`. /// /// /// If the project has the following root directories: @@ -41,6 +43,10 @@ pub struct ListDirectoryToolInput { /// /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`. /// + /// + /// + /// To list a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`. + /// pub path: String, } @@ -234,7 +240,7 @@ impl AgentTool for ListDirectoryTool { // Fast path: a global skill resource lives outside any worktree, so // standard project-path resolution would refuse it. If the path - // resolves under the global skills tree, list it directly. + // expands and resolves under the global skills tree, list it directly. if let Some(skill_path) = resolve_global_skill_path(Path::new(&input.path), fs.as_ref()).await { diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index 3963565caf2..000f17a38c9 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -1,6 +1,7 @@ use super::tool_permissions::{ authorize_symlink_escapes, canonicalize_worktree_roots, collect_symlink_escapes, - sensitive_settings_kind, + resolve_creatable_global_skill_descendant_path, resolve_global_skill_descendant_path, + resolves_to_global_skills_dir, sensitive_settings_kind, }; use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, @@ -22,6 +23,7 @@ use util::markdown::MarkdownInlineCode; /// If the source and destination directories are the same, but the filename is different, this performs a rename. Otherwise, it performs a move. /// /// This tool should be used when it's desirable to move or rename a file or directory without changing its contents at all. +/// The only supported paths outside the project are descendants of `~/.agents/skills`, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct MovePathToolInput { /// The source path of the file or directory to move/rename. @@ -116,6 +118,28 @@ impl AgentTool for MovePathTool { let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; + if resolves_to_global_skills_dir(Path::new(&input.source_path), fs.as_ref()).await + || resolves_to_global_skills_dir( + Path::new(&input.destination_path), + fs.as_ref(), + ) + .await + { + return Err( + "Cannot move the global agent skills directory itself. Move a skill directory or file beneath it instead." + .to_string(), + ); + } + + let global_source_path = + resolve_global_skill_descendant_path(Path::new(&input.source_path), fs.as_ref()) + .await; + let global_destination_path = resolve_creatable_global_skill_descendant_path( + Path::new(&input.destination_path), + fs.as_ref(), + ) + .await; + let symlink_escapes: Vec<(&str, std::path::PathBuf)> = project.read_with(cx, |project, cx| { collect_symlink_escapes( @@ -176,6 +200,65 @@ impl AgentTool for MovePathTool { authorize.await.map_err(|e| e.to_string())?; } + if global_source_path.is_some() || global_destination_path.is_some() { + let source_path = if let Some(global_source_path) = global_source_path { + global_source_path + } else { + project.read_with(cx, |project, cx| { + let project_path = project.find_project_path(&input.source_path, cx).ok_or_else(|| { + format!("Source path {} was not found in the project.", input.source_path) + })?; + project.entry_for_path(&project_path, cx).ok_or_else(|| { + format!("Source path {} was not found in the project.", input.source_path) + })?; + project.absolute_path(&project_path, cx).ok_or_else(|| { + format!("Source path {} could not be resolved.", input.source_path) + }) + })? + }; + + let destination_path = if let Some(global_destination_path) = global_destination_path + { + global_destination_path + } else { + project.read_with(cx, |project, cx| { + let project_path = project.find_project_path(&input.destination_path, cx).ok_or_else(|| { + format!( + "Destination path {} was outside the project.", + input.destination_path + ) + })?; + project.absolute_path(&project_path, cx).ok_or_else(|| { + format!( + "Destination path {} could not be resolved.", + input.destination_path + ) + }) + })? + }; + + futures::select! { + result = fs.rename( + &source_path, + &destination_path, + fs::RenameOptions { + create_parents: true, + ..fs::RenameOptions::default() + }, + ).fuse() => { + result.map_err(|e| format!("Moving {} to {}: {e}", input.source_path, input.destination_path))?; + } + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Move cancelled by user".to_string()); + } + } + + return Ok(format!( + "Moved {} to {}", + input.source_path, input.destination_path + )); + } + let rename_task = project.update(cx, |project, cx| { match project .find_project_path(&input.source_path, cx) @@ -232,6 +315,125 @@ mod tests { }); } + #[gpui::test] + async fn test_move_path_global_skill_directory_to_project(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root/project"), json!({})).await; + let skill_dir = agent_skills::global_skills_dir().join("my-skill"); + fs.insert_tree(&skill_dir, json!({ "SKILL.md": "content" })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(MovePathTool::new(project)); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .to_string_lossy() + .into_owned(); + let destination_path = path!("/root/project/my-skill").to_string(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(MovePathToolInput { + source_path: input_path, + destination_path, + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should move after approval: {result:?}"); + assert!(!fs.is_dir(&skill_dir).await); + assert_eq!( + fs.load(path!("/root/project/my-skill/SKILL.md").as_ref()) + .await + .unwrap(), + "content" + ); + } + + #[gpui::test] + async fn test_move_path_project_directory_to_global_skill_directory(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root/project"), + json!({ "exported-skill": { "SKILL.md": "content" } }), + ) + .await; + let skills_dir = agent_skills::global_skills_dir(); + fs.create_dir(&skills_dir).await.unwrap(); + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let tool = Arc::new(MovePathTool::new(project)); + let destination_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("exported-skill") + .to_string_lossy() + .into_owned(); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(MovePathToolInput { + source_path: path!("/root/project/exported-skill").to_string(), + destination_path, + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let result = task.await; + assert!(result.is_ok(), "should move after approval: {result:?}"); + assert!( + !fs.is_dir(path!("/root/project/exported-skill").as_ref()) + .await + ); + assert_eq!( + fs.load(skills_dir.join("exported-skill").join("SKILL.md").as_ref()) + .await + .unwrap(), + "content" + ); + } + #[gpui::test] async fn test_move_path_symlink_escape_source_requests_authorization(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index 95301ee1523..cf075d74a29 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -18,6 +18,80 @@ fn tool_content_err(e: impl std::fmt::Display) -> LanguageModelToolResultContent LanguageModelToolResultContent::from(e.to_string()) } +/// Resolves the optional `start_line` / `end_line` inputs from the tool schema +/// to a concrete 1-indexed, inclusive `(start, end)` line range: +/// +/// - `start` defaults to 1 and is clamped to `>= 1` (the model occasionally passes +/// `0` despite instructions to be 1-indexed). +/// - `end` defaults to `u32::MAX` and is clamped to `>= start`, so callers always +/// read at least one line even when the model passes `end < start`. +/// +/// Callers translate this 1-indexed inclusive range to whichever coordinate +/// system their slicing API wants (e.g. 0-indexed exclusive row ranges for +/// `Buffer::text_for_range`). +fn resolve_line_range(start_line: Option, end_line: Option) -> (u32, u32) { + let start = start_line.unwrap_or(1).max(1); + let end = end_line.unwrap_or(u32::MAX).max(start); + (start, end) +} + +/// Prefixes each line of `text` with its line number in `cat -n` format: +/// the line number is right-aligned in a 6-character field, followed by a +/// single tab, followed by the line's original content (including its +/// trailing newline if present). Numbering starts at `start_line`. +/// +/// This format matches what the model expects in the edit tool, where the +/// line number prefix is `line number + tab` and everything after the tab is +/// the actual file content to match. +fn format_with_line_numbers(text: &str, start_line: u32) -> String { + if text.is_empty() { + return String::new(); + } + + let mut output = String::with_capacity(text.len() + text.len() / 4); + write_lines_numbered(&mut output, std::iter::once(text), start_line); + output +} + +/// Streams `cat -n`-style line-numbered output directly into `output` from an +/// iterator of string slices. Chunks do not need to align to line boundaries: +/// a single chunk may contain multiple newlines, span multiple lines, or end +/// mid-line. This lets callers consume `Buffer::text_for_range`'s `Chunks` +/// iterator without materializing the unnumbered text first. +fn write_lines_numbered<'a>( + output: &mut String, + chunks: impl IntoIterator, + start_line: u32, +) { + use std::fmt::Write as _; + + let mut line_number = start_line; + let mut at_line_start = true; + for chunk in chunks { + let mut rest = chunk; + while !rest.is_empty() { + if at_line_start { + // Writes to a `String` are infallible, so the `Result` can be ignored. + let _ = write!(output, "{line_number:>6}\t"); + at_line_start = false; + } + match rest.find('\n') { + Some(nl) => { + let (head, tail) = rest.split_at(nl + 1); + output.push_str(head); + line_number = line_number.saturating_add(1); + at_line_start = true; + rest = tail; + } + None => { + output.push_str(rest); + break; + } + } + } + } +} + /// Read a file under the global skills directory directly via the filesystem, /// bypassing project/worktree resolution. Used for skill resources that live /// outside any worktree. @@ -40,26 +114,21 @@ async fn read_global_skill_file( .line(start_line.map(|line| line.saturating_sub(1))), ])); - let result_text = if start_line.is_some() || end_line.is_some() { - // Mirror the line-range semantics of the buffer-backed path: 1-indexed, - // start clamped to >= 1, end exclusive of the next line, and always - // returning at least one line. `split_inclusive` keeps each line's - // terminator attached, so CRLF stays CRLF and the trailing newline of - // the last returned line is preserved — matching `Buffer::text_for_range`. - let start = start_line.unwrap_or(1).max(1); - let mut end = end_line.unwrap_or(u32::MAX); - if end < start { - end = start; - } - + let (raw_text, first_line_number) = if start_line.is_some() || end_line.is_some() { + // `split_inclusive` keeps each line's terminator attached, so CRLF stays + // CRLF and the trailing newline of the last returned line is preserved — + // matching `Buffer::text_for_range` in the buffer-backed path. + let (start, end) = resolve_line_range(start_line, end_line); let lines: Vec<&str> = content.split_inclusive('\n').collect(); let start_idx = (start as usize).saturating_sub(1).min(lines.len()); let end_idx = (end as usize).min(lines.len()).max(start_idx); - lines[start_idx..end_idx].concat() + (lines[start_idx..end_idx].concat(), start) } else { - content + (content, 1) }; + let result_text = format_with_line_numbers(&raw_text, first_line_number); + let markdown = MarkdownCodeBlock { tag: requested_path, text: &result_text, @@ -86,11 +155,13 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput, outline}; /// Do NOT retry reading the same file without line numbers if you receive an outline. /// - This tool supports reading image files. Supported formats: PNG, JPEG, WebP, GIF, BMP, TIFF. /// Image files are returned as visual content that you can analyze directly. +/// +/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ReadFileToolInput { /// The relative path of the file to read. /// - /// This path should never be absolute, and the first component of the path should always be a root directory in a project. + /// This path should never be absolute, and the first component of the path should always be a root directory in a project, unless it's a global agent skill under `~/.agents/skills`. /// /// /// If the project has the following root directories: @@ -101,6 +172,10 @@ pub struct ReadFileToolInput { /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`. /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`. /// + /// + /// + /// To read a global agent skill file, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill/SKILL.md`. + /// pub path: String, /// Optional line number to start reading on (1-based index) #[serde(default)] @@ -182,8 +257,8 @@ impl AgentTool for ReadFileTool { .map_err(tool_content_err)?; let fs = project.read_with(cx, |project, _cx| project.fs().clone()); - // Fast path: if the model passes an absolute path that resolves - // under the global skills directory, read it directly via the + // Fast path: if the model passes a path that resolves under the + // global skills directory, read it directly via the // filesystem. Global skills live outside any worktree, so the // standard project-path machinery would refuse them. if let Some(skill_path) = @@ -338,32 +413,42 @@ impl AgentTool for ReadFileTool { } let mut anchor = None; + let mut is_outline_response = false; // Check if specific line ranges are provided let result = if input.start_line.is_some() || input.end_line.is_some() { - let result = buffer.read_with(cx, |buffer, _cx| { - // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0. - let start = input.start_line.unwrap_or(1).max(1); + let result_text = buffer.read_with(cx, |buffer, _cx| { + let (start, end) = resolve_line_range(input.start_line, input.end_line); let start_row = start - 1; if start_row <= buffer.max_point().row { let column = buffer.line_indent_for_row(start_row).raw_len(); anchor = Some(buffer.anchor_before(Point::new(start_row, column))); } - let mut end_row = input.end_line.unwrap_or(u32::MAX); - if end_row <= start_row { - end_row = start_row + 1; // read at least one lines - } - let start = buffer.anchor_before(Point::new(start_row, 0)); - let end = buffer.anchor_before(Point::new(end_row, 0)); - buffer.text_for_range(start..end).collect::() + // `end` is 1-indexed inclusive; `Point` rows are 0-indexed. + // Using `end` directly as the (exclusive) end row is the + // standard inclusive→exclusive translation, and since + // `resolve_line_range` guarantees `end >= start`, we always + // read at least one line. + let start_anchor = buffer.anchor_before(Point::new(start_row, 0)); + let end_anchor = buffer.anchor_before(Point::new(end, 0)); + // Stream the numbered output directly from the buffer's + // chunk iterator so the unnumbered range is never + // materialized as its own `String`. + let mut output = String::new(); + write_lines_numbered( + &mut output, + buffer.text_for_range(start_anchor..end_anchor), + start, + ); + output }); action_log.update(cx, |log, cx| { log.buffer_read(buffer.clone(), cx); }); - Ok(result.into()) + Ok(result_text.into()) } else { // No line ranges specified, so check file size to see if it's too big. let buffer_content = outline::get_buffer_content_or_outline( @@ -377,7 +462,10 @@ impl AgentTool for ReadFileTool { log.buffer_read(buffer.clone(), cx); }); - if buffer_content.is_outline { + + is_outline_response = buffer_content.is_synthetic; + + if buffer_content.is_synthetic { Ok(formatdoc! {" SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers. @@ -391,7 +479,7 @@ impl AgentTool for ReadFileTool { } .into()) } else { - Ok(buffer_content.text.into()) + Ok(format_with_line_numbers(&buffer_content.text, 1).into()) } }; @@ -409,11 +497,12 @@ impl AgentTool for ReadFileTool { } if let Ok(LanguageModelToolResultContent::Text(text)) = &result { let text: &str = text; - let markdown = MarkdownCodeBlock { - tag: &input.path, - text, - } - .to_string(); + // For outline responses, omit the path tag so the markdown renderer + // does not invoke tree-sitter syntax highlighting against pseudo-code + // outline text. The outline is not valid source for the file's language, + // so highlighting would be both expensive and incorrect. + let tag: &str = if is_outline_response { "" } else { &input.path }; + let markdown = MarkdownCodeBlock { tag, text }.to_string(); event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ acp::ToolCallContent::Content(acp::Content::new(markdown)), ])); @@ -423,6 +512,27 @@ impl AgentTool for ReadFileTool { result }) } + + fn replay( + &self, + input: Self::Input, + output: Self::Output, + event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Result<()> { + if let LanguageModelToolResultContent::Text(text) = output { + let markdown = MarkdownCodeBlock { + tag: &input.path, + text: &text, + } + .to_string(); + event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new(markdown)), + ])); + } + + Ok(()) + } } #[cfg(test)] @@ -526,7 +636,10 @@ mod test { ) }) .await; - assert_eq!(result.unwrap(), "This is a small file content".into()); + assert_eq!( + result.unwrap(), + " 1\tThis is a small file content".into() + ); } #[gpui::test] @@ -610,6 +723,131 @@ mod test { ); } + // The outline returned for a large file is not valid source for the file's + // language, so the UI-side markdown wrapping must omit the path tag. + // Otherwise the markdown renderer routes the fenced block through + // `CodeBlockKind::FencedSrc`, resolves the file's language, and runs + // tree-sitter against pseudo-code outline text on every paint. + #[gpui::test] + async fn test_outline_response_uses_untagged_code_block(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::>().join("\n") + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(language::rust_lang()); + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); + let (event_stream, mut rx) = ToolCallEventStream::test(); + + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/large_file.rs".into(), + start_line: None, + end_line: None, + }; + tool.clone() + .run(ToolInput::resolved(input), event_stream, cx) + }) + .await + .unwrap(); + + // Sanity-check: the file is large enough to trigger the outline branch. + assert!( + result + .to_str() + .unwrap() + .starts_with("SUCCESS: File outline retrieved."), + "expected outline response, got: {:?}", + result.to_str().unwrap() + ); + + // The first update carries the location; the second carries the + // markdown content destined for the tool-call UI. + let _location_update = rx.expect_update_fields().await; + let content_update = rx.expect_update_fields().await; + let content_blocks = content_update.content.expect("expected content update"); + let acp::ToolCallContent::Content(content) = content_blocks + .first() + .expect("expected at least one content block") + else { + panic!("expected ContentBlock, got {:?}", content_blocks.first()); + }; + let acp::ContentBlock::Text(text) = &content.content else { + panic!("expected text content block, got {:?}", content.content); + }; + + assert!( + text.text.starts_with("```\n"), + "outline response must use an untagged fenced code block; got first line: {:?}", + text.text.lines().next() + ); + assert!( + !text.text.starts_with("```root/"), + "outline response must not include the file path as a code block tag" + ); + } + + // The full-file (non-outline) response should still tag the code block + // with the file path so the markdown renderer can resolve the file's + // language for syntax highlighting. + #[gpui::test] + async fn test_full_file_response_keeps_path_tag(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "small_file.rs": "fn main() {}" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); + let (event_stream, mut rx) = ToolCallEventStream::test(); + + cx.update(|cx| { + let input = ReadFileToolInput { + path: "root/small_file.rs".into(), + start_line: None, + end_line: None, + }; + tool.clone() + .run(ToolInput::resolved(input), event_stream, cx) + }) + .await + .unwrap(); + + let _location_update = rx.expect_update_fields().await; + let content_update = rx.expect_update_fields().await; + let content_blocks = content_update.content.expect("expected content update"); + let acp::ToolCallContent::Content(content) = content_blocks + .first() + .expect("expected at least one content block") + else { + panic!("expected ContentBlock, got {:?}", content_blocks.first()); + }; + let acp::ContentBlock::Text(text) = &content.content else { + panic!("expected text content block, got {:?}", content.content); + }; + + assert!( + text.text.starts_with("```root/small_file.rs\n"), + "full-file response must tag the code block with the file path; got first line: {:?}", + text.text.lines().next() + ); + } + // When a worktree is named "foo" and contains a subdirectory also named "foo", // read_file({"path": "foo/test.txt"}) should return the file at the worktree // root (as the tool schema promises), not the one inside the foo/ subdirectory. @@ -648,7 +886,7 @@ mod test { ) }) .await; - assert_eq!(result.unwrap(), "root content".into()); + assert_eq!(result.unwrap(), " 1\troot content".into()); } #[gpui::test] @@ -681,7 +919,10 @@ mod test { ) }) .await; - assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into()); + assert_eq!( + result.unwrap(), + " 2\tLine 2\n 3\tLine 3\n 4\tLine 4\n".into() + ); } #[gpui::test] @@ -715,7 +956,7 @@ mod test { ) }) .await; - assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into()); + assert_eq!(result.unwrap(), " 1\tLine 1\n 2\tLine 2\n".into()); // end_line of 0 should result in at least 1 line let result = cx @@ -732,7 +973,7 @@ mod test { ) }) .await; - assert_eq!(result.unwrap(), "Line 1\n".into()); + assert_eq!(result.unwrap(), " 1\tLine 1\n".into()); // when start_line > end_line, should still return at least 1 line let result = cx @@ -749,7 +990,7 @@ mod test { ) }) .await; - assert_eq!(result.unwrap(), "Line 3\n".into()); + assert_eq!(result.unwrap(), " 3\tLine 3\n".into()); } fn error_text(content: LanguageModelToolResultContent) -> String { @@ -983,7 +1224,7 @@ mod test { }) .await; assert!(result.is_ok(), "Should be able to read normal files"); - assert_eq!(result.unwrap(), "Normal file content".into()); + assert_eq!(result.unwrap(), " 1\tNormal file content".into()); // Path traversal attempts with .. should fail let result = cx @@ -1153,7 +1394,7 @@ mod test { assert_eq!( result, - "fn main() { println!(\"Hello from worktree1\"); }".into() + " 1\tfn main() { println!(\"Hello from worktree1\"); }".into() ); // Test reading private file in worktree1 should fail @@ -1219,7 +1460,7 @@ mod test { assert_eq!( result, - "export function greet() { return 'Hello from worktree2'; }".into() + " 1\texport function greet() { return 'Hello from worktree2'; }".into() ); // Test reading private file in worktree2 should fail @@ -1529,7 +1770,10 @@ mod test { let LanguageModelToolResultContent::Text(text) = content else { panic!("expected text content"); }; - assert_eq!(text.as_ref(), "# Spec\n\nReference body."); + assert_eq!( + text.as_ref(), + " 1\t# Spec\n 2\t\n 3\tReference body." + ); } #[gpui::test] @@ -1576,7 +1820,7 @@ mod test { }; // Mirrors the buffer-backed path: lines 2-3 inclusive, WITH trailing // newline of the last returned line. - assert_eq!(text.as_ref(), "line two\nline three\n"); + assert_eq!(text.as_ref(), " 2\tline two\n 3\tline three\n"); } #[gpui::test] @@ -1621,7 +1865,7 @@ mod test { let LanguageModelToolResultContent::Text(text) = result.unwrap() else { panic!("expected text content"); }; - assert_eq!(text.as_ref(), "Line 1\nLine 2\n"); + assert_eq!(text.as_ref(), " 1\tLine 1\n 2\tLine 2\n"); } #[gpui::test] @@ -1666,7 +1910,7 @@ mod test { let LanguageModelToolResultContent::Text(text) = result.unwrap() else { panic!("expected text content"); }; - assert_eq!(text.as_ref(), "Line 1\n"); + assert_eq!(text.as_ref(), " 1\tLine 1\n"); } #[gpui::test] @@ -1711,7 +1955,7 @@ mod test { let LanguageModelToolResultContent::Text(text) = result.unwrap() else { panic!("expected text content"); }; - assert_eq!(text.as_ref(), "Line 3\n"); + assert_eq!(text.as_ref(), " 3\tLine 3\n"); } #[gpui::test] @@ -1756,7 +2000,7 @@ mod test { let LanguageModelToolResultContent::Text(text) = result.unwrap() else { panic!("expected text content"); }; - assert_eq!(text.as_ref(), "line one\r\nline two\r\n"); + assert_eq!(text.as_ref(), " 1\tline one\r\n 2\tline two\r\n"); } #[gpui::test] diff --git a/crates/agent/src/tools/rename_tool.rs b/crates/agent/src/tools/rename_tool.rs index ac8fa1dccb2..d05b9872b8c 100644 --- a/crates/agent/src/tools/rename_tool.rs +++ b/crates/agent/src/tools/rename_tool.rs @@ -2,6 +2,7 @@ use std::fmt::Write; use std::sync::Arc; use agent_client_protocol::schema as acp; +use collections::HashSet; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; @@ -95,6 +96,12 @@ impl AgentTool for RenameTool { )); } + let buffers = transaction.0.keys().cloned().collect::>(); + project + .update(cx, |project, cx| project.save_buffers(buffers, cx)) + .await + .map_err(|e| format!("Rename succeeded, but failed to save renamed files: {e}"))?; + let mut output = format!( "Renamed `{}` to `{}` in {} file(s):\n", input.symbol.symbol_name, diff --git a/crates/agent/src/tools/skill_tool.rs b/crates/agent/src/tools/skill_tool.rs index d45633da505..978a24f6968 100644 --- a/crates/agent/src/tools/skill_tool.rs +++ b/crates/agent/src/tools/skill_tool.rs @@ -46,11 +46,12 @@ fn neutralize_envelope_tags(input: &str) -> String { /// frontmatter), not O(total file size). pub fn render_skill_envelope(skill: &Skill, body: &str) -> String { let source = match &skill.source { + agent_skills::SkillSource::BuiltIn => "built-in", agent_skills::SkillSource::Global => "global", agent_skills::SkillSource::ProjectLocal { .. } => "project-local", }; let worktree = match &skill.source { - agent_skills::SkillSource::Global => None, + agent_skills::SkillSource::BuiltIn | agent_skills::SkillSource::Global => None, agent_skills::SkillSource::ProjectLocal { worktree_root_name, .. } => Some(worktree_root_name.clone()), @@ -200,31 +201,33 @@ impl AgentTool for SkillTool { (skill.clone(), path_string) }; - // Read the body on demand. Bodies are not kept in memory - // between materializations — see `agent_skills::read_skill_body`. - let body = agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path) - .await - .map_err(|e| SkillToolOutput::Error { - error: e.to_string(), - })?; + // For built-in skills the body is already in memory (compiled + // into the binary). For user skills, read on demand from disk. + let body = if let Some(embedded) = skill.embedded_body { + embedded.to_string() + } else { + agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path) + .await + .map_err(|e| SkillToolOutput::Error { + error: e.to_string(), + })? + }; let rendered = render_skill_envelope(&skill, &body); - // Activations go through the standard tool-permission flow so - // they participate in the same Allow-Once / Always-Allow UX as - // every other built-in tool. The auth context value is the - // skill's absolute SKILL.md path so that "always allow this - // specific skill" is keyed to a specific file: editing the - // SKILL.md will change the path's content but not the path, - // so for content-change re-trust we'd want a hash too — but - // at minimum, two skills with the same name from different - // locations get independent trust grants. - let authorize = cx.update(|cx| { - let context = crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]); - event_stream.authorize(self.initial_title(Ok(input), cx), context, cx) - }); - authorize.await.map_err(|e| SkillToolOutput::Error { - error: e.to_string(), - })?; + // Built-in skills ship with Zed and are trusted by default, + // so they skip the authorization prompt. User-installed skills + // go through the standard Allow-Once / Always-Allow UX. + let is_builtin = skill.source == agent_skills::SkillSource::BuiltIn; + if !is_builtin { + let authorize = cx.update(|cx| { + let context = + crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]); + event_stream.authorize(self.initial_title(Ok(input), cx), context, cx) + }); + authorize.await.map_err(|e| SkillToolOutput::Error { + error: e.to_string(), + })?; + } Ok(SkillToolOutput::Found { rendered }) }) diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 4f0c6b48c80..9944b57e0bd 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -14,6 +14,7 @@ use std::{ time::Duration, }; +use crate::sandboxing::sandboxing_enabled; use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput}; const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; @@ -39,7 +40,7 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`). /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`). /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly. -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user. /// @@ -49,6 +50,35 @@ pub struct TerminalToolInput { pub cd: String, /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. pub timeout_ms: Option, + /// Request network access for this command. + /// + /// Only meaningful when the system prompt's "Terminal sandbox" section + /// is present — ignored otherwise. By default sandboxed commands + /// cannot make outbound network connections; set this to `true` only + /// when the command needs network access. The user will be prompted + /// to approve before the command runs. + #[serde(default)] + pub allow_network: Option, + /// Request unrestricted filesystem-write access for this command. + /// + /// Only meaningful when the system prompt's "Terminal sandbox" section + /// is present — ignored otherwise. By default sandboxed commands can + /// only write to the project worktree directories and a per-command + /// temporary directory; set this to `true` only when the command + /// needs to write elsewhere. The user will be prompted to approve + /// before the command runs. + #[serde(default)] + pub allow_fs_write: Option, + /// Request to run this command outside the sandbox entirely. + /// + /// Only meaningful when the system prompt's "Terminal sandbox" section + /// is present — ignored otherwise. Prefer `allow_network: true` or + /// `allow_fs_write: true` when one of those is enough. Set this to + /// `true` ONLY when the command needs behavior that the sandbox can't + /// grant on a per-permission basis. The user will be prompted to + /// approve before the command runs without sandbox restrictions. + #[serde(default)] + pub unsandboxed: Option, } pub struct TerminalTool { @@ -96,24 +126,100 @@ impl AgentTool for TerminalTool { cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; - let (working_dir, authorize) = cx.update(|cx| { + let (working_dir, authorize, sandboxing) = cx.update(|cx| { let working_dir = working_dir(&input, &self.project, cx).map_err(|err| err.to_string())?; let context = crate::ToolPermissionContext::new(Self::NAME, vec![input.command.clone()]); let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), context, cx); - Result::<_, String>::Ok((working_dir, authorize)) + let sandboxing = sandboxing_enabled(cx); + Result::<_, String>::Ok((working_dir, authorize, sandboxing)) })?; authorize.await.map_err(|e| e.to_string())?; + // Sandbox flags only do anything when sandboxing is on. When + // off, we treat them as `None` so the model can't surreptitiously + // change runtime behavior by setting flags described as a no-op + // in the system prompt. + let want_network = sandboxing && input.allow_network == Some(true); + let want_fs_write = sandboxing && input.allow_fs_write == Some(true); + let want_unsandboxed = sandboxing && input.unsandboxed == Some(true); + + // `unsandboxed: true` bypasses the wrap entirely; per-permission + // requests are only meaningful when the command is still being + // sandboxed. + let escalate = !want_unsandboxed && (want_network || want_fs_write); + + if want_unsandboxed || escalate { + let title = sandbox_approval_title(want_network, want_fs_write, want_unsandboxed); + let approve = cx.update(|cx| { + let context = crate::ToolPermissionContext::new( + Self::NAME, + vec![input.command.clone()], + ); + // Sandbox escalations always prompt, even if the user + // has `always_allow` rules for this command — the + // escalation is a stronger trust boundary than the + // baseline command approval. + event_stream.authorize_always_prompt(title, context, cx) + }); + if let Err(error) = approve.await { + return Ok(if want_unsandboxed { + format!( + "Command cancelled: user denied permission to run outside the sandbox ({error})." + ) + } else { + format!( + "Command cancelled: user denied the requested sandbox permissions ({error})." + ) + }); + } + } + + // The per-thread scratch directory (and the `$TMPDIR`/`TMP`/ + // `TEMP` environment variables pointing at it) is provisioned by + // the thread environment in `create_terminal`, which also adds it + // to the sandbox's writable scope. We must not set `$TMPDIR` here: + // the environment overrides it with the per-thread directory, so a + // per-command directory set here would never be the `$TMPDIR` the + // command actually sees and would be left out of the writable + // scope, breaking writes into `$TMPDIR`. + let extra_env = Vec::new(); + + // Build the writable scope from the project's worktrees. The + // per-thread temp directory is appended by the thread environment + // (which owns it and points `$TMPDIR` at it). Crucially we do + // *not* include the resolved `cd` working directory — that's + // model-controlled, and using it as the writable scope would + // let the model widen its own write permissions outside the + // project. + let sandbox_wrap = if sandboxing && !want_unsandboxed { + let writable_paths: Vec = cx.update(|cx| { + self.project + .read(cx) + .worktrees(cx) + .map(|w| w.read(cx).abs_path().to_path_buf()) + .collect::>() + }); + Some(acp_thread::SandboxWrap { + writable_paths, + allow_network: want_network, + allow_fs_write: want_fs_write, + }) + } else { + None + }; + let terminal = self .environment .create_terminal( input.command.clone(), + extra_env, working_dir, Some(COMMAND_OUTPUT_LIMIT), + sandbox_wrap, cx, ) .await @@ -182,6 +288,29 @@ impl AgentTool for TerminalTool { } } +/// User-facing title for the sandbox-escalation approval prompt. +/// +/// `want_unsandboxed` wins over the per-permission flags because +/// `unsandboxed: true` bypasses the per-permission machinery entirely. +fn sandbox_approval_title( + want_network: bool, + want_fs_write: bool, + want_unsandboxed: bool, +) -> &'static str { + if want_unsandboxed { + "Allow this command to run outside the sandbox?" + } else { + match (want_network, want_fs_write) { + (true, true) => "Allow network access and arbitrary filesystem writes?", + (true, false) => "Allow network access?", + (false, true) => "Allow arbitrary filesystem writes?", + // Caller only invokes this when at least one flag is set, so + // this fallback is unreachable in practice. + (false, false) => "Allow this command to run?", + } + } +} + fn process_content( output: acp::TerminalOutputResponse, command: &str, @@ -310,6 +439,7 @@ mod tests { .to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }; let title = format_initial_title(Ok(input)); @@ -369,6 +499,7 @@ mod tests { command: cmd.to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }; let title = format_initial_title(Ok(input)); @@ -406,6 +537,7 @@ mod tests { command: "echo 'hello world'".to_string(), cd: ".".to_string(), timeout_ms: None, + ..Default::default() }; let title = format_initial_title(Ok(input)); @@ -435,6 +567,7 @@ mod tests { command: long_command, cd: ".".to_string(), timeout_ms: None, + ..Default::default() }; let title = format_initial_title(Ok(input)); @@ -641,6 +774,7 @@ mod tests { command: "echo $HOME".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -708,6 +842,7 @@ mod tests { command: "echo $HOME".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -769,6 +904,7 @@ mod tests { command: "echo $(rm -rf /)".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -838,6 +974,7 @@ mod tests { command: "PAGER=blah git log --oneline".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -911,6 +1048,7 @@ mod tests { command: "PAGER=blah git log".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1018,6 +1156,7 @@ mod tests { command: command.to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1185,6 +1324,7 @@ mod tests { command: "echo $(whoami)".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1257,6 +1397,7 @@ mod tests { command: "PAGER=other git log".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1323,6 +1464,7 @@ mod tests { command: "A=1 B=2 git log".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1400,6 +1542,7 @@ mod tests { command: "PAGER=\"less -R\" git log".to_string(), cd: "root".to_string(), timeout_ms: None, + ..Default::default() }), event_stream, cx, @@ -1428,4 +1571,72 @@ mod tests { "unexpected terminal result: {result}" ); } + + #[test] + fn test_sandbox_approval_title_unsandboxed_wins() { + // `unsandboxed: true` skips the sandbox entirely, so the title should + // reflect that even when other flags are also set — they're moot. + assert_eq!( + sandbox_approval_title(true, true, true), + "Allow this command to run outside the sandbox?" + ); + assert_eq!( + sandbox_approval_title(false, false, true), + "Allow this command to run outside the sandbox?" + ); + } + + #[test] + fn test_sandbox_approval_title_per_permission_flags() { + assert_eq!( + sandbox_approval_title(true, true, false), + "Allow network access and arbitrary filesystem writes?" + ); + assert_eq!( + sandbox_approval_title(true, false, false), + "Allow network access?" + ); + assert_eq!( + sandbox_approval_title(false, true, false), + "Allow arbitrary filesystem writes?" + ); + } + + #[test] + fn test_input_schema_includes_sandbox_flags() { + // The model only sees these fields when the sandboxing prompt + // section is rendered, but they're always present in the schema so + // input validation doesn't reject them when sent. Guard against + // accidentally renaming or removing them. + let schema = serde_json::to_string(&schemars::schema_for!(TerminalToolInput)) + .expect("input schema should serialize"); + assert!( + schema.contains("allow_network"), + "schema should advertise allow_network: {schema}" + ); + assert!( + schema.contains("allow_fs_write"), + "schema should advertise allow_fs_write: {schema}" + ); + assert!( + schema.contains("unsandboxed"), + "schema should advertise unsandboxed: {schema}" + ); + } + + #[test] + fn test_sandbox_flags_default_to_none_when_absent() { + // The model is expected to omit the sandbox fields entirely on most + // calls. Make sure deserialization doesn't reject the minimal + // payload and that the fields default to `None` (which the tool + // interprets as "no escalation requested"). + let input: TerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "echo hi", + "cd": ".", + })) + .expect("minimal input should deserialize"); + assert_eq!(input.allow_network, None); + assert_eq!(input.allow_fs_write, None); + assert_eq!(input.unsandboxed, None); + } } diff --git a/crates/agent/src/tools/tool_permissions.rs b/crates/agent/src/tools/tool_permissions.rs index ec343071a9b..4d52dad7001 100644 --- a/crates/agent/src/tools/tool_permissions.rs +++ b/crates/agent/src/tools/tool_permissions.rs @@ -9,9 +9,9 @@ use fs::Fs; use gpui::{App, Entity, Task, WeakEntity}; use project::{Project, ProjectPath}; use settings::Settings; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; -use util::paths::component_matches_ignore_ascii_case; +use util::{normalize_path, paths::component_matches_ignore_ascii_case}; pub enum SensitiveSettingsKind { Local, @@ -116,25 +116,24 @@ fn is_within_any_worktree(canonical_path: &Path, canonical_worktree_roots: &[Pat .any(|root| canonical_path.starts_with(root)) } -/// If `path` is an absolute path under the global skills directory -/// (`~/.agents/skills`), return the canonicalized absolute path. Returns -/// `None` for any path that resolves outside the global skills tree, for -/// relative paths, or if the skills directory itself can't be canonicalized -/// (fail closed — better to refuse access than to compare against a -/// non-canonical path). +/// If `path` names `~/.agents/skills` or one of its descendants, return the +/// canonicalized absolute path. Returns `None` for any path that resolves +/// outside the global skills tree, for relative paths that don't start with +/// `~`, or if the skills directory itself can't be canonicalized (fail closed +/// — better to refuse access than to compare against a non-canonical path). /// /// This is the gate that lets `read_file` / `list_directory` reach into the /// global skills directory — which lives outside any worktree — without /// also opening up arbitrary external paths. pub async fn resolve_global_skill_path(path: &Path, fs: &dyn Fs) -> Option { - if !path.is_absolute() { - return None; - } + let normalized_path = resolve_lexical_global_skill_path(path)?; - // Canonicalize both sides so symlinks and `..` segments can't sneak the - // path out of the skills tree (and so different but equivalent path - // representations match). - let canonical_path = fs.canonicalize(path).await.ok()?; + // Canonicalize both sides so symlinks can't sneak the path out of the + // skills tree (and so different but equivalent path representations + // match). The lexical check above intentionally runs first, so a + // symlinked `~/.agents/skills` root can't broaden the allowlist to every + // path under the symlink target. + let canonical_path = fs.canonicalize(&normalized_path).await.ok()?; let canonical_skills_dir = canonical_global_skills_dir(fs).await?; if canonical_path.starts_with(&canonical_skills_dir) { @@ -144,6 +143,112 @@ pub async fn resolve_global_skill_path(path: &Path, fs: &dyn Fs) -> Option Option { + if path.is_absolute() { + return Some(path.to_path_buf()); + } + + let mut components = path.components(); + let first_component = components.next()?; + if !matches!(first_component, Component::Normal(component) if component == "~") { + return None; + } + + let mut expanded = paths::home_dir().clone(); + for component in components { + match component { + Component::Normal(component) => expanded.push(component), + Component::CurDir => {} + Component::ParentDir => expanded.push(".."), + Component::Prefix(_) | Component::RootDir => return None, + } + } + Some(expanded) +} + +fn expand_and_normalize_absolute_path(path: &Path) -> Option { + let expanded_path = expand_home_prefix(path)?; + let normalized_path = normalize_path(&expanded_path); + normalized_path.is_absolute().then_some(normalized_path) +} + +fn resolve_lexical_global_skill_path(path: &Path) -> Option { + let normalized_path = expand_and_normalize_absolute_path(path)?; + let normalized_skills_dir = normalize_path(&agent_skills::global_skills_dir()); + + normalized_path + .starts_with(&normalized_skills_dir) + .then_some(normalized_path) +} + +/// If `path` names `~/.agents/skills` or one of its descendants, return a +/// canonical absolute path for it. Unlike [`resolve_global_skill_path`], the +/// target path may or may not exist on disk yet — the caller decides whether +/// to read, write, or create it. Returns `None` for any other path, including +/// siblings of the global skills tree or paths that would escape it with `..` +/// or symlinks. +pub async fn resolve_creatable_global_skill_path(path: &Path, fs: &dyn Fs) -> Option { + let normalized_path = resolve_lexical_global_skill_path(path)?; + let canonical_path = canonicalize_with_ancestors(&normalized_path, fs).await?; + let canonical_skills_dir = canonical_global_skills_dir(fs).await?; + + if canonical_path.starts_with(&canonical_skills_dir) { + Some(canonical_path) + } else { + None + } +} + +fn is_strict_descendant(path: &Path, ancestor: &Path) -> bool { + path != ancestor && path.starts_with(ancestor) +} + +/// Returns whether `path` resolves to the global agent skills directory itself. +/// +/// This is used by destructive tools to reject operations targeting the root +/// `~/.agents/skills` directory while still allowing operations on individual +/// skills or resources beneath it. +pub async fn resolves_to_global_skills_dir(path: &Path, fs: &dyn Fs) -> bool { + let Some(normalized_path) = resolve_lexical_global_skill_path(path) else { + return false; + }; + let Some(canonical_path) = canonicalize_with_ancestors(&normalized_path, fs).await else { + return false; + }; + let Some(canonical_skills_dir) = canonical_global_skills_dir(fs).await else { + return false; + }; + + canonical_path == canonical_skills_dir +} + +/// Filters a previously-resolved global skills path so that callers which +/// must never act on `~/.agents/skills` itself (move, delete) only see paths +/// that point strictly below the skills root. +async fn restrict_to_skill_descendant( + canonical_path: Option, + fs: &dyn Fs, +) -> Option { + let canonical_path = canonical_path?; + let canonical_skills_dir = canonical_global_skills_dir(fs).await?; + is_strict_descendant(&canonical_path, &canonical_skills_dir).then_some(canonical_path) +} + +/// Like [`resolve_global_skill_path`], but only succeeds for paths strictly +/// below `~/.agents/skills`, not the skills directory itself. +pub async fn resolve_global_skill_descendant_path(path: &Path, fs: &dyn Fs) -> Option { + restrict_to_skill_descendant(resolve_global_skill_path(path, fs).await, fs).await +} + +/// Like [`resolve_creatable_global_skill_path`], but only succeeds for paths +/// strictly below `~/.agents/skills`, not the skills directory itself. +pub async fn resolve_creatable_global_skill_descendant_path( + path: &Path, + fs: &dyn Fs, +) -> Option { + restrict_to_skill_descendant(resolve_creatable_global_skill_path(path, fs).await, fs).await +} + /// Returns the kind of sensitive settings or agent skills location this path targets, if any: /// either inside a `.zed/` local-settings directory, inside `.agents/skills/`, or inside /// the global config dir. @@ -773,6 +878,189 @@ mod tests { roots } + #[gpui::test] + async fn test_resolve_creatable_global_skill_path_allows_tilde_path(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill"); + let expected_path = agent_skills::global_skills_dir().join("my-skill"); + + let resolved = resolve_creatable_global_skill_path(&input_path, fs.as_ref()) + .await + .expect("global skill path should resolve"); + + assert_eq!(resolved, expected_path); + } + + #[gpui::test] + async fn test_resolve_global_skill_path_allows_tilde_path(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let skill_file = agent_skills::global_skills_dir() + .join("my-skill") + .join("SKILL.md"); + fs.insert_tree( + skill_file + .parent() + .expect("skill file should have a parent"), + json!({ "SKILL.md": "---\nname: my-skill\ndescription: test\n---" }), + ) + .await; + + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .join("SKILL.md"); + let resolved = resolve_global_skill_path(&input_path, fs.as_ref()) + .await + .expect("global skill file should resolve"); + + assert_eq!(resolved, skill_file); + } + + #[gpui::test] + async fn test_resolve_creatable_global_skill_path_rejects_other_home_paths( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let sibling_path = PathBuf::from("~").join(".agents").join("not-skills"); + let escaped_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("..") + .join("not-skills"); + + assert!( + resolve_creatable_global_skill_path(&sibling_path, fs.as_ref()) + .await + .is_none() + ); + assert!( + resolve_creatable_global_skill_path(&escaped_path, fs.as_ref()) + .await + .is_none() + ); + } + + #[gpui::test] + async fn test_resolve_creatable_global_skill_path_rejects_symlink_escape( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let skills_dir = agent_skills::global_skills_dir(); + fs.create_dir(&skills_dir) + .await + .expect("global skills directory should be created"); + fs.create_dir(path!("/external").as_ref()) + .await + .expect("external directory should be created"); + fs.create_symlink(&skills_dir.join("link"), PathBuf::from(path!("/external"))) + .await + .expect("symlink should be created"); + + let escaped_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("link") + .join("new-dir"); + + assert!( + resolve_creatable_global_skill_path(&escaped_path, fs.as_ref()) + .await + .is_none() + ); + } + + #[gpui::test] + async fn test_global_skill_path_resolvers_reject_absolute_paths_when_skills_dir_is_symlink_to_root( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(paths::home_dir(), json!({ ".agents": {} })) + .await; + fs.insert_tree(path!("/tmp"), json!({ "outside.txt": "outside" })) + .await; + + let skills_dir = agent_skills::global_skills_dir(); + fs.create_symlink(&skills_dir, PathBuf::from(path!("/"))) + .await + .expect("global skills directory should be symlinked to root"); + + let outside_path = PathBuf::from(path!("/tmp/outside.txt")); + assert!( + resolve_global_skill_path(&outside_path, fs.as_ref()) + .await + .is_none(), + "existing absolute paths outside the lexical global skills tree should not resolve", + ); + assert!( + resolve_creatable_global_skill_path(&outside_path, fs.as_ref()) + .await + .is_none(), + "creatable absolute paths outside the lexical global skills tree should not resolve", + ); + + let traversed_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("..") + .join("outside"); + assert!( + resolve_creatable_global_skill_path(&traversed_path, fs.as_ref()) + .await + .is_none(), + "paths that normalize outside the lexical global skills tree should not resolve", + ); + } + + #[gpui::test] + async fn test_global_skill_path_resolvers_reject_absolute_paths_when_skills_dir_is_symlink_to_home( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + paths::home_dir(), + json!({ + ".agents": {}, + "outside.txt": "outside", + }), + ) + .await; + + let skills_dir = agent_skills::global_skills_dir(); + fs.create_symlink(&skills_dir, paths::home_dir().clone()) + .await + .expect("global skills directory should be symlinked to home"); + + let outside_path = paths::home_dir().join("outside.txt"); + assert!( + resolve_global_skill_path(&outside_path, fs.as_ref()) + .await + .is_none(), + "existing absolute paths outside the lexical global skills tree should not resolve", + ); + assert!( + resolve_creatable_global_skill_path(&outside_path, fs.as_ref()) + .await + .is_none(), + "creatable absolute paths outside the lexical global skills tree should not resolve", + ); + } + #[gpui::test] async fn test_resolve_project_path_safe_for_normal_files(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/update_title_tool.rs b/crates/agent/src/tools/update_title_tool.rs new file mode 100644 index 00000000000..b86b82f9ac0 --- /dev/null +++ b/crates/agent/src/tools/update_title_tool.rs @@ -0,0 +1,140 @@ +use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput}; +use agent_client_protocol::schema as acp; +use gpui::{App, SharedString, Task, WeakEntity}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +const MAX_TITLE_LEN: usize = 200; + +/// Updates the current session title. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct UpdateTitleToolInput { + /// A concise, human-readable title for the current session. + pub title: String, +} + +pub struct UpdateTitleTool { + thread: WeakEntity, +} + +impl UpdateTitleTool { + pub fn new(thread: WeakEntity) -> Self { + Self { thread } + } + + pub(crate) fn title_for_input( + input: Result, + ) -> SharedString { + let Ok(input) = input else { + return "Update title".into(); + }; + let Ok(title) = normalize_title(&input.title) else { + return "Update title".into(); + }; + format!("Update title: {title}").into() + } +} + +impl AgentTool for UpdateTitleTool { + type Input = UpdateTitleToolInput; + type Output = String; + + const NAME: &'static str = "update_title"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Think + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + Self::title_for_input(input) + } + + fn run( + self: Arc, + input: ToolInput, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let thread = self.thread.clone(); + cx.spawn(async move |cx| { + let input = input.recv().await.map_err(|error| error.to_string())?; + let title = normalize_title(&input.title)?; + + thread + .update(cx, |thread, cx| { + thread.set_title(title.into(), cx); + }) + .map_err(|error| error.to_string())?; + + Ok("Session title updated".to_string()) + }) + } + + fn replay( + &self, + input: Self::Input, + _output: Self::Output, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> anyhow::Result<()> { + let title = self.initial_title(Ok(input), cx).to_string(); + event_stream.update_fields(acp::ToolCallUpdateFields::new().title(title)); + Ok(()) + } +} + +fn normalize_title(title: &str) -> Result { + let title = title.lines().next().unwrap_or("").trim(); + if title.is_empty() { + return Err("Title cannot be empty".to_string()); + } + Ok(util::truncate_and_trailoff(title, MAX_TITLE_LEN)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[test] + fn test_normalize_title() { + assert_eq!( + normalize_title(" Title from model\nignored").unwrap(), + "Title from model" + ); + assert!(normalize_title(" \nignored").is_err()); + } + + #[gpui::test] + async fn test_initial_title(cx: &mut TestAppContext) { + let tool = UpdateTitleTool::new(WeakEntity::new_invalid()); + + let title = cx.update(|cx| { + tool.initial_title( + Ok(UpdateTitleToolInput { + title: "Investigate title updates".to_string(), + }), + cx, + ) + }); + assert_eq!( + title, + SharedString::from("Update title: Investigate title updates") + ); + + let title = cx.update(|cx| { + tool.initial_title( + Ok(UpdateTitleToolInput { + title: " ".to_string(), + }), + cx, + ) + }); + assert_eq!(title, SharedString::from("Update title")); + } +} diff --git a/crates/agent/src/tools/write_file_tool.rs b/crates/agent/src/tools/write_file_tool.rs index d48d574397e..735a9d23a91 100644 --- a/crates/agent/src/tools/write_file_tool.rs +++ b/crates/agent/src/tools/write_file_tool.rs @@ -22,11 +22,13 @@ const DEFAULT_UI_TEXT: &str = "Writing file"; /// To make granular edits to an existing file, prefer the `edit_file` tool instead. /// /// Before using this tool, verify the directory path is correct (only applicable when creating new files). Use the `list_directory` tool to verify the parent directory exists and is the correct location +/// +/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct WriteFileToolInput { /// The full path of the file to create or overwrite in the project. /// - /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories. + /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories, unless it's a global agent skill under `~/.agents/skills`. /// /// The following examples assume we have two root directories in the project: /// - /a/b/backend @@ -41,6 +43,10 @@ pub struct WriteFileToolInput { /// /// `frontend/db.js` /// + /// + /// + /// To create or overwrite a global agent skill file, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill/SKILL.md`. + /// pub path: PathBuf, /// The entire content for the file. @@ -237,6 +243,7 @@ impl AgentTool for WriteFileTool { run_session( self.process_streaming_writes(&mut input, &event_stream, cx) .await, + &event_stream, cx, ) .await @@ -272,7 +279,7 @@ mod tests { use prompt_store::ProjectContext; use serde_json::json; use settings::{Settings, SettingsStore}; - use std::sync::Arc; + use std::{path::PathBuf, sync::Arc}; use util::path; use util::rel_path::{RelPath, rel_path}; @@ -327,6 +334,62 @@ mod tests { assert_eq!(*old_text, "old content"); } + #[gpui::test] + async fn test_streaming_write_global_skill_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({})).await; + let skill_dir = agent_skills::global_skills_dir().join("my-skill"); + fs.insert_tree(&skill_dir, json!({})).await; + let (write_tool, _project, _action_log, fs, _thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; + + let input_path = PathBuf::from("~") + .join(".agents") + .join("skills") + .join("my-skill") + .join("SKILL.md"); + let skill_file = agent_skills::global_skills_dir() + .join("my-skill") + .join("SKILL.md"); + + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: input_path, + content: "# My Skill\n".into(), + }), + event_stream, + cx, + ) + }); + + event_rx.expect_update_fields().await; + let auth = event_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("agent skills"), + "Authorization title should mention agent skills, got: {title}", + ); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) + .expect("authorization response should send"); + + let EditSessionOutput::Success { new_text, .. } = task.await.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "# My Skill\n"); + assert_eq!( + fs.load(&skill_file).await.unwrap().replace("\r\n", "\n"), + "# My Skill\n" + ); + } + #[gpui::test] async fn test_streaming_path_completeness_heuristic(cx: &mut TestAppContext) { let (write_tool, _project, _action_log, _fs, _thread) = @@ -998,7 +1061,8 @@ mod tests { cx.run_until_parked(); - let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); + let changed = + action_log.read_with(cx, |log, cx| log.changed_buffers(cx).collect::>()); assert!( !changed.is_empty(), "action_log.changed_buffers() should be non-empty after streaming write, \ @@ -1070,7 +1134,8 @@ mod tests { ); // Reject all edits — this should delete the newly created file - let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); + let changed = + action_log.read_with(cx, |log, cx| log.changed_buffers(cx).collect::>()); assert!( !changed.is_empty(), "action_log should track the created file as changed" diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 166a75ea250..f972c6f72ab 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,16 +9,19 @@ use agent_client_protocol::{ }; use anyhow::anyhow; use async_channel; -use collections::HashMap; +use collections::{HashMap, HashSet}; use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use futures::channel::mpsc; use futures::future::Shared; use futures::io::BufReader; use futures::{AsyncBufReadExt as _, Future, FutureExt as _, StreamExt as _}; -use project::agent_server_store::{AgentServerCommand, AgentServerStore}; +use project::agent_server_store::{ + AgentServerCommand, AgentServerStore, AllAgentServersSettings, CustomAgentServerSettings, +}; use project::{AgentId, Project}; use remote::remote_client::Interactive; use serde::Deserialize; +use settings::SettingsStore; use std::path::PathBuf; use std::process::{ExitStatus, Stdio}; use std::rc::Rc; @@ -32,7 +35,7 @@ use util::path_list::PathList; use util::process::Child; use anyhow::{Context as _, Result}; -use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntity}; +use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Subscription, Task, WeakEntity}; use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; use terminal::TerminalBuilder; @@ -421,18 +424,101 @@ pub struct AcpConnection { auth_methods: Vec, agent_server_store: WeakEntity, agent_capabilities: acp::AgentCapabilities, - default_mode: Option, - default_model: Option, - default_config_options: HashMap, + defaults: AcpConnectionDefaults, child: Option, session_list: Option>, debug_log: AcpDebugLog, + _settings_subscription: Subscription, _io_task: Task<()>, _dispatch_task: Task<()>, _wait_task: Task>, _stderr_task: Task>, } +#[derive(Clone, Default)] +struct AcpConnectionDefaults { + mode: Rc>>, + model: Rc>>, + config_options: Rc>>, +} + +impl AcpConnectionDefaults { + fn new( + mode: Option, + model: Option, + config_options: HashMap, + ) -> Self { + Self { + mode: Rc::new(RefCell::new(mode)), + model: Rc::new(RefCell::new(model)), + config_options: Rc::new(RefCell::new(config_options)), + } + } + + fn mode(&self) -> Option { + self.mode.borrow().clone() + } + + fn model(&self) -> Option { + self.model.borrow().clone() + } + + fn config_option(&self, config_id: &str) -> Option { + self.config_options.borrow().get(config_id).cloned() + } + + fn set( + &self, + mode: Option, + model: Option, + config_options: HashMap, + ) { + *self.mode.borrow_mut() = mode; + *self.model.borrow_mut() = model; + *self.config_options.borrow_mut() = config_options; + } + + fn refresh_from_settings(&self, agent_id: &AgentId, cx: &App) { + let Some(settings_store) = cx.try_global::() else { + self.set(None, None, HashMap::default()); + return; + }; + let settings = settings_store.get::(None); + let Some(agent_settings) = settings.get(agent_id.as_ref()) else { + self.set(None, None, HashMap::default()); + return; + }; + + let default_config_options = match agent_settings { + CustomAgentServerSettings::Custom { + default_config_options, + .. + } + | CustomAgentServerSettings::Registry { + default_config_options, + .. + } => default_config_options.clone(), + }; + self.set( + agent_settings.default_mode().map(acp::SessionModeId::new), + agent_settings.default_model().map(acp::ModelId::new), + default_config_options, + ); + } + + fn observe_settings(&self, agent_id: AgentId, cx: &mut App) -> Subscription { + if cx.try_global::().is_none() { + return Subscription::new(|| {}); + } + + self.refresh_from_settings(&agent_id, cx); + let defaults = self.clone(); + cx.observe_global::(move |cx| { + defaults.refresh_from_settings(&agent_id, cx); + }) + } +} + struct PendingAcpSession { task: Shared, Arc>>>, ref_count: usize, @@ -473,15 +559,17 @@ pub struct AcpSession { pub struct AcpSessionList { connection: ConnectionTo, + supports_delete: bool, updates_tx: async_channel::Sender, updates_rx: async_channel::Receiver, } impl AcpSessionList { - fn new(connection: ConnectionTo) -> Self { + fn new(connection: ConnectionTo, supports_delete: bool) -> Self { let (tx, rx) = async_channel::unbounded(); Self { connection, + supports_delete, updates_tx: tx, updates_rx: rx, } @@ -520,7 +608,10 @@ impl AgentSessionList for AcpSessionList { .into_iter() .map(|s| AgentSessionInfo { session_id: s.session_id, - work_dirs: Some(PathList::new(&[s.cwd])), + work_dirs: Some(work_dirs_from_session_info( + s.cwd, + s.additional_directories, + )), title: s.title.map(Into::into), updated_at: s.updated_at.and_then(|date_str| { chrono::DateTime::parse_from_rfc3339(&date_str) @@ -537,6 +628,29 @@ impl AgentSessionList for AcpSessionList { }) } + fn supports_delete(&self, cx: &App) -> bool { + self.supports_delete && cx.has_flag::() + } + + fn delete_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task> { + if !self.supports_delete(cx) { + return Task::ready(Err(anyhow::anyhow!("delete_session not supported"))); + } + + let conn = self.connection.clone(); + let updates_tx = self.updates_tx.clone(); + let session_id = session_id.clone(); + cx.foreground_executor().spawn(async move { + into_foreground_future(conn.send_request(acp::DeleteSessionRequest::new(session_id))) + .await + .map_err(map_acp_error)?; + updates_tx + .try_send(acp_thread::SessionListUpdate::Refresh) + .log_err(); + Ok(()) + }) + } + fn watch( &self, _cx: &mut App, @@ -927,6 +1041,11 @@ impl AcpConnection { .unwrap_or_else(|| agent_id.0.clone()); let agent_version = agent_info .and_then(|info| (!info.version.is_empty()).then(|| SharedString::from(info.version))); + let agent_supports_delete = response + .agent_capabilities + .session_capabilities + .delete + .is_some(); let session_list = if response .agent_capabilities @@ -934,7 +1053,10 @@ impl AcpConnection { .list .is_some() { - let list = Rc::new(AcpSessionList::new(connection.clone())); + let list = Rc::new(AcpSessionList::new( + connection.clone(), + agent_supports_delete, + )); *client_session_list.borrow_mut() = Some(list.clone()); Some(list) } else { @@ -960,6 +1082,14 @@ impl AcpConnection { } else { response.auth_methods }; + let defaults = + AcpConnectionDefaults::new(default_mode, default_model, default_config_options); + let settings_subscription = cx.update({ + let agent_id = agent_id.clone(); + let defaults = defaults.clone(); + move |cx| defaults.observe_settings(agent_id, cx) + }); + Ok(Self { id: agent_id, auth_methods, @@ -970,11 +1100,10 @@ impl AcpConnection { sessions, pending_sessions: Rc::new(RefCell::new(HashMap::default())), agent_capabilities: response.agent_capabilities, - default_mode, - default_model, - default_config_options, + defaults, session_list, debug_log, + _settings_subscription: settings_subscription, _io_task: io_task, _dispatch_task: dispatch_task, _wait_task: wait_task, @@ -995,10 +1124,14 @@ impl AcpConnection { agent_server_store: WeakEntity, io_task: Task<()>, dispatch_task: Task<()>, - _cx: &mut App, + cx: &mut App, ) -> Self { + let agent_id = AgentId::new("test"); + let defaults = AcpConnectionDefaults::default(); + let settings_subscription = defaults.observe_settings(agent_id.clone(), cx); + Self { - id: AgentId::new("test"), + id: agent_id, telemetry_id: "test".into(), agent_version: None, connection, @@ -1007,12 +1140,11 @@ impl AcpConnection { auth_methods: vec![], agent_server_store, agent_capabilities, - default_mode: None, - default_model: None, - default_config_options: HashMap::default(), + defaults, child: None, session_list: None, debug_log: AcpDebugLog::default(), + _settings_subscription: settings_subscription, _io_task: io_task, _dispatch_task: dispatch_task, _wait_task: Task::ready(Ok(())), @@ -1020,6 +1152,14 @@ impl AcpConnection { } } + fn session_directories_from_work_dirs( + &self, + work_dirs: &PathList, + ) -> Result { + let supports_additional_directories = self.supports_session_additional_directories(); + session_directories_from_work_dirs(work_dirs, supports_additional_directories) + } + fn open_or_create_session( self: Rc, session_id: acp::SessionId, @@ -1029,7 +1169,7 @@ impl AcpConnection { rpc_call: impl FnOnce( ConnectionTo, acp::SessionId, - PathBuf, + SessionDirectories, ) -> futures::future::LocalBoxFuture<'static, Result> + 'static, @@ -1056,9 +1196,9 @@ impl AcpConnection { } } - // TODO: remove this once ACP supports multiple working directories - let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { - return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + let directories = match self.session_directories_from_work_dirs(&work_dirs) { + Ok(directories) => directories, + Err(error) => return Task::ready(Err(error)), }; let shared_task = cx @@ -1100,7 +1240,9 @@ impl AcpConnection { ); let response = - match rpc_call(this.connection.clone(), session_id.clone(), cwd).await { + match rpc_call(this.connection.clone(), session_id.clone(), directories) + .await + { Ok(response) => response, Err(err) => { this.sessions.borrow_mut().remove(&session_id); @@ -1169,7 +1311,7 @@ impl AcpConnection { config_opts_ref .iter() .filter_map(|config_option| { - let default_value = self.default_config_options.get(&*config_option.id.0)?; + let default_value = self.defaults.config_option(config_option.id.0.as_ref())?; let is_valid = match &config_option.kind { acp::SessionConfigKind::Select(select) => match &select.options { @@ -1195,11 +1337,7 @@ impl AcpConnection { } _ => None, }; - Some(( - config_option.id.clone(), - default_value.clone(), - initial_value, - )) + Some((config_option.id.clone(), default_value, initial_value)) } else { log::warn!( "`{}` is not a valid value for config option `{}` in {}", @@ -1255,6 +1393,77 @@ impl AcpConnection { } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct SessionDirectories { + cwd: PathBuf, + additional_directories: Vec, +} + +impl SessionDirectories { + fn into_new_session_request(self, mcp_servers: Vec) -> acp::NewSessionRequest { + acp::NewSessionRequest::new(self.cwd) + .additional_directories(self.additional_directories) + .mcp_servers(mcp_servers) + } + + fn into_load_session_request( + self, + session_id: acp::SessionId, + mcp_servers: Vec, + ) -> acp::LoadSessionRequest { + acp::LoadSessionRequest::new(session_id, self.cwd) + .additional_directories(self.additional_directories) + .mcp_servers(mcp_servers) + } + + fn into_resume_session_request( + self, + session_id: acp::SessionId, + mcp_servers: Vec, + ) -> acp::ResumeSessionRequest { + acp::ResumeSessionRequest::new(session_id, self.cwd) + .additional_directories(self.additional_directories) + .mcp_servers(mcp_servers) + } +} + +fn session_directories_from_work_dirs( + work_dirs: &PathList, + supports_additional_directories: bool, +) -> Result { + let mut ordered_paths = work_dirs.ordered_paths(); + let cwd = ordered_paths + .next() + .cloned() + .ok_or_else(|| anyhow!("Working directory cannot be empty"))?; + let additional_directories = if supports_additional_directories { + ordered_paths.cloned().collect() + } else { + Vec::new() + }; + + Ok(SessionDirectories { + cwd, + additional_directories, + }) +} + +fn work_dirs_from_session_info(cwd: PathBuf, additional_directories: Vec) -> PathList { + let mut seen_paths = HashSet::default(); + let mut paths = Vec::with_capacity(1 + additional_directories.len()); + + seen_paths.insert(cwd.clone()); + paths.push(cwd); + + for path in additional_directories { + if seen_paths.insert(path.clone()) { + paths.push(path); + } + } + + PathList::new(&paths) +} + fn emit_load_error_to_all_sessions( sessions: &Rc>>, error: LoadError, @@ -1352,17 +1561,18 @@ impl AgentConnection for AcpConnection { work_dirs: PathList, cx: &mut App, ) -> Task>> { - // TODO: remove this once ACP supports multiple working directories - let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { - return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + let directories = match self.session_directories_from_work_dirs(&work_dirs) { + Ok(directories) => directories, + Err(error) => return Task::ready(Err(error)), }; let name = self.id.0.clone(); let mcp_servers = mcp_servers_for_project(&project, cx); cx.spawn(async move |cx| { let response = into_foreground_future( - self.connection - .send_request(acp::NewSessionRequest::new(cwd.clone()).mcp_servers(mcp_servers)), + self.connection.send_request( + directories.into_new_session_request(mcp_servers), + ), ) .await .map_err(map_acp_error)?; @@ -1370,7 +1580,8 @@ impl AgentConnection for AcpConnection { let (modes, models, config_options) = config_state(response.modes, response.models, response.config_options); - if let Some(default_mode) = self.default_mode.clone() { + let default_mode = self.defaults.mode(); + if let Some(default_mode) = default_mode { if let Some(modes) = modes.as_ref() { let mut modes_ref = modes.borrow_mut(); let has_mode = modes_ref @@ -1419,7 +1630,8 @@ impl AgentConnection for AcpConnection { } } - if let Some(default_model) = self.default_model.clone() { + let default_model = self.defaults.model(); + if let Some(default_model) = default_model { if let Some(models) = models.as_ref() { let mut models_ref = models.borrow_mut(); let has_model = models_ref @@ -1517,6 +1729,13 @@ impl AgentConnection for AcpConnection { .is_some() } + fn supports_session_additional_directories(&self) -> bool { + self.agent_capabilities + .session_capabilities + .additional_directories + .is_some() + } + fn load_session( self: Rc, session_id: acp::SessionId, @@ -1537,14 +1756,11 @@ impl AgentConnection for AcpConnection { project, work_dirs, title, - move |connection, session_id, cwd| { + move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future( - connection.send_request( - acp::LoadSessionRequest::new(session_id.clone(), cwd) - .mcp_servers(mcp_servers), - ), - ) + let response = into_foreground_future(connection.send_request( + directories.into_load_session_request(session_id.clone(), mcp_servers), + )) .await .map_err(map_acp_error)?; Ok(SessionConfigResponse { @@ -1583,14 +1799,11 @@ impl AgentConnection for AcpConnection { project, work_dirs, title, - move |connection, session_id, cwd| { + move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future( - connection.send_request( - acp::ResumeSessionRequest::new(session_id.clone(), cwd) - .mcp_servers(mcp_servers), - ), - ) + let response = into_foreground_future(connection.send_request( + directories.into_resume_session_request(session_id.clone(), mcp_servers), + )) .await .map_err(map_acp_error)?; Ok(SessionConfigResponse { @@ -1726,6 +1939,22 @@ impl AgentConnection for AcpConnection { }) } + fn supports_logout(&self) -> bool { + self.agent_capabilities.auth.logout.is_some() + } + + fn logout(&self, cx: &mut App) -> Task> { + if !self.supports_logout() { + return Task::ready(Err(anyhow!("Logout is not supported by this agent."))); + } + + let conn = self.connection.clone(); + cx.foreground_executor().spawn(async move { + into_foreground_future(conn.send_request(acp::LogoutRequest::new())).await?; + Ok(()) + }) + } + fn prompt( &self, _id: acp_thread::UserMessageId, @@ -1993,6 +2222,7 @@ pub mod test_support { pub connection: Rc, pub load_session_count: Arc, pub close_session_count: Arc, + pub logout_count: Arc, pub keep_agent_alive: Task>, } @@ -2057,6 +2287,10 @@ pub mod test_support { self.inner.supports_resume_session() } + fn supports_session_additional_directories(&self) -> bool { + self.inner.supports_session_additional_directories() + } + fn resume_session( self: Rc, session_id: acp::SessionId, @@ -2086,6 +2320,14 @@ pub mod test_support { self.inner.authenticate(method, cx) } + fn supports_logout(&self) -> bool { + self.inner.supports_logout() + } + + fn logout(&self, cx: &mut App) -> Task> { + self.inner.logout(cx) + } + fn prompt( &self, user_message_id: UserMessageId, @@ -2168,6 +2410,7 @@ pub mod test_support { ) -> Result { let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + let logout_count = Arc::new(AtomicUsize::new(0)); let sessions: Rc>> = Rc::new(RefCell::new(HashMap::default())); let client_session_list: Rc>>> = @@ -2236,6 +2479,16 @@ pub mod test_support { }, agent_client_protocol::on_receive_request!(), ) + .on_receive_request( + { + let logout_count = logout_count.clone(); + async move |_req: acp::LogoutRequest, responder, _cx| { + logout_count.fetch_add(1, Ordering::SeqCst); + responder.respond(acp::LogoutResponse::new()) + } + }, + agent_client_protocol::on_receive_request!(), + ) .on_receive_notification( async move |_notif: acp::CancelNotification, _cx| Ok(()), agent_client_protocol::on_receive_notification!(), @@ -2308,6 +2561,7 @@ pub mod test_support { connection: Rc::new(connection), load_session_count, close_session_count, + logout_count, keep_agent_alive, }) } @@ -2337,7 +2591,11 @@ pub mod test_support { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; + use feature_flags::FeatureFlag as _; + use super::*; + use gpui::UpdateGlobal as _; + use settings::Settings as _; #[test] fn terminal_auth_task_builds_spawn_from_prebuilt_command() { @@ -2484,6 +2742,476 @@ mod tests { ); } + #[test] + fn session_directories_use_ordered_paths_when_supported() { + let work_dirs = PathList::new(&[ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ]); + + let directories = + session_directories_from_work_dirs(&work_dirs, true).expect("work dirs should convert"); + + assert_eq!( + directories, + SessionDirectories { + cwd: std::path::PathBuf::from("/workspace-b"), + additional_directories: vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c") + ], + } + ); + + let session_id = acp::SessionId::new("session-1"); + let new_session_request = directories.clone().into_new_session_request(Vec::new()); + let load_session_request = directories + .clone() + .into_load_session_request(session_id.clone(), Vec::new()); + let resume_session_request = + directories.into_resume_session_request(session_id, Vec::new()); + + assert_eq!( + new_session_request.cwd, + std::path::PathBuf::from("/workspace-b") + ); + assert_eq!( + new_session_request.additional_directories, + vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c") + ] + ); + assert_eq!( + load_session_request.additional_directories, + new_session_request.additional_directories + ); + assert_eq!( + resume_session_request.additional_directories, + new_session_request.additional_directories + ); + } + + #[test] + fn session_directories_drop_additional_paths_when_unsupported() { + let work_dirs = PathList::new(&[ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + ]); + + let directories = session_directories_from_work_dirs(&work_dirs, false) + .expect("work dirs should convert"); + + assert_eq!( + directories, + SessionDirectories { + cwd: std::path::PathBuf::from("/workspace-b"), + additional_directories: Vec::new(), + } + ); + } + + #[test] + fn session_info_work_dirs_preserve_cwd_then_additional_directories() { + let work_dirs = work_dirs_from_session_info( + std::path::PathBuf::from("/workspace-b"), + vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ], + ); + + assert_eq!( + work_dirs.ordered_paths().cloned().collect::>(), + vec![ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ] + ); + } + + #[test] + fn session_info_work_dirs_deduplicate_cwd_and_additional_directories() { + let work_dirs = work_dirs_from_session_info( + std::path::PathBuf::from("/workspace-b"), + vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ], + ); + + assert_eq!( + work_dirs.ordered_paths().cloned().collect::>(), + vec![ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ] + ); + } + + #[gpui::test] + async fn session_list_includes_additional_directories_in_work_dirs( + cx: &mut gpui::TestAppContext, + ) { + let connection = connect_session_list_test_agent( + vec![ + acp::SessionInfo::new("session-1", "/workspace-b").additional_directories(vec![ + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ]), + ], + cx, + ) + .await; + let session_list = AcpSessionList::new(connection, false); + + let response = cx + .update(|cx| session_list.list_sessions(AgentSessionListRequest::default(), cx)) + .await + .expect("session list should load"); + let session = response + .sessions + .first() + .expect("session list should include the returned session"); + let work_dirs = session + .work_dirs + .as_ref() + .expect("session should include work dirs"); + + assert_eq!( + work_dirs.ordered_paths().cloned().collect::>(), + vec![ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + std::path::PathBuf::from("/workspace-c"), + ] + ); + } + + fn set_acp_beta_override(cx: &mut App, value: &str) { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + settings::SettingsStore::update_global(cx, |store, _| { + store.register_setting::(); + }); + feature_flags::FeatureFlagStore::init(cx); + + let value = value.to_string(); + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content + .feature_flags + .get_or_insert_default() + .insert(AcpBetaFeatureFlag::NAME.to_string(), value); + }); + }); + } + + async fn connect_session_list_test_agent( + sessions: Vec, + cx: &mut gpui::TestAppContext, + ) -> ConnectionTo { + let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + let sessions = Arc::new(sessions); + + cx.background_spawn( + Agent + .builder() + .name("list-test-agent") + .on_receive_request( + { + let sessions = sessions.clone(); + async move |_request: acp::ListSessionsRequest, responder, _cx| { + responder.respond(acp::ListSessionsResponse::new((*sessions).clone())) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_to(agent_transport), + ) + .detach(); + + let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); + cx.background_spawn(Client.builder().name("list-test-client").connect_with( + client_transport, + move |connection: ConnectionTo| async move { + connection_tx.send(connection).ok(); + futures::future::pending::>().await + }, + )) + .detach(); + + connection_rx + .await + .expect("failed to receive ACP connection") + } + + #[gpui::test] + async fn additional_directories_support_respects_agent_capability( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {}, "b": {} })) + .await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + let mut harness = test_support::connect_fake_acp_connection(project, cx).await; + + let work_dirs = PathList::new(&[ + std::path::PathBuf::from("/workspace-b"), + std::path::PathBuf::from("/workspace-a"), + ]); + + let missing_capability = harness + .connection + .session_directories_from_work_dirs(&work_dirs) + .expect("work dirs should convert"); + assert!(missing_capability.additional_directories.is_empty()); + + Rc::get_mut(&mut harness.connection) + .expect("test harness should own the only ACP connection handle") + .agent_capabilities + .session_capabilities + .additional_directories = Some(acp::SessionAdditionalDirectoriesCapabilities::new()); + + let supported = harness + .connection + .session_directories_from_work_dirs(&work_dirs) + .expect("work dirs should convert"); + assert_eq!( + supported, + SessionDirectories { + cwd: std::path::PathBuf::from("/workspace-b"), + additional_directories: vec![std::path::PathBuf::from("/workspace-a")], + } + ); + } + + #[gpui::test] + async fn session_delete_support_requires_beta_flag_and_capability( + cx: &mut gpui::TestAppContext, + ) { + let deleted_sessions = Arc::new(std::sync::Mutex::new(Vec::new())); + let connection = connect_session_delete_test_agent(deleted_sessions, cx).await; + let session_list = AcpSessionList::new(connection.clone(), true); + let missing_capability = AcpSessionList::new(connection, false); + + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + + assert_eq!( + session_list.supports_delete(cx), + cx.has_flag::() + ); + assert!(!missing_capability.supports_delete(cx)); + + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + assert!(session_list.supports_delete(cx)); + assert!(!missing_capability.supports_delete(cx)); + }); + } + + async fn connect_session_delete_test_agent( + deleted_sessions: Arc>>, + cx: &mut gpui::TestAppContext, + ) -> ConnectionTo { + let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + + cx.background_spawn( + Agent + .builder() + .name("delete-test-agent") + .on_receive_request( + { + let deleted_sessions = deleted_sessions.clone(); + async move |request: acp::DeleteSessionRequest, responder, _cx| { + deleted_sessions + .lock() + .expect("deleted sessions lock should not be poisoned") + .push(request.session_id); + responder.respond(acp::DeleteSessionResponse::default()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_to(agent_transport), + ) + .detach(); + + let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); + cx.background_spawn(Client.builder().name("delete-test-client").connect_with( + client_transport, + move |connection: ConnectionTo| async move { + connection_tx.send(connection).ok(); + futures::future::pending::>().await + }, + )) + .detach(); + + connection_rx + .await + .expect("failed to receive ACP connection") + } + + #[gpui::test] + async fn settings_changes_refresh_active_connection_defaults(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + let harness = test_support::connect_fake_acp_connection(project, cx).await; + + cx.update(|cx| { + AllAgentServersSettings::override_global( + AllAgentServersSettings(HashMap::from_iter([( + "test".to_string(), + settings::CustomAgentServerSettings::Custom { + path: PathBuf::from("test-agent"), + args: Vec::new(), + env: HashMap::default(), + default_mode: Some("manual".to_string()), + default_model: Some("claude-sonnet-4".to_string()), + favorite_models: Vec::new(), + default_config_options: HashMap::from_iter([( + "mode".to_string(), + "manual".to_string(), + )]), + favorite_config_option_values: HashMap::default(), + } + .into(), + )])), + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + harness.connection.defaults.mode(), + Some(acp::SessionModeId::new("manual")) + ); + assert_eq!( + harness.connection.defaults.model(), + Some(acp::ModelId::new("claude-sonnet-4")) + ); + assert_eq!( + harness.connection.defaults.config_option("mode").as_deref(), + Some("manual") + ); + + cx.update(|cx| { + AllAgentServersSettings::override_global( + AllAgentServersSettings(HashMap::default()), + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!(harness.connection.defaults.mode(), None); + assert_eq!(harness.connection.defaults.model(), None); + assert_eq!(harness.connection.defaults.config_option("mode"), None); + } + + #[gpui::test] + async fn session_list_delete_sends_session_delete_when_supported( + cx: &mut gpui::TestAppContext, + ) { + let deleted_sessions = Arc::new(std::sync::Mutex::new(Vec::new())); + let connection = connect_session_delete_test_agent(deleted_sessions.clone(), cx).await; + let session_list = AcpSessionList::new(connection, true); + let session_id = acp::SessionId::new("session-to-delete"); + + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + cx.update(|cx| session_list.delete_session(&session_id, cx)) + .await + .expect("delete_session failed"); + + assert_eq!( + *deleted_sessions + .lock() + .expect("deleted sessions lock should not be poisoned"), + vec![session_id] + ); + } + + #[gpui::test] + async fn session_list_delete_does_not_send_when_unsupported(cx: &mut gpui::TestAppContext) { + let deleted_sessions = Arc::new(std::sync::Mutex::new(Vec::new())); + let connection = connect_session_delete_test_agent(deleted_sessions.clone(), cx).await; + let session_list = AcpSessionList::new(connection, false); + let session_id = acp::SessionId::new("session-to-delete"); + + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); + }); + let error = cx + .update(|cx| session_list.delete_session(&session_id, cx)) + .await + .expect_err("delete_session should fail when unsupported"); + + assert!( + error.to_string().contains("delete_session not supported"), + "unexpected error: {error}" + ); + assert!( + deleted_sessions + .lock() + .expect("deleted sessions lock should not be poisoned") + .is_empty() + ); + } + + #[gpui::test] + async fn logout_support_requires_agent_capability(cx: &mut gpui::TestAppContext) { + cx.update(|cx| set_acp_beta_override(cx, "off")); + assert!(!cx.update(|cx| cx.has_flag::())); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "a": {} })).await; + let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; + let mut harness = test_support::connect_fake_acp_connection(project, cx).await; + + assert!(!harness.connection.supports_logout()); + let unsupported_logout = cx.update(|cx| harness.connection.logout(cx)); + let error = unsupported_logout + .await + .expect_err("logout should be rejected when the agent does not advertise support"); + assert_eq!(error.to_string(), "Logout is not supported by this agent."); + assert_eq!(harness.logout_count.load(Ordering::SeqCst), 0); + + Rc::get_mut(&mut harness.connection) + .expect("test harness should own the only ACP connection handle") + .agent_capabilities + .auth = acp::AgentAuthCapabilities::new().logout(acp::LogoutCapabilities::new()); + + assert!(harness.connection.supports_logout()); + cx.update(|cx| harness.connection.logout(cx)) + .await + .expect("logout should be sent when the agent advertises support"); + assert_eq!(harness.logout_count.load(Ordering::SeqCst), 1); + } + #[cfg(not(windows))] #[gpui::test] async fn startup_returns_error_when_agent_exits_before_initialization( @@ -3142,6 +3870,7 @@ fn mcp_servers_for_project(project: &Entity, cx: &App) -> Vec Some(acp::McpServer::Http( acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers( headers diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index b3574f6e81a..77c9595f171 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -88,22 +88,18 @@ impl AgentServer for CustomAgentServer { let config_id = config_id.to_string(); let value_id = value_id.to_string(); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); match settings { settings::CustomAgentServerSettings::Custom { favorite_config_option_values, .. } - | settings::CustomAgentServerSettings::Extension { - favorite_config_option_values, - .. - } | settings::CustomAgentServerSettings::Registry { favorite_config_option_values, .. @@ -129,16 +125,15 @@ impl AgentServer for CustomAgentServer { fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { let agent_id = self.agent_id(); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); match settings { settings::CustomAgentServerSettings::Custom { default_mode, .. } - | settings::CustomAgentServerSettings::Extension { default_mode, .. } | settings::CustomAgentServerSettings::Registry { default_mode, .. } => { *default_mode = mode_id.map(|m| m.to_string()); } @@ -161,16 +156,15 @@ impl AgentServer for CustomAgentServer { fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { let agent_id = self.agent_id(); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); match settings { settings::CustomAgentServerSettings::Custom { default_model, .. } - | settings::CustomAgentServerSettings::Extension { default_model, .. } | settings::CustomAgentServerSettings::Registry { default_model, .. } => { *default_model = model_id.map(|m| m.to_string()); } @@ -205,20 +199,17 @@ impl AgentServer for CustomAgentServer { cx: &App, ) { let agent_id = self.agent_id(); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); let favorite_models = match settings { settings::CustomAgentServerSettings::Custom { favorite_models, .. } - | settings::CustomAgentServerSettings::Extension { - favorite_models, .. - } | settings::CustomAgentServerSettings::Registry { favorite_models, .. } => favorite_models, @@ -258,22 +249,18 @@ impl AgentServer for CustomAgentServer { let agent_id = self.agent_id(); let config_id = config_id.to_string(); let value_id = value_id.map(|s| s.to_string()); - update_settings_file(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, _cx| { let settings = settings .agent_servers .get_or_insert_default() .entry(agent_id.0.to_string()) - .or_insert_with(|| default_settings_for_agent(agent_id, cx)); + .or_insert_with(default_settings_for_agent); match settings { settings::CustomAgentServerSettings::Custom { default_config_options, .. } - | settings::CustomAgentServerSettings::Extension { - default_config_options, - .. - } | settings::CustomAgentServerSettings::Registry { default_config_options, .. @@ -307,10 +294,6 @@ impl AgentServer for CustomAgentServer { default_config_options, .. } - | project::agent_server_store::CustomAgentServerSettings::Extension { - default_config_options, - .. - } | project::agent_server_store::CustomAgentServerSettings::Registry { default_config_options, .. @@ -422,28 +405,14 @@ fn is_registry_agent(agent_id: impl Into, cx: &App) -> bool { is_in_registry || is_settings_registry } -fn default_settings_for_agent( - agent_id: impl Into, - cx: &App, -) -> settings::CustomAgentServerSettings { - if is_registry_agent(agent_id, cx) { - settings::CustomAgentServerSettings::Registry { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - } - } else { - settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - } +fn default_settings_for_agent() -> settings::CustomAgentServerSettings { + settings::CustomAgentServerSettings::Registry { + default_model: None, + default_mode: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), } } @@ -547,53 +516,4 @@ mod tests { assert!(is_registry_agent("agent-from-settings", cx)); }); } - - #[gpui::test] - fn test_agent_with_extension_settings_type_is_not_registry(cx: &mut TestAppContext) { - init_test(cx); - set_agent_server_settings( - cx, - vec![( - "my-extension-agent", - settings::CustomAgentServerSettings::Extension { - env: HashMap::default(), - default_mode: None, - default_model: None, - favorite_models: Vec::new(), - default_config_options: HashMap::default(), - favorite_config_option_values: HashMap::default(), - }, - )], - ); - cx.update(|cx| { - assert!(!is_registry_agent("my-extension-agent", cx)); - }); - } - - #[gpui::test] - fn test_default_settings_for_extension_agent(cx: &mut TestAppContext) { - init_test(cx); - cx.update(|cx| { - assert!(matches!( - default_settings_for_agent("some-extension-agent", cx), - settings::CustomAgentServerSettings::Extension { .. } - )); - }); - } - - #[gpui::test] - fn test_default_settings_for_agent_in_registry(cx: &mut TestAppContext) { - init_test(cx); - init_registry_with_agents(cx, &["new-registry-agent"]); - cx.update(|cx| { - assert!(matches!( - default_settings_for_agent("new-registry-agent", cx), - settings::CustomAgentServerSettings::Registry { .. } - )); - assert!(matches!( - default_settings_for_agent("not-in-registry", cx), - settings::CustomAgentServerSettings::Extension { .. } - )); - }); - } } diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 985c0309afb..fd6f18f12af 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -21,6 +21,7 @@ futures.workspace = true gpui.workspace = true language_model.workspace = true log.workspace = true +paths.workspace = true project.workspace = true regex.workspace = true schemars.workspace = true @@ -31,7 +32,6 @@ util.workspace = true [dev-dependencies] fs.workspace = true gpui = { workspace = true, features = ["test-support"] } -paths.workspace = true serde_json_lenient.workspace = true serde_json.workspace = true diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 7453d60e48d..d7b9d0ed018 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -1,4 +1,5 @@ mod agent_profile; +mod user_agents_md; use std::path::{Component, Path}; use std::sync::{Arc, LazyLock}; @@ -20,6 +21,7 @@ use settings::{ }; pub use crate::agent_profile::*; +pub use crate::user_agents_md::{UserAgentsMd, UserAgentsMdState, init as init_user_agents_md}; pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt"); pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str = diff --git a/crates/agent/src/user_agents_md.rs b/crates/agent_settings/src/user_agents_md.rs similarity index 100% rename from crates/agent/src/user_agents_md.rs rename to crates/agent_settings/src/user_agents_md.rs diff --git a/crates/agent_skills/Cargo.toml b/crates/agent_skills/Cargo.toml index db3fb7e2947..31864f7a4f0 100644 --- a/crates/agent_skills/Cargo.toml +++ b/crates/agent_skills/Cargo.toml @@ -13,6 +13,7 @@ path = "agent_skills.rs" [dependencies] anyhow.workspace = true +base64.workspace = true const_format.workspace = true fs.workspace = true futures.workspace = true @@ -20,6 +21,7 @@ gpui.workspace = true paths.workspace = true serde.workspace = true serde_yaml_ng.workspace = true +url.workspace = true util.workspace = true [dev-dependencies] diff --git a/crates/agent_skills/agent_skills.rs b/crates/agent_skills/agent_skills.rs index 63c506bbf64..e2ffc550be3 100644 --- a/crates/agent_skills/agent_skills.rs +++ b/crates/agent_skills/agent_skills.rs @@ -1,11 +1,12 @@ use anyhow::{Context as _, Result}; -use const_format::concatcp; +use const_format::{concatcp, formatcp}; use fs::Fs; use futures::StreamExt; +use gpui::{Global, SharedString}; use serde::{Deserialize, Serialize}; -use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use url::Url; use util::paths::component_matches_ignore_ascii_case; /// First segment of the skills directory path: `.agents`. @@ -64,11 +65,19 @@ pub struct Skill { /// `skill` tool refuses to load it. The user can still invoke it as a /// slash command. pub disable_model_invocation: bool, + /// For built-in skills whose content is compiled into the binary, + /// this holds the full SKILL.md body so the skill tool can serve it + /// without a filesystem read. + pub embedded_body: Option<&'static str>, } /// Indicates where a skill was loaded from. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SkillSource { + /// Compiled into the Zed binary. These are always available and have + /// the lowest override priority (global and project-local skills can + /// shadow them). + BuiltIn, /// From ~/.agents/skills/ Global, /// From {project}/.agents/skills/ @@ -79,6 +88,23 @@ pub enum SkillSource { } impl SkillSource { + /// Precedence for resolving same-named skills. Higher values shadow + /// lower ones: `ProjectLocal` > `Global` > `BuiltIn`. Two sources + /// returning equal precedence (e.g. two project-local skills from + /// different worktrees) leave the winner up to the caller, which by + /// convention keeps the first one in iteration order. + /// + /// Adding a new `SkillSource` variant should be a one-line change + /// here — every consumer routes through this method so the hierarchy + /// stays in sync. + pub fn precedence(&self) -> u8 { + match self { + Self::BuiltIn => 0, + Self::Global => 1, + Self::ProjectLocal { .. } => 2, + } + } + /// Scope prefix used in the `/:` slash-command /// syntax that the autocomplete popup inserts. Global skills use /// an empty prefix (so the inserted text is `/:`), and @@ -91,9 +117,21 @@ impl SkillSource { /// invoked as `/:`, and the worktree's skill is invoked as /// `/global:`. The two grammars never collide on the /// inserted text. + /// Human-readable label for this source, used in the UI to + /// distinguish skills from different origins. + pub fn display_label(&self) -> &str { + match self { + Self::BuiltIn => "built-in", + Self::Global => "global", + Self::ProjectLocal { + worktree_root_name, .. + } => worktree_root_name.as_ref(), + } + } + pub fn scope_prefix(&self) -> &str { match self { - Self::Global => "", + Self::BuiltIn | Self::Global => "", Self::ProjectLocal { worktree_root_name, .. } => worktree_root_name.as_ref(), @@ -112,7 +150,7 @@ impl SkillSource { /// strictness only affects users typing by memory. pub fn matches_scope(&self, scope: &str) -> bool { match self { - Self::Global => scope.is_empty(), + Self::BuiltIn | Self::Global => scope.is_empty(), Self::ProjectLocal { worktree_root_name, .. } => !scope.is_empty() && worktree_root_name.as_ref() == scope, @@ -120,6 +158,23 @@ impl SkillSource { } } +/// App-wide index of loaded skills, published by NativeAgent and read +/// by any UI that needs to display the skill list (e.g. Settings UI). +#[derive(Default)] +pub struct SkillIndex { + pub global_skills: Vec, + pub project_skills: Vec, +} + +#[derive(Clone)] +pub struct ProjectSkillGroup { + pub worktree_id: SkillScopeId, + pub worktree_root_name: SharedString, + pub skills: Vec, +} + +impl Global for SkillIndex {} + /// Just the frontmatter, used for parsing #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillMetadata { @@ -187,17 +242,7 @@ pub fn parse_skill_frontmatter( content: &str, source: SkillSource, ) -> Result { - if content.len() > MAX_SKILL_FILE_SIZE { - anyhow::bail!( - "SKILL.md file exceeds maximum size of {}KB", - MAX_SKILL_FILE_SIZE / 1024 - ); - } - - let (metadata, _body) = extract_frontmatter(content)?; - - validate_name(&metadata.name)?; - validate_description(&metadata.description)?; + let (metadata, _body) = parse_skill_file_content(content)?; let directory_path = skill_file_path .parent() @@ -211,9 +256,33 @@ pub fn parse_skill_frontmatter( directory_path, skill_file_path: skill_file_path.to_path_buf(), disable_model_invocation: metadata.disable_model_invocation, + embedded_body: None, }) } +/// Extract the YAML frontmatter and body from a SKILL.md file without +/// validating the metadata fields. +pub fn extract_skill_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> { + if content.len() > MAX_SKILL_FILE_SIZE { + anyhow::bail!( + "SKILL.md file exceeds maximum size of {}KB", + MAX_SKILL_FILE_SIZE / 1024 + ); + } + + extract_frontmatter(content) +} + +/// Parse and validate the YAML frontmatter and body from a SKILL.md file. +pub fn parse_skill_file_content(content: &str) -> Result<(SkillMetadata, &str)> { + let (metadata, body) = extract_skill_frontmatter(content)?; + + validate_name(&metadata.name).map_err(anyhow::Error::msg)?; + validate_description(&metadata.description).map_err(anyhow::Error::msg)?; + + Ok((metadata, body)) +} + fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> { let content = content.trim_start(); @@ -290,6 +359,14 @@ fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> { /// by [`validate_name`]. pub const MAX_SKILL_NAME_LEN: usize = 64; +/// Maximum length (in bytes) for a valid skill description. Mirrors the +/// upper bound enforced by [`validate_description`]. +/// +/// Byte-based rather than char-based because that's what `.len()` returns +/// and what every caller currently measures; the UI also surfaces this +/// limit as a byte count so the editor's counter matches the validator. +pub const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; + /// Convert an arbitrary human-readable string into a valid skill name, or /// return `None` if no valid name can be produced (e.g. the input contains /// no ASCII alphanumeric characters at all). @@ -354,34 +431,54 @@ pub fn slugify_skill_name(input: &str) -> Option { if slug.is_empty() { None } else { Some(slug) } } -fn validate_name(name: &str) -> Result<()> { +/// Validate a skill name against the rules enforced by both the loader +/// and the create-skill UI. +/// +/// Rules: +/// * non-empty +/// * at most [`MAX_SKILL_NAME_LEN`] bytes +/// * ASCII lowercase letters, digits, and hyphens only +/// * must not start or end with a hyphen — [`slugify_skill_name`] +/// already guarantees this for its output, so requiring it in the +/// validator keeps hand-written `SKILL.md` files consistent with +/// slugifier output +/// +/// Error messages are returned as `&'static str` (interpolated at +/// compile time via `formatcp!`) so that UI surfaces can store them in +/// `Option<&'static str>` fields without allocating, and loader callers +/// can convert them to `anyhow::Error` via `anyhow::Error::msg`. +pub fn validate_name(name: &str) -> Result<(), &'static str> { if name.is_empty() { - anyhow::bail!("Skill name cannot be empty"); + return Err("Skill name cannot be empty"); } - if name.len() > MAX_SKILL_NAME_LEN { - anyhow::bail!("Skill name must be at most {MAX_SKILL_NAME_LEN} characters"); + return Err(formatcp!( + "Skill name must be at most {MAX_SKILL_NAME_LEN} characters" + )); + } + if name.starts_with('-') || name.ends_with('-') { + return Err("Skill name must not start or end with a hyphen"); } - if !name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { - anyhow::bail!("Skill name must contain only lowercase letters, numbers, and hyphens"); + return Err("Skill name must contain only lowercase letters, numbers, and hyphens"); } - Ok(()) } -fn validate_description(description: &str) -> Result<()> { - if description.is_empty() { - anyhow::bail!("Skill description cannot be empty"); +/// Validate a skill description against the rules enforced by both the +/// loader and the create-skill UI. +pub fn validate_description(description: &str) -> Result<(), &'static str> { + if description.trim().is_empty() { + return Err("Skill description cannot be empty"); } - - if description.len() > 1024 { - anyhow::bail!("Skill description must be at most 1024 characters"); + if description.len() > MAX_SKILL_DESCRIPTION_LEN { + return Err(formatcp!( + "Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} bytes" + )); } - Ok(()) } @@ -460,53 +557,15 @@ async fn find_skill_files(fs: &Arc, directory: &Path) -> Vec { .await } -/// Returns the byte index ONE PAST the end of the closing frontmatter -/// delimiter line in `bytes`, or `None` if no closing delimiter has been -/// seen yet. Used by the chunked reader to know when it has enough -/// bytes to stop pulling from disk. +/// Read `skill_file_path` from disk and parse its frontmatter. The +/// SKILL.md body is parsed away by `parse_skill_frontmatter` and not +/// surfaced here; it's re-read on demand via `read_skill_body` when a +/// skill is actually being loaded for the model. /// -/// Scans for the first `\n---` line followed by `\n`, `\r\n`, or EOF -/// (excluding the opening line itself, which sits at byte 0 and is -/// naturally skipped because we only consider lines following a `\n`). -/// This may overshoot in pathological cases (e.g. `---` inside a quoted -/// YAML string), but `parse_skill_frontmatter`'s candidate-and-validate -/// logic still produces a correct result or a YAML parse error. -fn closing_delimiter_end(bytes: &[u8]) -> Option { - for (i, &b) in bytes.iter().enumerate() { - if b != b'\n' { - continue; - } - let line_start = i + 1; - if line_start + 3 > bytes.len() { - continue; - } - if &bytes[line_start..line_start + 3] != b"---" { - continue; - } - let after_dashes = line_start + 3; - if after_dashes == bytes.len() { - return Some(after_dashes); - } - if bytes[after_dashes] == b'\n' { - return Some(after_dashes + 1); - } - if after_dashes + 1 < bytes.len() - && bytes[after_dashes] == b'\r' - && bytes[after_dashes + 1] == b'\n' - { - return Some(after_dashes + 2); - } - // Line is `---trailing` or `----`; keep scanning. - } - None -} - -/// Read just enough of `skill_file_path` from disk to parse its -/// frontmatter. The SKILL.md body is NOT loaded — that's deferred to -/// `read_skill_body`, called only when a skill is actually being -/// materialized for the model. Reading in 4KB chunks keeps the peak -/// memory cost of loading N skills proportional to total frontmatter -/// size, not total file size. +/// We load the whole file in one go rather than streaming up to the +/// closing `---`. `MAX_SKILL_FILE_SIZE` is 100KB and the metadata check +/// below caps the worst case at that, so the peak transient cost is +/// trivially small (≤ `MAX_SKILL_FILE_SIZE` × `SKILL_IO_CONCURRENCY`). pub async fn load_skill_frontmatter( fs: Arc, skill_file_path: PathBuf, @@ -514,10 +573,15 @@ pub async fn load_skill_frontmatter( ) -> Result { // Short-circuit on oversized files before reading any of their // contents, so a stray multi-GB file named `SKILL.md` can't OOM the - // app. We only act on a positive signal that the file is too large; - // if metadata fails or is unavailable, we fall through to the read - // loop, which is itself capped at `MAX_SKILL_FILE_SIZE`. - if let Ok(Some(metadata)) = fs.metadata(&skill_file_path).await + // app. If metadata is unavailable, refuse to read. + let metadata = fs + .metadata(&skill_file_path) + .await + .map_err(|e| SkillLoadError { + path: skill_file_path.clone(), + message: format!("Failed to read SKILL.md metadata: {}", e), + })?; + if let Some(metadata) = metadata && metadata.len > MAX_SKILL_FILE_SIZE as u64 { return Err(SkillLoadError { @@ -529,51 +593,15 @@ pub async fn load_skill_frontmatter( }); } - let mut reader = fs - .open_sync(&skill_file_path) + let content = fs + .load(&skill_file_path) .await .map_err(|e| SkillLoadError { path: skill_file_path.clone(), - message: format!("Failed to open file: {}", e), + message: format!("Failed to read file: {}", e), })?; - // The chunked read is intentionally synchronous: `Fs::open_sync` - // returns a synchronous `Read` (RealFs uses `std::fs::File`), and - // production callers already wrap `load_skills_from_directory` in - // `cx.background_spawn`, so any blocking happens on the background - // executor — not on the foreground thread. Routing through - // `smol::unblock` instead would schedule the work on smol's blocking - // pool, whose wakeups don't drive GPUI's test scheduler and therefore - // panic with "Parking forbidden" under `TestAppContext`. - let read_result: Result, io::Error> = (|| { - let mut accumulated: Vec = Vec::new(); - let mut chunk = [0u8; 4096]; - loop { - let n = reader.read(&mut chunk)?; - if n == 0 { - break; - } - accumulated.extend_from_slice(&chunk[..n]); - if closing_delimiter_end(&accumulated).is_some() { - break; - } - if accumulated.len() > MAX_SKILL_FILE_SIZE { - break; - } - } - Ok(accumulated) - })(); - let accumulated = read_result.map_err(|e| SkillLoadError { - path: skill_file_path.clone(), - message: format!("Failed to read file: {}", e), - })?; - - let content = std::str::from_utf8(&accumulated).map_err(|e| SkillLoadError { - path: skill_file_path.clone(), - message: format!("SKILL.md is not valid UTF-8: {}", e), - })?; - - parse_skill_frontmatter(&skill_file_path, content, source).map_err(|e| SkillLoadError { + parse_skill_frontmatter(&skill_file_path, &content, source).map_err(|e| SkillLoadError { path: skill_file_path.clone(), message: e.to_string(), }) @@ -600,6 +628,53 @@ pub async fn read_skill_body( Ok(body.trim().to_string()) } +/// Content of the built-in `create-skill` SKILL.md, embedded at compile time. +const CREATE_SKILL_CONTENT: &str = include_str!("builtin/create-skill/SKILL.md"); + +/// Returns the set of skills that are compiled into the Zed binary. +pub fn builtin_skills() -> Vec { + let mut skills = Vec::new(); + if let Ok(skill) = parse_builtin_skill("create-skill", CREATE_SKILL_CONTENT) { + skills.push(skill); + } + skills +} + +/// Parse a built-in skill from its embedded SKILL.md content. The skill +/// gets a synthetic `` path since it doesn't live on disk. +fn parse_builtin_skill(name: &str, content: &'static str) -> Result { + let (metadata, body) = extract_frontmatter(content)?; + validate_name(&metadata.name).map_err(anyhow::Error::msg)?; + validate_description(&metadata.description).map_err(anyhow::Error::msg)?; + + let synthetic_dir = PathBuf::from(format!("/{}", name)); + let synthetic_path = synthetic_dir.join(SKILL_FILE_NAME); + + Ok(Skill { + name: metadata.name, + description: metadata.description, + source: SkillSource::BuiltIn, + directory_path: synthetic_dir, + skill_file_path: synthetic_path, + disable_model_invocation: metadata.disable_model_invocation, + embedded_body: Some(body.trim()), + }) +} + +/// All built-in skills as `(name, raw_content)` pairs. Used by +/// `builtin_skill_content` to serve the full SKILL.md without disk I/O. +const BUILTIN_SKILL_ENTRIES: &[(&str, &str)] = &[("create-skill", CREATE_SKILL_CONTENT)]; + +/// Look up the full embedded content of a built-in skill by its +/// synthetic file path. Returns `None` if the path doesn't match any +/// built-in skill. +pub fn builtin_skill_content(skill_file_path: &Path) -> Option<&'static str> { + BUILTIN_SKILL_ENTRIES.iter().find_map(|(name, content)| { + let expected = PathBuf::from(format!("/{}", name)).join(SKILL_FILE_NAME); + (expected == skill_file_path).then_some(*content) + }) +} + /// Returns the global skills directory: `~/.agents/skills`. /// /// Other agents (e.g. Claude Code) already write skill files into this @@ -657,12 +732,92 @@ pub fn is_agents_skills_path(path: &Path) -> bool { false } +/// The `zed://` scheme used by share links. +const SKILL_SHARE_LINK_SCHEME: &str = "zed"; +/// The host (the part after `zed://`) that identifies a skill share link. +const SKILL_SHARE_LINK_HOST: &str = "skill"; +/// The query parameter that carries the embedded `SKILL.md` payload. +const SKILL_SHARE_LINK_DATA_PARAM: &str = "data"; + +/// The `zed://` deep-link prefix for a shared skill. Opening a link with this +/// prefix prompts the recipient to review and install the embedded skill. +pub const SKILL_SHARE_LINK_PREFIX: &str = + concatcp!(SKILL_SHARE_LINK_SCHEME, "://", SKILL_SHARE_LINK_HOST); + +/// Build a shareable `zed://skill?data=…` link that fully embeds the given +/// `SKILL.md` file contents. +/// +/// The contents are base64url-encoded (no padding) so the link is +/// self-contained and URL-safe: the recipient doesn't need the skill to be +/// hosted anywhere. Recover the contents with [`decode_skill_share_link`]. +pub fn encode_skill_share_link(skill_file_content: &str) -> String { + use base64::Engine as _; + let data = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(skill_file_content.as_bytes()); + let mut url = Url::parse(SKILL_SHARE_LINK_PREFIX).expect("skill share link prefix is valid"); + url.query_pairs_mut() + .append_pair(SKILL_SHARE_LINK_DATA_PARAM, &data); + url.into() +} + +/// Recover the `SKILL.md` contents embedded in a `zed://skill?data=…` link +/// produced by [`encode_skill_share_link`]. +pub fn decode_skill_share_link(link: &str) -> Result { + use base64::Engine as _; + let url = Url::parse(link).context("skill share link is not a valid URL")?; + anyhow::ensure!( + url.scheme() == SKILL_SHARE_LINK_SCHEME && url.host_str() == Some(SKILL_SHARE_LINK_HOST), + "not a skill share link" + ); + let data = url + .query_pairs() + .find_map(|(key, value)| (key == SKILL_SHARE_LINK_DATA_PARAM).then_some(value)) + .context("skill share link is missing the `data` parameter")?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(data.as_bytes()) + .context("skill share link `data` is not valid base64")?; + anyhow::ensure!( + bytes.len() <= MAX_SKILL_FILE_SIZE, + "shared skill exceeds the maximum size of {MAX_SKILL_FILE_SIZE} bytes" + ); + let content = String::from_utf8(bytes).context("skill share link `data` is not valid UTF-8")?; + Ok(content) +} + #[cfg(test)] mod tests { use super::*; use fs::FakeFs; use gpui::TestAppContext; + #[test] + fn test_skill_source_precedence_is_total_and_ordered() { + // Pin the hierarchy: project-local > global > built-in. Every + // override and conflict-resolution site routes through this, + // so the rest of the codebase relies on it being correct. + let built_in = SkillSource::BuiltIn.precedence(); + let global = SkillSource::Global.precedence(); + let project = SkillSource::ProjectLocal { + worktree_id: SkillScopeId(1), + worktree_root_name: "my-project".into(), + } + .precedence(); + + assert!(built_in < global, "global must shadow built-in"); + assert!(global < project, "project-local must shadow global"); + + // Two project-local skills from different worktrees tie. The + // "first wins" convention is enforced by the callers, but the + // precedence itself must be equal so neither silently shadows + // the other. + let other_project = SkillSource::ProjectLocal { + worktree_id: SkillScopeId(2), + worktree_root_name: "other-project".into(), + } + .precedence(); + assert_eq!(project, other_project); + } + #[test] fn test_parse_valid_skill() { let content = r#"--- @@ -690,6 +845,26 @@ Do the thing. assert!(!skill.disable_model_invocation); } + #[test] + fn test_parse_skill_file_content_returns_body() { + let content = r#"--- +name: my-skill +description: A test skill for testing purposes +--- + +# My Skill + +Do the thing. +"#; + + let (metadata, body) = parse_skill_file_content(content) + .expect("valid skill content should parse successfully"); + + assert_eq!(metadata.name, "my-skill"); + assert_eq!(metadata.description, "A test skill for testing purposes"); + assert_eq!(body.trim(), "# My Skill\n\nDo the thing."); + } + #[test] fn test_parse_disable_model_invocation_true() { let content = r#"--- @@ -873,12 +1048,8 @@ Content. SkillSource::Global, ); assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("at most 64 characters") - ); + let expected = format!("at most {MAX_SKILL_NAME_LEN} characters"); + assert!(result.unwrap_err().to_string().contains(&expected)); } #[test] @@ -1154,12 +1325,8 @@ Content. SkillSource::Global, ); assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("at most 1024 characters") - ); + let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes"); + assert!(result.unwrap_err().to_string().contains(&expected)); } #[test] @@ -1532,6 +1699,7 @@ description: A skill with no body content directory_path: PathBuf::from("/skills/test-skill"), skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let summary = SkillSummary::from(&skill); @@ -1759,4 +1927,110 @@ description: A skill with no body content "project/.AGENTS/SKILLS/foo" ))); } + + #[test] + fn validate_name_accepts_valid_names() { + assert!(validate_name("draft-pr").is_ok()); + assert!(validate_name("a").is_ok()); + assert!(validate_name("skill1").is_ok()); + assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN)).is_ok()); + } + + #[test] + fn validate_name_rejects_empty() { + assert!(validate_name("").is_err()); + } + + #[test] + fn validate_name_rejects_uppercase() { + assert!(validate_name("Draft-PR").is_err()); + } + + #[test] + fn validate_name_rejects_leading_and_trailing_hyphens() { + assert!(validate_name("-draft").is_err()); + assert!(validate_name("draft-").is_err()); + } + + #[test] + fn validate_name_rejects_invalid_chars() { + assert!(validate_name("draft_pr").is_err()); + assert!(validate_name("draft pr").is_err()); + assert!(validate_name("draft.pr").is_err()); + } + + #[test] + fn validate_name_rejects_too_long() { + assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN + 1)).is_err()); + } + + #[test] + fn validate_description_accepts_valid() { + assert!(validate_description("A useful skill").is_ok()); + } + + #[test] + fn validate_description_rejects_empty_and_whitespace_only() { + assert!(validate_description("").is_err()); + assert!(validate_description(" ").is_err()); + assert!(validate_description("\t\n ").is_err()); + } + + #[test] + fn validate_description_rejects_too_long() { + assert!(validate_description(&"a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1)).is_err()); + } + + #[test] + fn validate_description_length_is_measured_in_bytes() { + // "é" is 2 bytes in UTF-8. A string of MAX/2 + 1 "é" characters has + // only ~MAX/2 + 1 chars but exceeds MAX bytes, so it must be + // rejected by a byte-based validator (and accepted by a char-based + // one). This regression-tests the byte semantics that the loader + // and UI both rely on. + let chars = MAX_SKILL_DESCRIPTION_LEN / 2 + 1; + let description = "é".repeat(chars); + assert!(description.chars().count() <= MAX_SKILL_DESCRIPTION_LEN); + assert!(description.len() > MAX_SKILL_DESCRIPTION_LEN); + assert!(validate_description(&description).is_err()); + } + + #[test] + fn slugify_output_always_passes_validate_name() { + for input in [ + "foo", + "Foo Bar", + "rock & roll", + "---weird---", + "a".repeat(200).as_str(), + ] { + if let Some(slug) = slugify_skill_name(input) { + assert!( + validate_name(&slug).is_ok(), + "slug {slug:?} from {input:?} failed validate_name" + ); + } + } + } + + #[test] + fn skill_share_link_round_trips() { + let content = + "---\nname: my-skill\ndescription: Does a thing.\n---\n\n## Steps\n\nDo the thing.\n"; + let link = encode_skill_share_link(content); + let data = link + .strip_prefix("zed://skill?data=") + .expect("link should start with the skill share prefix"); + // base64url (no-pad) output must not require percent-encoding. + assert!(!data.contains('+') && !data.contains('/') && !data.contains('=')); + assert_eq!(decode_skill_share_link(&link).unwrap(), content); + } + + #[test] + fn decode_skill_share_link_rejects_non_skill_links() { + assert!(decode_skill_share_link("zed://settings/agent.skills").is_err()); + assert!(decode_skill_share_link("zed://skill").is_err()); + assert!(decode_skill_share_link("zed://skill?other=1").is_err()); + assert!(decode_skill_share_link("zed://skill?data=!!!notbase64").is_err()); + } } diff --git a/crates/agent_skills/builtin/create-skill/SKILL.md b/crates/agent_skills/builtin/create-skill/SKILL.md new file mode 100644 index 00000000000..e388d84f708 --- /dev/null +++ b/crates/agent_skills/builtin/create-skill/SKILL.md @@ -0,0 +1,95 @@ +--- +name: create-skill +description: Helps you create new agent skills for Zed. Use this to create a skill, ask about SKILLs.md, or package reusable agent instructions. +--- + +# Creating a Zed Agent Skill + +Use this skill when the user wants to create, edit, or understand agent skills in Zed. + +## What is a Skill? + +A skill is a reusable set of instructions that an agent can load on demand. Each skill lives in its own directory and is defined by a `SKILL.md` file with YAML frontmatter. + +## Where Skills Live + +Skills can be placed in two locations: + +| Scope | Path | When to use | +|-------|------|-------------| +| Global | `~/.agents/skills//SKILL.md` | Personal skills, available in all projects | +| Project-local | `/.agents/skills//SKILL.md` | Project-specific skills, shared with collaborators through version control | + +Prefer project-local when the skill is specific to a repository. Prefer global when the skill is a personal workflow the user wants everywhere. + +## SKILL.md Format + +Every `SKILL.md` must start with YAML frontmatter between `---` delimiters: + +```markdown +--- +name: my-skill-name +description: A clear, specific description of what this skill does and when to use it. +--- + +# Skill Title + +Instructions for the agent go here. Write them as if you're telling the agent +what to do when this skill is activated. +``` + +### Required Frontmatter Fields + +- **`name`** (required): Must be 1–64 characters, lowercase alphanumeric with single-hyphen separators. Must match the containing directory name exactly. Regex: `^[a-z0-9]+(-[a-z0-9]+)*$` +- **`description`** (required): Must be 1–1024 characters. This is what the agent sees when deciding whether to use the skill — make it specific and actionable. + +### Optional Frontmatter Fields + +- **`disable-model-invocation`**: When set to `true`, the skill is hidden from the agent's automatic catalog. The user can still invoke it manually via the `/` slash command menu. Useful for skills that should only run when explicitly requested. + +## Naming Rules + +The skill name must: +- Be lowercase letters and numbers only, with single hyphens as separators +- Not start or end with `-` +- Not contain consecutive `--` +- Match the directory name that contains the `SKILL.md` + +Good: `git-release`, `pr-review`, `rust-patterns` +Bad: `Git-Release`, `pr--review`, `-my-skill`, `my_skill` + +## Writing Good Skill Instructions + +The body of the SKILL.md (after the frontmatter) contains the instructions the agent will follow. Guidelines: + +1. **Be direct**: Write instructions as if talking to the agent. "Do X", "Check Y", "Ask the user about Z". +2. **Be specific**: Include concrete file paths, commands, formats, and patterns. +3. **Include when-to-use guidance**: Help the agent understand the right context for this skill. +4. **Reference supporting files**: Skills can include additional files in their directory. Reference them with relative paths (e.g., `templates/component.tsx`). The agent can read these files when the skill is activated. +5. **Keep descriptions actionable**: The `description` field is the agent's primary signal for whether to load this skill. "Helps with code" is too vague. "Generate React components following the project's design system patterns" is specific. + +## Supporting Files + +A skill directory can contain additional files beyond `SKILL.md`: + +``` +~/.agents/skills/react-component/ +├── SKILL.md +├── templates/ +│ ├── component.tsx +│ └── test.tsx +└── examples/ + └── button.tsx +``` + +Reference these in the skill body. The agent can read them using the file path shown in the `` tag of the skill envelope. + +## Step-by-Step: Creating a Skill + +1. Decide on scope (global vs project-local) based on the user's needs. +2. Choose a descriptive, hyphenated name. +3. Create the directory structure. The `create_directory` tool normally only creates directories inside the current project, but it has a special allow case for global skills under `~/.agents/skills`. +4. Write the `SKILL.md` with frontmatter and instructions. The `write_file` and `edit_file` tools also have a special allow case for creating or modifying files under `~/.agents/skills`. +5. Optionally add supporting files (templates, examples, references). + +After creating the skill, it will be automatically discovered by Zed's agent on the next conversation (no restart needed for global skills if the `~/.agents/skills/` directory already exists). diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index fcb23fd17f0..acabc22a95c 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -30,10 +30,10 @@ acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true agent.workspace = true -agent_skills.workspace = true async-channel.workspace = true agent_servers.workspace = true agent_settings.workspace = true +agent_skills.workspace = true ai_onboarding.workspace = true anyhow.workspace = true heapless.workspace = true @@ -69,6 +69,7 @@ language.workspace = true language_model.workspace = true language_models.workspace = true log.workspace = true +lru.workspace = true lsp.workspace = true markdown.workspace = true menu.workspace = true @@ -88,7 +89,7 @@ release_channel.workspace = true remote.workspace = true remote_connection.workspace = true rope.workspace = true -rules_library.workspace = true +skill_creator.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true @@ -145,3 +146,4 @@ tempfile.workspace = true vim.workspace = true tree-sitter-md.workspace = true unindent.workspace = true +terminal = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 67d21211026..b90a02c30e5 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -664,8 +664,14 @@ impl AgentConfiguration { None }; let auth_required = matches!(server_status, ContextServerStatus::AuthRequired); + let client_secret_required = matches!( + server_status, + ContextServerStatus::ClientSecretRequired { .. } + ); let authenticating = matches!(server_status, ContextServerStatus::Authenticating); let context_server_store = self.context_server_store.clone(); + let workspace = self.workspace.clone(); + let language_registry = self.language_registry.clone(); let tool_count = self .context_server_registry @@ -685,6 +691,9 @@ impl AgentConfiguration { ContextServerStatus::Error(_) => AiSettingItemStatus::Error, ContextServerStatus::Stopped => AiSettingItemStatus::Stopped, ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired, + ContextServerStatus::ClientSecretRequired { .. } => { + AiSettingItemStatus::ClientSecretRequired + } ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating, }; @@ -886,7 +895,7 @@ impl AgentConfiguration { ), ) .child( - Button::new("error-logout-server", "Authenticate") + Button::new("authenticate-server", "Authenticate") .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .on_click({ @@ -900,6 +909,46 @@ impl AgentConfiguration { ) .into_any_element(), ) + } else if client_secret_required { + Some( + feedback_base_container() + .child( + h_flex() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child( + Icon::new(IconName::Info) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new("Enter a client secret to connect this server") + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child( + Button::new("enter-client-secret", "Enter Client Secret") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click({ + let context_server_id = context_server_id.clone(); + move |_event, window, cx| { + ConfigureContextServerModal::show_modal_for_existing_server( + context_server_id.clone(), + language_registry.clone(), + workspace.clone(), + window, + cx, + ) + .detach(); + } + }), + ) + .into_any_element(), + ) } else if authenticating { Some( h_flex() @@ -1125,7 +1174,6 @@ impl AgentConfiguration { }; let source_kind = match source { - ExternalAgentSource::Extension => AiSettingItemSource::Extension, ExternalAgentSource::Registry => AiSettingItemSource::Registry, ExternalAgentSource::Custom => AiSettingItemSource::Custom, }; @@ -1169,26 +1217,6 @@ impl AgentConfiguration { }); let uninstall_button = match source { - ExternalAgentSource::Extension => Some( - IconButton::new( - SharedString::from(format!("uninstall-{}", id)), - IconName::Trash, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Uninstall Agent Extension")) - .on_click(cx.listener(move |this, _, _window, cx| { - let agent_name = agent_server_name.clone(); - - if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| { - store.get_extension_id_for_agent(&agent_name) - }) { - ExtensionStore::global(cx) - .update(cx, |store, cx| store.uninstall_extension(ext_id, cx)) - .detach_and_log_err(cx); - } - })), - ), ExternalAgentSource::Registry => { let fs = self.fs.clone(); Some( diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 48d01e506bf..5ccc901b4a4 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -17,7 +17,7 @@ use project::{ ContextServerStatus, ContextServerStore, ServerStatusChangedEvent, registry::ContextServerDescriptorRegistry, }, - project_settings::{ContextServerSettings, ProjectSettings}, + project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings}, worktree_store::WorktreeStore, }; use serde::Deserialize; @@ -43,7 +43,9 @@ enum ConfigurationTarget { id: ContextServerId, url: String, headers: HashMap, + oauth: Option, }, + Extension { id: ContextServerId, repository_url: Option, @@ -121,15 +123,17 @@ impl ConfigurationSource { id, url, headers: auth, + oauth, } => ConfigurationSource::Existing { editor: create_editor( - context_server_http_input(Some((id, url, auth))), + context_server_http_input(Some((id, url, auth, oauth))), jsonc_language, window, cx, ), is_http: true, }, + ConfigurationTarget::Extension { id, repository_url, @@ -168,7 +172,7 @@ impl ConfigurationSource { ConfigurationSource::New { editor, is_http } | ConfigurationSource::Existing { editor, is_http } => { if *is_http { - parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth)| { + parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| { ( id, ContextServerSettings::Http { @@ -176,6 +180,7 @@ impl ConfigurationSource { url, headers: auth, timeout: None, + oauth, }, ) }) @@ -256,11 +261,16 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) } fn context_server_http_input( - existing: Option<(ContextServerId, String, HashMap)>, + existing: Option<( + ContextServerId, + String, + HashMap, + Option, + )>, ) -> String { - let (name, url, headers) = match existing { - Some((id, url, headers)) => { - let header = if headers.is_empty() { + let (name, url, headers, oauth) = match existing { + Some((id, url, headers, oauth)) => { + let headers = if headers.is_empty() { r#"// "Authorization": "Bearer "#.to_string() } else { let json = serde_json::to_string_pretty(&headers).unwrap(); @@ -274,15 +284,48 @@ fn context_server_http_input( .map(|line| format!(" {}", line)) .collect::() }; - (id.0.to_string(), url, header) + (id.0.to_string(), url, headers, oauth) } None => ( "some-remote-server".to_string(), "https://example.com/mcp".to_string(), r#"// "Authorization": "Bearer "#.to_string(), + None, ), }; + let oauth = oauth.map_or_else( + || { + r#" + /// Uncomment to use a pre-registered OAuth client. You can include the client secret here as well, otherwise it will be prompted interactively and saved in the system keychain. + // "oauth": { + // "client_id": "your-client-id", + // },"# + .to_string() + }, + + |oauth| { + let mut lines = vec![ + String::from("\n \"oauth\": {"), + + format!(" \"client_id\": {},", serde_json::to_string(&oauth.client_id).unwrap()), + ]; + if let Some(client_secret) = oauth.client_secret { + lines.push(format!( + " \"client_secret\": {}", + serde_json::to_string(&client_secret).unwrap() + )); + } else { + lines.push(String::from( + " /// Optional client secret for confidential clients\n // \"client_secret\": \"your-client-secret\"", + )); + } + lines.push(String::from(" },")); + + lines.join("\n") + }, + ); + format!( r#"{{ /// Configure an MCP server that you connect to over HTTP @@ -290,7 +333,7 @@ fn context_server_http_input( /// The name of your remote MCP server "{name}": {{ /// The URL of the remote MCP server - "url": "{url}", + "url": "{url}",{oauth} "headers": {{ /// Any headers to send along {headers} @@ -300,12 +343,21 @@ fn context_server_http_input( ) } -fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap)> { +fn parse_http_input( + text: &str, +) -> Result<( + ContextServerId, + String, + HashMap, + Option, +)> { #[derive(Deserialize)] struct Temp { url: String, #[serde(default)] headers: HashMap, + #[serde(default)] + oauth: Option, } let value: HashMap = serde_json_lenient::from_str(text)?; if value.len() != 1 { @@ -314,7 +366,12 @@ fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap, + }, + Authenticating { + server_id: ContextServerId, + }, Error(SharedString), } @@ -361,10 +426,47 @@ pub struct ConfigureContextServerModal { state: State, original_server_id: Option, scroll_handle: ScrollHandle, + secret_editor: Entity, _auth_subscription: Option, } impl ConfigureContextServerModal { + fn initial_state( + context_server_store: &Entity, + target: &ConfigurationTarget, + cx: &App, + ) -> State { + let Some(server_id) = (match target { + ConfigurationTarget::Existing { id, .. } + | ConfigurationTarget::ExistingHttp { id, .. } + | ConfigurationTarget::Extension { id, .. } => Some(id), + ConfigurationTarget::New => None, + }) else { + return State::Idle; + }; + + match context_server_store.read(cx).status_for_server(server_id) { + Some(ContextServerStatus::AuthRequired) => State::AuthRequired { + server_id: server_id.clone(), + }, + Some(ContextServerStatus::ClientSecretRequired { error }) => { + State::ClientSecretRequired { + server_id: server_id.clone(), + error: error.map(SharedString::from), + } + } + Some(ContextServerStatus::Authenticating) => State::Authenticating { + server_id: server_id.clone(), + }, + Some(ContextServerStatus::Error(error)) => State::Error(error.into()), + + Some(ContextServerStatus::Starting) + | Some(ContextServerStatus::Running) + | Some(ContextServerStatus::Stopped) + | None => State::Idle, + } + } + pub fn register( workspace: &mut Workspace, language_registry: Arc, @@ -426,12 +528,14 @@ impl ConfigureContextServerModal { url, headers, timeout: _, - .. + oauth, } => Some(ConfigurationTarget::ExistingHttp { id: server_id, url, headers, + oauth, }), + ContextServerSettings::Extension { .. } => { match workspace .update(cx, |workspace, cx| { @@ -468,9 +572,10 @@ impl ConfigureContextServerModal { let workspace_handle = cx.weak_entity(); let context_server_store = workspace.project().read(cx).context_server_store(); workspace.toggle_modal(window, cx, |window, cx| Self { - context_server_store, + context_server_store: context_server_store.clone(), workspace: workspace_handle, - state: State::Idle, + state: Self::initial_state(&context_server_store, &target, cx), + original_server_id: match &target { ConfigurationTarget::Existing { id, .. } => Some(id.clone()), ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()), @@ -485,6 +590,16 @@ impl ConfigureContextServerModal { cx, ), scroll_handle: ScrollHandle::new(), + secret_editor: cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text( + "Enter client secret (leave empty for public clients)", + window, + cx, + ); + editor.set_masked(true, cx); + editor + }), _auth_subscription: None, }) }) @@ -497,13 +612,12 @@ impl ConfigureContextServerModal { } fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context) { - if matches!( - self.state, - State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } - ) { + if matches!(self.state, State::Waiting | State::Authenticating { .. }) { return; } + self._auth_subscription = None; + self.state = State::Idle; let Some(workspace) = self.workspace.upgrade() else { return; @@ -519,7 +633,7 @@ impl ConfigureContextServerModal { self.state = State::Waiting; - let existing_server = self.context_server_store.read(cx).get_running_server(&id); + let existing_server = self.context_server_store.read(cx).get_server(&id); if existing_server.is_some() { self.context_server_store.update(cx, |store, cx| { store.stop_server(&id, cx).log_err(); @@ -542,6 +656,13 @@ impl ConfigureContextServerModal { this.state = State::AuthRequired { server_id: id }; cx.notify(); } + Ok(ContextServerStatus::ClientSecretRequired { error }) => { + this.state = State::ClientSecretRequired { + server_id: id, + error: error.map(SharedString::from), + }; + cx.notify(); + } Err(err) => { this.set_error(err, cx); } @@ -581,13 +702,33 @@ impl ConfigureContextServerModal { cx.emit(DismissEvent); } + fn cancel_authentication(&mut self, server_id: &ContextServerId, cx: &mut Context) { + self._auth_subscription = None; + self.context_server_store.update(cx, |store, cx| { + store.stop_server(server_id, cx).log_err(); + }); + self.state = State::Idle; + cx.notify(); + } + fn authenticate(&mut self, server_id: ContextServerId, cx: &mut Context) { self.context_server_store.update(cx, |store, cx| { store.authenticate_server(&server_id, cx).log_err(); }); + self.await_auth_outcome(server_id, cx); + } + fn submit_client_secret(&mut self, server_id: ContextServerId, cx: &mut Context) { + let secret = self.secret_editor.read(cx).text(cx); + self.context_server_store.update(cx, |store, cx| { + store.submit_client_secret(&server_id, secret, cx).log_err(); + }); + self.await_auth_outcome(server_id, cx); + } + + fn await_auth_outcome(&mut self, server_id: ContextServerId, cx: &mut Context) { self.state = State::Authenticating { - _server_id: server_id.clone(), + server_id: server_id.clone(), }; self._auth_subscription = Some(cx.subscribe( @@ -610,6 +751,14 @@ impl ConfigureContextServerModal { }; cx.notify(); } + ContextServerStatus::ClientSecretRequired { error } => { + this._auth_subscription = None; + this.state = State::ClientSecretRequired { + server_id: event.server_id.clone(), + error: error.clone().map(SharedString::from), + }; + cx.notify(); + } ContextServerStatus::Error(error) => { this._auth_subscription = None; this.set_error(error.clone(), cx); @@ -814,10 +963,7 @@ impl ConfigureContextServerModal { fn render_modal_footer(&self, cx: &mut Context) -> ModalFooter { let focus_handle = self.focus_handle(cx); - let is_busy = matches!( - self.state, - State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } - ); + let is_busy = matches!(self.state, State::Waiting | State::Authenticating { .. }); ModalFooter::new() .start_slot::, _keymap: &Keymap) {} - - fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} - - fn on_app_menu_action(&self, callback: Box) { - self.callbacks.borrow_mut().app_menu_action = Some(callback); - } - - fn on_will_open_app_menu(&self, callback: Box) { - self.callbacks.borrow_mut().will_open_app_menu = Some(callback); - } - - fn on_validate_app_menu_command(&self, callback: Box bool>) { - self.callbacks.borrow_mut().validate_app_menu_command = Some(callback); - } - - fn thermal_state(&self) -> ThermalState { - ThermalState::Nominal - } - - fn on_thermal_state_change(&self, callback: Box) { - self.callbacks.borrow_mut().thermal_state_change = Some(callback); - } - - fn compositor_name(&self) -> &'static str { - "Web" - } - - fn app_path(&self) -> Result { - Err(anyhow::anyhow!("app_path is not available on the web")) - } - - fn path_for_auxiliary_executable(&self, _name: &str) -> Result { - Err(anyhow::anyhow!( - "path_for_auxiliary_executable is not available on the web" - )) - } - - fn set_cursor_style(&self, style: CursorStyle) { - let css_cursor = match style { - CursorStyle::Arrow => "default", - CursorStyle::IBeam => "text", - CursorStyle::Crosshair => "crosshair", - CursorStyle::ClosedHand => "grabbing", - CursorStyle::OpenHand => "grab", - CursorStyle::PointingHand => "pointer", - CursorStyle::ResizeLeft | CursorStyle::ResizeRight | CursorStyle::ResizeLeftRight => { - "ew-resize" - } - CursorStyle::ResizeUp | CursorStyle::ResizeDown | CursorStyle::ResizeUpDown => { - "ns-resize" - } - CursorStyle::ResizeUpLeftDownRight => "nesw-resize", - CursorStyle::ResizeUpRightDownLeft => "nwse-resize", - CursorStyle::ResizeColumn => "col-resize", - CursorStyle::ResizeRow => "row-resize", - CursorStyle::IBeamCursorForVerticalLayout => "vertical-text", - CursorStyle::OperationNotAllowed => "not-allowed", - CursorStyle::DragLink => "alias", - CursorStyle::DragCopy => "copy", - CursorStyle::ContextualMenu => "context-menu", - }; - - self.last_cursor_css.set(css_cursor); - if self.cursor_visible.get() { - set_body_cursor(&self.browser_window, css_cursor); - } - } - - fn hide_cursor_until_mouse_moves(&self) { - if !self.cursor_visible.replace(false) { - return; - } - set_body_cursor(&self.browser_window, "none"); - } - - fn is_cursor_visible(&self) -> bool { - self.cursor_visible.get() - } - - fn should_auto_hide_scrollbars(&self) -> bool { - true - } - - fn read_from_clipboard(&self) -> Option { - None - } - - fn write_to_clipboard(&self, _item: ClipboardItem) {} - - fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { - Task::ready(Err(anyhow::anyhow!( - "credential storage is not available on the web" - ))) - } - - fn read_credentials(&self, _url: &str) -> Task)>>> { - Task::ready(Ok(None)) - } - - fn delete_credentials(&self, _url: &str) -> Task> { - Task::ready(Err(anyhow::anyhow!( - "credential storage is not available on the web" - ))) - } - - fn keyboard_layout(&self) -> Box { - Box::new(WebKeyboardLayout) - } - - fn keyboard_mapper(&self) -> Rc { - Rc::new(DummyKeyboardMapper) - } - - fn on_keyboard_layout_change(&self, callback: Box) { - self.callbacks.borrow_mut().keyboard_layout_change = Some(callback); - } -} - -struct EventListenerHandle { - target: web_sys::EventTarget, - event_name: &'static str, - closure: Closure, -} - -impl Drop for EventListenerHandle { - fn drop(&mut self) { - self.target - .remove_event_listener_with_callback( - self.event_name, - self.closure.as_ref().unchecked_ref(), - ) - .ok(); - } -} - -fn cursor_restore_listeners( - browser_window: &web_sys::Window, - cursor_visible: Rc>, - last_cursor_css: Rc>, -) -> Vec { - let mut handles = Vec::new(); - let Some(document) = browser_window.document() else { - return handles; - }; - - let make_restore_handler = |browser_window: web_sys::Window| { - let cursor_visible = cursor_visible.clone(); - let last_cursor_css = last_cursor_css.clone(); - Closure::::new(move |_event: JsValue| { - if !cursor_visible.replace(true) { - set_body_cursor(&browser_window, last_cursor_css.get()); - } - }) - }; - - let mut add_listener = |target: &web_sys::EventTarget, event_name: &'static str| { - let closure = make_restore_handler(browser_window.clone()); - target - .add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref()) - .ok(); - handles.push(EventListenerHandle { - target: target.clone(), - event_name, - closure, - }); - }; - - let document_target: &web_sys::EventTarget = document.as_ref(); - let window_target: &web_sys::EventTarget = browser_window.as_ref(); - - add_listener(document_target, "mousemove"); - add_listener(document_target, "mouseenter"); - add_listener(window_target, "blur"); - add_listener(document_target, "visibilitychange"); - - handles -} - -fn set_body_cursor(browser_window: &web_sys::Window, css_cursor: &str) { - if let Some(document) = browser_window.document() - && let Some(body) = document.body() - && let Err(error) = body.style().set_property("cursor", css_cursor) - { - log::warn!("Failed to set cursor style: {error:?}"); - } -} +use crate::dispatcher::WebDispatcher; +use crate::display::WebDisplay; +use crate::keyboard::WebKeyboardLayout; +use crate::window::WebWindow; +use anyhow::Result; +use futures::channel::oneshot; +use gpui::{ + Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DummyKeyboardMapper, + ForegroundExecutor, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, + PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, + ThermalState, WindowAppearance, WindowParams, +}; +use gpui_wgpu::WgpuContext; +use std::{ + borrow::Cow, + cell::{Cell, RefCell}, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; +use wasm_bindgen::prelude::*; + +static BUNDLED_FONTS: &[&[u8]] = &[ + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"), + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf"), + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBold.ttf"), + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBoldItalic.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Italic.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-BoldItalic.ttf"), +]; + +pub struct WebPlatform { + browser_window: web_sys::Window, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + active_window: RefCell>, + active_display: Rc, + callbacks: RefCell, + wgpu_context: Rc>>, + cursor_visible: Rc>, + last_cursor_css: Rc>, + _cursor_restore_listeners: Vec, +} + +#[derive(Default)] +struct WebPlatformCallbacks { + open_urls: Option)>>, + quit: Option>, + reopen: Option>, + app_menu_action: Option>, + will_open_app_menu: Option>, + validate_app_menu_command: Option bool>>, + keyboard_layout_change: Option>, + thermal_state_change: Option>, +} + +impl WebPlatform { + pub fn new(allow_multi_threading: bool) -> Self { + let browser_window = + web_sys::window().expect("must be running in a browser window context"); + let dispatcher = Arc::new(WebDispatcher::new( + browser_window.clone(), + allow_multi_threading, + )); + let background_executor = BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(dispatcher); + let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new_without_system_fonts( + "IBM Plex Sans", + )); + let fonts = BUNDLED_FONTS + .iter() + .map(|bytes| Cow::Borrowed(*bytes)) + .collect(); + if let Err(error) = text_system.add_fonts(fonts) { + log::error!("failed to load bundled fonts: {error:#}"); + } + let text_system: Arc = text_system; + let active_display: Rc = + Rc::new(WebDisplay::new(browser_window.clone())); + + let cursor_visible = Rc::new(Cell::new(true)); + let last_cursor_css = Rc::new(Cell::new("default")); + let cursor_restore_listeners = cursor_restore_listeners( + &browser_window, + cursor_visible.clone(), + last_cursor_css.clone(), + ); + + Self { + browser_window, + background_executor, + foreground_executor, + text_system, + active_window: RefCell::new(None), + active_display, + callbacks: RefCell::new(WebPlatformCallbacks::default()), + wgpu_context: Rc::new(RefCell::new(None)), + cursor_visible, + last_cursor_css, + _cursor_restore_listeners: cursor_restore_listeners, + } + } +} + +impl Platform for WebPlatform { + fn background_executor(&self) -> BackgroundExecutor { + self.background_executor.clone() + } + + fn foreground_executor(&self) -> ForegroundExecutor { + self.foreground_executor.clone() + } + + fn text_system(&self) -> Arc { + self.text_system.clone() + } + + fn run(&self, on_finish_launching: Box) { + let wgpu_context = self.wgpu_context.clone(); + wasm_bindgen_futures::spawn_local(async move { + match WgpuContext::new_web().await { + Ok(context) => { + log::info!("WebGPU context initialized successfully"); + *wgpu_context.borrow_mut() = Some(context); + on_finish_launching(); + } + Err(err) => { + log::error!("Failed to initialize WebGPU context: {err:#}"); + on_finish_launching(); + } + } + }); + } + + fn quit(&self) { + log::warn!("WebPlatform::quit called, but quitting is not supported in the browser ."); + } + + fn restart(&self, _binary_path: Option) {} + + fn activate(&self, _ignoring_other_apps: bool) {} + + fn hide(&self) {} + + fn hide_other_apps(&self) {} + + fn unhide_other_apps(&self) {} + + fn displays(&self) -> Vec> { + vec![self.active_display.clone()] + } + + fn primary_display(&self) -> Option> { + Some(self.active_display.clone()) + } + + fn active_window(&self) -> Option { + *self.active_window.borrow() + } + + fn open_window( + &self, + handle: AnyWindowHandle, + params: WindowParams, + ) -> anyhow::Result> { + let context_ref = self.wgpu_context.borrow(); + let context = context_ref.as_ref().ok_or_else(|| { + anyhow::anyhow!("WebGPU context not initialized. Was Platform::run() called?") + })?; + + let window = WebWindow::new(handle, params, context, self.browser_window.clone())?; + *self.active_window.borrow_mut() = Some(handle); + Ok(Box::new(window)) + } + + fn window_appearance(&self) -> WindowAppearance { + let Ok(Some(media_query)) = self + .browser_window + .match_media("(prefers-color-scheme: dark)") + else { + return WindowAppearance::Light; + }; + if media_query.matches() { + WindowAppearance::Dark + } else { + WindowAppearance::Light + } + } + + fn open_url(&self, url: &str) { + if let Err(error) = self.browser_window.open_with_url(url) { + log::warn!("Failed to open URL '{url}': {error:?}"); + } + } + + fn on_open_urls(&self, callback: Box)>) { + self.callbacks.borrow_mut().open_urls = Some(callback); + } + + fn register_url_scheme(&self, _url: &str) -> Task> { + Task::ready(Ok(())) + } + + fn prompt_for_paths( + &self, + _options: PathPromptOptions, + ) -> oneshot::Receiver>>> { + let (tx, rx) = oneshot::channel(); + tx.send(Err(anyhow::anyhow!( + "prompt_for_paths is not supported on the web" + ))) + .ok(); + rx + } + + fn prompt_for_new_path( + &self, + _directory: &Path, + _suggested_name: Option<&str>, + ) -> oneshot::Receiver>> { + let (sender, receiver) = oneshot::channel(); + sender + .send(Err(anyhow::anyhow!( + "prompt_for_new_path is not supported on the web" + ))) + .ok(); + receiver + } + + fn can_select_mixed_files_and_dirs(&self) -> bool { + false + } + + fn reveal_path(&self, _path: &Path) {} + + fn open_with_system(&self, _path: &Path) {} + + fn on_quit(&self, callback: Box) { + self.callbacks.borrow_mut().quit = Some(callback); + } + + fn on_reopen(&self, callback: Box) { + self.callbacks.borrow_mut().reopen = Some(callback); + } + + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} + + fn on_app_menu_action(&self, callback: Box) { + self.callbacks.borrow_mut().app_menu_action = Some(callback); + } + + fn on_will_open_app_menu(&self, callback: Box) { + self.callbacks.borrow_mut().will_open_app_menu = Some(callback); + } + + fn on_validate_app_menu_command(&self, callback: Box bool>) { + self.callbacks.borrow_mut().validate_app_menu_command = Some(callback); + } + + fn thermal_state(&self) -> ThermalState { + ThermalState::Nominal + } + + fn on_thermal_state_change(&self, callback: Box) { + self.callbacks.borrow_mut().thermal_state_change = Some(callback); + } + + fn compositor_name(&self) -> &'static str { + "Web" + } + + fn app_path(&self) -> Result { + Err(anyhow::anyhow!("app_path is not available on the web")) + } + + fn path_for_auxiliary_executable(&self, _name: &str) -> Result { + Err(anyhow::anyhow!( + "path_for_auxiliary_executable is not available on the web" + )) + } + + fn set_cursor_style(&self, style: CursorStyle) { + let css_cursor = match style { + CursorStyle::Arrow => "default", + CursorStyle::IBeam => "text", + CursorStyle::Crosshair => "crosshair", + CursorStyle::ClosedHand => "grabbing", + CursorStyle::OpenHand => "grab", + CursorStyle::PointingHand => "pointer", + CursorStyle::ResizeLeft | CursorStyle::ResizeRight | CursorStyle::ResizeLeftRight => { + "ew-resize" + } + CursorStyle::ResizeUp | CursorStyle::ResizeDown | CursorStyle::ResizeUpDown => { + "ns-resize" + } + CursorStyle::ResizeUpLeftDownRight => "nesw-resize", + CursorStyle::ResizeUpRightDownLeft => "nwse-resize", + CursorStyle::ResizeColumn => "col-resize", + CursorStyle::ResizeRow => "row-resize", + CursorStyle::IBeamCursorForVerticalLayout => "vertical-text", + CursorStyle::OperationNotAllowed => "not-allowed", + CursorStyle::DragLink => "alias", + CursorStyle::DragCopy => "copy", + CursorStyle::ContextualMenu => "context-menu", + }; + + self.last_cursor_css.set(css_cursor); + if self.cursor_visible.get() { + set_body_cursor(&self.browser_window, css_cursor); + } + } + + fn hide_cursor_until_mouse_moves(&self) { + if !self.cursor_visible.replace(false) { + return; + } + set_body_cursor(&self.browser_window, "none"); + } + + fn is_cursor_visible(&self) -> bool { + self.cursor_visible.get() + } + + fn should_auto_hide_scrollbars(&self) -> bool { + true + } + + fn read_from_clipboard(&self) -> Option { + None + } + + fn write_to_clipboard(&self, _item: ClipboardItem) {} + + fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "credential storage is not available on the web" + ))) + } + + fn read_credentials(&self, _url: &str) -> Task)>>> { + Task::ready(Ok(None)) + } + + fn delete_credentials(&self, _url: &str) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "credential storage is not available on the web" + ))) + } + + fn keyboard_layout(&self) -> Box { + Box::new(WebKeyboardLayout) + } + + fn keyboard_mapper(&self) -> Rc { + Rc::new(DummyKeyboardMapper) + } + + fn on_keyboard_layout_change(&self, callback: Box) { + self.callbacks.borrow_mut().keyboard_layout_change = Some(callback); + } +} + +struct EventListenerHandle { + target: web_sys::EventTarget, + event_name: &'static str, + closure: Closure, +} + +impl Drop for EventListenerHandle { + fn drop(&mut self) { + self.target + .remove_event_listener_with_callback( + self.event_name, + self.closure.as_ref().unchecked_ref(), + ) + .ok(); + } +} + +fn cursor_restore_listeners( + browser_window: &web_sys::Window, + cursor_visible: Rc>, + last_cursor_css: Rc>, +) -> Vec { + let mut handles = Vec::new(); + let Some(document) = browser_window.document() else { + return handles; + }; + + let make_restore_handler = |browser_window: web_sys::Window| { + let cursor_visible = cursor_visible.clone(); + let last_cursor_css = last_cursor_css.clone(); + Closure::::new(move |_event: JsValue| { + if !cursor_visible.replace(true) { + set_body_cursor(&browser_window, last_cursor_css.get()); + } + }) + }; + + let mut add_listener = |target: &web_sys::EventTarget, event_name: &'static str| { + let closure = make_restore_handler(browser_window.clone()); + target + .add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref()) + .ok(); + handles.push(EventListenerHandle { + target: target.clone(), + event_name, + closure, + }); + }; + + let document_target: &web_sys::EventTarget = document.as_ref(); + let window_target: &web_sys::EventTarget = browser_window.as_ref(); + + add_listener(document_target, "mousemove"); + add_listener(document_target, "mouseenter"); + add_listener(window_target, "blur"); + add_listener(document_target, "visibilitychange"); + + handles +} + +fn set_body_cursor(browser_window: &web_sys::Window, css_cursor: &str) { + if let Some(document) = browser_window.document() + && let Some(body) = document.body() + && let Err(error) = body.style().set_property("cursor", css_cursor) + { + log::warn!("Failed to set cursor style: {error:?}"); + } +} diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index 125432c0ae8..b9399d32e63 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -1,731 +1,731 @@ -use crate::display::WebDisplay; -use crate::events::{ClickState, WebEventListeners, is_mac_platform}; -use std::sync::Arc; -use std::{cell::Cell, cell::RefCell, rc::Rc}; - -use gpui::{ - AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult, GpuSpecs, - Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, - PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - ResizeEdge, Scene, Size, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowControls, WindowDecorations, WindowParams, px, -}; -use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; -use wasm_bindgen::prelude::*; - -#[derive(Default)] -pub(crate) struct WebWindowCallbacks { - pub(crate) request_frame: Option>, - pub(crate) input: Option DispatchEventResult>>, - pub(crate) active_status_change: Option>, - pub(crate) hover_status_change: Option>, - pub(crate) resize: Option, f32)>>, - pub(crate) moved: Option>, - pub(crate) should_close: Option bool>>, - pub(crate) close: Option>, - pub(crate) appearance_changed: Option>, - pub(crate) hit_test_window_control: Option Option>>, -} - -pub(crate) struct WebWindowMutableState { - pub(crate) renderer: WgpuRenderer, - pub(crate) bounds: Bounds, - pub(crate) scale_factor: f32, - pub(crate) max_texture_dimension: u32, - pub(crate) title: String, - pub(crate) input_handler: Option, - pub(crate) is_fullscreen: bool, - pub(crate) is_active: bool, - pub(crate) is_hovered: bool, - pub(crate) mouse_position: Point, - pub(crate) modifiers: Modifiers, - pub(crate) capslock: Capslock, -} - -pub(crate) struct WebWindowInner { - pub(crate) browser_window: web_sys::Window, - pub(crate) canvas: web_sys::HtmlCanvasElement, - pub(crate) input_element: web_sys::HtmlInputElement, - pub(crate) has_device_pixel_support: bool, - pub(crate) is_mac: bool, - pub(crate) state: RefCell, - pub(crate) callbacks: RefCell, - pub(crate) click_state: RefCell, - pub(crate) pressed_button: Cell>, - pub(crate) last_physical_size: Cell<(u32, u32)>, - pub(crate) notify_scale: Cell, - pub(crate) is_composing: Cell, - mql_handle: RefCell>, - pending_physical_size: Cell>, -} - -pub struct WebWindow { - inner: Rc, - display: Rc, - #[allow(dead_code)] - handle: AnyWindowHandle, - _raf_closure: Closure, - _resize_observer: Option, - _resize_observer_closure: Closure, - _event_listeners: WebEventListeners, -} - -impl WebWindow { - pub fn new( - handle: AnyWindowHandle, - _params: WindowParams, - context: &WgpuContext, - browser_window: web_sys::Window, - ) -> anyhow::Result { - let document = browser_window - .document() - .ok_or_else(|| anyhow::anyhow!("No `document` found on window"))?; - - let canvas: web_sys::HtmlCanvasElement = document - .create_element("canvas") - .map_err(|e| anyhow::anyhow!("Failed to create canvas element: {e:?}"))? - .dyn_into() - .map_err(|e| anyhow::anyhow!("Created element is not a canvas: {e:?}"))?; - - let dpr = browser_window.device_pixel_ratio() as f32; - let max_texture_dimension = context.device.limits().max_texture_dimension_2d; - let has_device_pixel_support = check_device_pixel_support(); - - canvas.set_tab_index(-1); - - let style = canvas.style(); - style - .set_property("width", "100%") - .map_err(|e| anyhow::anyhow!("Failed to set canvas width style: {e:?}"))?; - style - .set_property("height", "100%") - .map_err(|e| anyhow::anyhow!("Failed to set canvas height style: {e:?}"))?; - style - .set_property("display", "block") - .map_err(|e| anyhow::anyhow!("Failed to set canvas display style: {e:?}"))?; - style - .set_property("outline", "none") - .map_err(|e| anyhow::anyhow!("Failed to set canvas outline style: {e:?}"))?; - style - .set_property("touch-action", "none") - .map_err(|e| anyhow::anyhow!("Failed to set touch-action style: {e:?}"))?; - - let body = document - .body() - .ok_or_else(|| anyhow::anyhow!("No `body` found on document"))?; - body.append_child(&canvas) - .map_err(|e| anyhow::anyhow!("Failed to append canvas to body: {e:?}"))?; - - let input_element: web_sys::HtmlInputElement = document - .create_element("input") - .map_err(|e| anyhow::anyhow!("Failed to create input element: {e:?}"))? - .dyn_into() - .map_err(|e| anyhow::anyhow!("Created element is not an input: {e:?}"))?; - let input_style = input_element.style(); - input_style.set_property("position", "fixed").ok(); - input_style.set_property("top", "0").ok(); - input_style.set_property("left", "0").ok(); - input_style.set_property("width", "1px").ok(); - input_style.set_property("height", "1px").ok(); - input_style.set_property("opacity", "0").ok(); - body.append_child(&input_element) - .map_err(|e| anyhow::anyhow!("Failed to append input to body: {e:?}"))?; - input_element.focus().ok(); - - let device_size = Size { - width: DevicePixels(0), - height: DevicePixels(0), - }; - - let renderer_config = WgpuSurfaceConfig { - size: device_size, - transparent: false, - preferred_present_mode: None, - }; - - let renderer = WgpuRenderer::new_from_canvas(context, &canvas, renderer_config)?; - - let display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); - - let initial_bounds = Bounds { - origin: Point::default(), - size: Size::default(), - }; - - let mutable_state = WebWindowMutableState { - renderer, - bounds: initial_bounds, - scale_factor: dpr, - max_texture_dimension, - title: String::new(), - input_handler: None, - is_fullscreen: false, - is_active: true, - is_hovered: false, - mouse_position: Point::default(), - modifiers: Modifiers::default(), - capslock: Capslock::default(), - }; - - let is_mac = is_mac_platform(&browser_window); - - let inner = Rc::new(WebWindowInner { - browser_window, - canvas, - input_element, - has_device_pixel_support, - is_mac, - state: RefCell::new(mutable_state), - callbacks: RefCell::new(WebWindowCallbacks::default()), - click_state: RefCell::new(ClickState::default()), - pressed_button: Cell::new(None), - last_physical_size: Cell::new((0, 0)), - notify_scale: Cell::new(false), - is_composing: Cell::new(false), - mql_handle: RefCell::new(None), - pending_physical_size: Cell::new(None), - }); - - let raf_closure = inner.create_raf_closure(); - inner.schedule_raf(&raf_closure); - - let resize_observer_closure = Self::create_resize_observer_closure(Rc::clone(&inner)); - let resize_observer = - web_sys::ResizeObserver::new(resize_observer_closure.as_ref().unchecked_ref()).ok(); - - if let Some(ref observer) = resize_observer { - inner.observe_canvas(observer); - inner.watch_dpr_changes(observer); - } - - let event_listeners = inner.register_event_listeners(); - - Ok(Self { - inner, - display, - handle, - _raf_closure: raf_closure, - _resize_observer: resize_observer, - _resize_observer_closure: resize_observer_closure, - _event_listeners: event_listeners, - }) - } - - fn create_resize_observer_closure( - inner: Rc, - ) -> Closure { - Closure::new(move |entries: js_sys::Array| { - let entry: web_sys::ResizeObserverEntry = match entries.get(0).dyn_into().ok() { - Some(entry) => entry, - None => return, - }; - - let dpr = inner.browser_window.device_pixel_ratio(); - let dpr_f32 = dpr as f32; - - let (physical_width, physical_height, logical_width, logical_height) = - if inner.has_device_pixel_support { - let size: web_sys::ResizeObserverSize = entry - .device_pixel_content_box_size() - .get(0) - .unchecked_into(); - let pw = size.inline_size() as u32; - let ph = size.block_size() as u32; - let lw = pw as f64 / dpr; - let lh = ph as f64 / dpr; - (pw, ph, lw as f32, lh as f32) - } else { - // Safari fallback: use contentRect (always CSS px). - let rect = entry.content_rect(); - let lw = rect.width() as f32; - let lh = rect.height() as f32; - let pw = (lw as f64 * dpr).round() as u32; - let ph = (lh as f64 * dpr).round() as u32; - (pw, ph, lw, lh) - }; - - let scale_changed = inner.notify_scale.replace(false); - let prev = inner.last_physical_size.get(); - let size_changed = prev != (physical_width, physical_height); - - if !scale_changed && !size_changed { - return; - } - inner - .last_physical_size - .set((physical_width, physical_height)); - - // Skip rendering to a zero-size canvas (e.g. display:none). - if physical_width == 0 || physical_height == 0 { - let mut s = inner.state.borrow_mut(); - s.bounds.size = Size::default(); - s.scale_factor = dpr_f32; - // Still fire the callback so GPUI knows the window is gone. - drop(s); - let mut cbs = inner.callbacks.borrow_mut(); - if let Some(ref mut callback) = cbs.resize { - callback(Size::default(), dpr_f32); - } - return; - } - - let max_texture_dimension = inner.state.borrow().max_texture_dimension; - let clamped_width = physical_width.min(max_texture_dimension); - let clamped_height = physical_height.min(max_texture_dimension); - - inner - .pending_physical_size - .set(Some((clamped_width, clamped_height))); - - { - let mut s = inner.state.borrow_mut(); - s.bounds.size = Size { - width: px(logical_width), - height: px(logical_height), - }; - s.scale_factor = dpr_f32; - } - - let new_size = Size { - width: px(logical_width), - height: px(logical_height), - }; - - let mut cbs = inner.callbacks.borrow_mut(); - if let Some(ref mut callback) = cbs.resize { - callback(new_size, dpr_f32); - } - }) - } -} - -impl WebWindowInner { - fn create_raf_closure(self: &Rc) -> Closure { - let raf_handle: Rc>> = Rc::new(RefCell::new(None)); - let raf_handle_inner = Rc::clone(&raf_handle); - - let this = Rc::clone(self); - let closure = Closure::new(move || { - { - let mut callbacks = this.callbacks.borrow_mut(); - if let Some(ref mut callback) = callbacks.request_frame { - callback(RequestFrameOptions { - require_presentation: true, - force_render: false, - }); - } - } - - // Re-schedule for the next frame - if let Some(ref func) = *raf_handle_inner.borrow() { - this.browser_window.request_animation_frame(func).ok(); - } - }); - - let js_func: js_sys::Function = - closure.as_ref().unchecked_ref::().clone(); - *raf_handle.borrow_mut() = Some(js_func); - - closure - } - - fn schedule_raf(&self, closure: &Closure) { - self.browser_window - .request_animation_frame(closure.as_ref().unchecked_ref()) - .ok(); - } - - fn observe_canvas(&self, observer: &web_sys::ResizeObserver) { - observer.unobserve(&self.canvas); - if self.has_device_pixel_support { - let options = web_sys::ResizeObserverOptions::new(); - options.set_box(web_sys::ResizeObserverBoxOptions::DevicePixelContentBox); - observer.observe_with_options(&self.canvas, &options); - } else { - observer.observe(&self.canvas); - } - } - - fn watch_dpr_changes(self: &Rc, observer: &web_sys::ResizeObserver) { - let current_dpr = self.browser_window.device_pixel_ratio(); - let media_query = - format!("(resolution: {current_dpr}dppx), (-webkit-device-pixel-ratio: {current_dpr})"); - let Some(mql) = self.browser_window.match_media(&media_query).ok().flatten() else { - return; - }; - - let this = Rc::clone(self); - let observer = observer.clone(); - - let closure = Closure::::new(move |_event: JsValue| { - this.notify_scale.set(true); - this.observe_canvas(&observer); - this.watch_dpr_changes(&observer); - }); - - mql.add_event_listener_with_callback("change", closure.as_ref().unchecked_ref()) - .ok(); - - *self.mql_handle.borrow_mut() = Some(MqlHandle { - mql, - _closure: closure, - }); - } - - pub(crate) fn register_visibility_change( - self: &Rc, - ) -> Option> { - let document = self.browser_window.document()?; - let this = Rc::clone(self); - - let closure = Closure::::new(move |_event: JsValue| { - let is_visible = this - .browser_window - .document() - .map(|doc| { - let state_str: String = js_sys::Reflect::get(&doc, &"visibilityState".into()) - .ok() - .and_then(|v| v.as_string()) - .unwrap_or_default(); - state_str == "visible" - }) - .unwrap_or(true); - - { - let mut state = this.state.borrow_mut(); - state.is_active = is_visible; - } - let mut callbacks = this.callbacks.borrow_mut(); - if let Some(ref mut callback) = callbacks.active_status_change { - callback(is_visible); - } - }); - - document - .add_event_listener_with_callback("visibilitychange", closure.as_ref().unchecked_ref()) - .ok(); - - Some(closure) - } - - pub(crate) fn with_input_handler( - &self, - f: impl FnOnce(&mut PlatformInputHandler) -> R, - ) -> Option { - let mut handler = self.state.borrow_mut().input_handler.take()?; - let result = f(&mut handler); - self.state.borrow_mut().input_handler = Some(handler); - Some(result) - } - - pub(crate) fn register_appearance_change( - self: &Rc, - ) -> Option> { - let mql = self - .browser_window - .match_media("(prefers-color-scheme: dark)") - .ok()??; - - let this = Rc::clone(self); - let closure = Closure::::new(move |_event: JsValue| { - let mut callbacks = this.callbacks.borrow_mut(); - if let Some(ref mut callback) = callbacks.appearance_changed { - callback(); - } - }); - - mql.add_event_listener_with_callback("change", closure.as_ref().unchecked_ref()) - .ok(); - - Some(closure) - } -} - -fn current_appearance(browser_window: &web_sys::Window) -> WindowAppearance { - let is_dark = browser_window - .match_media("(prefers-color-scheme: dark)") - .ok() - .flatten() - .map(|mql| mql.matches()) - .unwrap_or(false); - - if is_dark { - WindowAppearance::Dark - } else { - WindowAppearance::Light - } -} - -struct MqlHandle { - mql: web_sys::MediaQueryList, - _closure: Closure, -} - -impl Drop for MqlHandle { - fn drop(&mut self) { - self.mql - .remove_event_listener_with_callback("change", self._closure.as_ref().unchecked_ref()) - .ok(); - } -} - -// Safari does not support `devicePixelContentBoxSize`, so detect whether it's available. -fn check_device_pixel_support() -> bool { - let global: JsValue = js_sys::global().into(); - let Ok(constructor) = js_sys::Reflect::get(&global, &"ResizeObserverEntry".into()) else { - return false; - }; - let Ok(prototype) = js_sys::Reflect::get(&constructor, &"prototype".into()) else { - return false; - }; - let descriptor = js_sys::Object::get_own_property_descriptor( - &prototype.unchecked_into::(), - &"devicePixelContentBoxSize".into(), - ); - !descriptor.is_undefined() -} - -impl raw_window_handle::HasWindowHandle for WebWindow { - fn window_handle( - &self, - ) -> Result, raw_window_handle::HandleError> { - let canvas_ref: &JsValue = self.inner.canvas.as_ref(); - let obj = std::ptr::NonNull::from(canvas_ref).cast::(); - let handle = raw_window_handle::WebCanvasWindowHandle::new(obj); - Ok(unsafe { raw_window_handle::WindowHandle::borrow_raw(handle.into()) }) - } -} - -impl raw_window_handle::HasDisplayHandle for WebWindow { - fn display_handle( - &self, - ) -> Result, raw_window_handle::HandleError> { - Ok(raw_window_handle::DisplayHandle::web()) - } -} - -impl PlatformWindow for WebWindow { - fn bounds(&self) -> Bounds { - self.inner.state.borrow().bounds - } - - fn is_maximized(&self) -> bool { - false - } - - fn window_bounds(&self) -> WindowBounds { - WindowBounds::Windowed(self.bounds()) - } - - fn content_size(&self) -> Size { - self.inner.state.borrow().bounds.size - } - - fn resize(&mut self, size: Size) { - let style = self.inner.canvas.style(); - style - .set_property("width", &format!("{}px", f32::from(size.width))) - .ok(); - style - .set_property("height", &format!("{}px", f32::from(size.height))) - .ok(); - } - - fn scale_factor(&self) -> f32 { - self.inner.state.borrow().scale_factor - } - - fn appearance(&self) -> WindowAppearance { - current_appearance(&self.inner.browser_window) - } - - fn display(&self) -> Option> { - Some(self.display.clone()) - } - - fn mouse_position(&self) -> Point { - self.inner.state.borrow().mouse_position - } - - fn modifiers(&self) -> Modifiers { - self.inner.state.borrow().modifiers - } - - fn capslock(&self) -> Capslock { - self.inner.state.borrow().capslock - } - - fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { - self.inner.state.borrow_mut().input_handler = Some(input_handler); - } - - fn take_input_handler(&mut self) -> Option { - self.inner.state.borrow_mut().input_handler.take() - } - - fn prompt( - &self, - _level: PromptLevel, - _msg: &str, - _detail: Option<&str>, - _answers: &[PromptButton], - ) -> Option> { - None - } - - fn activate(&self) { - self.inner.state.borrow_mut().is_active = true; - } - - fn is_active(&self) -> bool { - self.inner.state.borrow().is_active - } - - fn is_hovered(&self) -> bool { - self.inner.state.borrow().is_hovered - } - - fn background_appearance(&self) -> WindowBackgroundAppearance { - WindowBackgroundAppearance::Opaque - } - - fn set_title(&mut self, title: &str) { - self.inner.state.borrow_mut().title = title.to_owned(); - if let Some(document) = self.inner.browser_window.document() { - document.set_title(title); - } - } - - fn set_background_appearance(&self, _background: WindowBackgroundAppearance) {} - - fn minimize(&self) { - log::warn!("WebWindow::minimize is not supported in the browser"); - } - - fn zoom(&self) { - log::warn!("WebWindow::zoom is not supported in the browser"); - } - - fn toggle_fullscreen(&self) { - let mut state = self.inner.state.borrow_mut(); - state.is_fullscreen = !state.is_fullscreen; - - if state.is_fullscreen { - let canvas: &web_sys::Element = self.inner.canvas.as_ref(); - canvas.request_fullscreen().ok(); - } else { - if let Some(document) = self.inner.browser_window.document() { - document.exit_fullscreen(); - } - } - } - - fn is_fullscreen(&self) -> bool { - self.inner.state.borrow().is_fullscreen - } - - fn on_request_frame(&self, callback: Box) { - self.inner.callbacks.borrow_mut().request_frame = Some(callback); - } - - fn on_input(&self, callback: Box DispatchEventResult>) { - self.inner.callbacks.borrow_mut().input = Some(callback); - } - - fn on_active_status_change(&self, callback: Box) { - self.inner.callbacks.borrow_mut().active_status_change = Some(callback); - } - - fn on_hover_status_change(&self, callback: Box) { - self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); - } - - fn on_resize(&self, callback: Box, f32)>) { - self.inner.callbacks.borrow_mut().resize = Some(callback); - } - - fn on_moved(&self, callback: Box) { - self.inner.callbacks.borrow_mut().moved = Some(callback); - } - - fn on_should_close(&self, callback: Box bool>) { - self.inner.callbacks.borrow_mut().should_close = Some(callback); - } - - fn on_close(&self, callback: Box) { - self.inner.callbacks.borrow_mut().close = Some(callback); - } - - fn on_hit_test_window_control(&self, callback: Box Option>) { - self.inner.callbacks.borrow_mut().hit_test_window_control = Some(callback); - } - - fn on_appearance_changed(&self, callback: Box) { - self.inner.callbacks.borrow_mut().appearance_changed = Some(callback); - } - - fn draw(&self, scene: &Scene) { - if let Some((width, height)) = self.inner.pending_physical_size.take() { - if self.inner.canvas.width() != width || self.inner.canvas.height() != height { - self.inner.canvas.set_width(width); - self.inner.canvas.set_height(height); - } - - let mut state = self.inner.state.borrow_mut(); - state.renderer.update_drawable_size(Size { - width: DevicePixels(width as i32), - height: DevicePixels(height as i32), - }); - drop(state); - } - - self.inner.state.borrow_mut().renderer.draw(scene); - } - - fn completed_frame(&self) { - // On web, presentation happens automatically via wgpu surface present - } - - fn sprite_atlas(&self) -> Arc { - self.inner.state.borrow().renderer.sprite_atlas().clone() - } - - fn is_subpixel_rendering_supported(&self) -> bool { - self.inner - .state - .borrow() - .renderer - .supports_dual_source_blending() - } - - fn gpu_specs(&self) -> Option { - Some(self.inner.state.borrow().renderer.gpu_specs()) - } - - fn update_ime_position(&self, _bounds: Bounds) {} - - fn request_decorations(&self, _decorations: WindowDecorations) {} - - fn show_window_menu(&self, _position: Point) {} - - fn start_window_move(&self) {} - - fn start_window_resize(&self, _edge: ResizeEdge) {} - - fn window_decorations(&self) -> Decorations { - Decorations::Server - } - - fn set_app_id(&mut self, _app_id: &str) {} - - fn window_controls(&self) -> WindowControls { - WindowControls { - fullscreen: true, - maximize: false, - minimize: false, - window_menu: false, - } - } - - fn set_client_inset(&self, _inset: Pixels) {} -} +use crate::display::WebDisplay; +use crate::events::{ClickState, WebEventListeners, is_mac_platform}; +use std::sync::Arc; +use std::{cell::Cell, cell::RefCell, rc::Rc}; + +use gpui::{ + AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult, GpuSpecs, + Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, + ResizeEdge, Scene, Size, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowControls, WindowDecorations, WindowParams, px, +}; +use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use wasm_bindgen::prelude::*; + +#[derive(Default)] +pub(crate) struct WebWindowCallbacks { + pub(crate) request_frame: Option>, + pub(crate) input: Option DispatchEventResult>>, + pub(crate) active_status_change: Option>, + pub(crate) hover_status_change: Option>, + pub(crate) resize: Option, f32)>>, + pub(crate) moved: Option>, + pub(crate) should_close: Option bool>>, + pub(crate) close: Option>, + pub(crate) appearance_changed: Option>, + pub(crate) hit_test_window_control: Option Option>>, +} + +pub(crate) struct WebWindowMutableState { + pub(crate) renderer: WgpuRenderer, + pub(crate) bounds: Bounds, + pub(crate) scale_factor: f32, + pub(crate) max_texture_dimension: u32, + pub(crate) title: String, + pub(crate) input_handler: Option, + pub(crate) is_fullscreen: bool, + pub(crate) is_active: bool, + pub(crate) is_hovered: bool, + pub(crate) mouse_position: Point, + pub(crate) modifiers: Modifiers, + pub(crate) capslock: Capslock, +} + +pub(crate) struct WebWindowInner { + pub(crate) browser_window: web_sys::Window, + pub(crate) canvas: web_sys::HtmlCanvasElement, + pub(crate) input_element: web_sys::HtmlInputElement, + pub(crate) has_device_pixel_support: bool, + pub(crate) is_mac: bool, + pub(crate) state: RefCell, + pub(crate) callbacks: RefCell, + pub(crate) click_state: RefCell, + pub(crate) pressed_button: Cell>, + pub(crate) last_physical_size: Cell<(u32, u32)>, + pub(crate) notify_scale: Cell, + pub(crate) is_composing: Cell, + mql_handle: RefCell>, + pending_physical_size: Cell>, +} + +pub struct WebWindow { + inner: Rc, + display: Rc, + #[allow(dead_code)] + handle: AnyWindowHandle, + _raf_closure: Closure, + _resize_observer: Option, + _resize_observer_closure: Closure, + _event_listeners: WebEventListeners, +} + +impl WebWindow { + pub fn new( + handle: AnyWindowHandle, + _params: WindowParams, + context: &WgpuContext, + browser_window: web_sys::Window, + ) -> anyhow::Result { + let document = browser_window + .document() + .ok_or_else(|| anyhow::anyhow!("No `document` found on window"))?; + + let canvas: web_sys::HtmlCanvasElement = document + .create_element("canvas") + .map_err(|e| anyhow::anyhow!("Failed to create canvas element: {e:?}"))? + .dyn_into() + .map_err(|e| anyhow::anyhow!("Created element is not a canvas: {e:?}"))?; + + let dpr = browser_window.device_pixel_ratio() as f32; + let max_texture_dimension = context.device.limits().max_texture_dimension_2d; + let has_device_pixel_support = check_device_pixel_support(); + + canvas.set_tab_index(-1); + + let style = canvas.style(); + style + .set_property("width", "100%") + .map_err(|e| anyhow::anyhow!("Failed to set canvas width style: {e:?}"))?; + style + .set_property("height", "100%") + .map_err(|e| anyhow::anyhow!("Failed to set canvas height style: {e:?}"))?; + style + .set_property("display", "block") + .map_err(|e| anyhow::anyhow!("Failed to set canvas display style: {e:?}"))?; + style + .set_property("outline", "none") + .map_err(|e| anyhow::anyhow!("Failed to set canvas outline style: {e:?}"))?; + style + .set_property("touch-action", "none") + .map_err(|e| anyhow::anyhow!("Failed to set touch-action style: {e:?}"))?; + + let body = document + .body() + .ok_or_else(|| anyhow::anyhow!("No `body` found on document"))?; + body.append_child(&canvas) + .map_err(|e| anyhow::anyhow!("Failed to append canvas to body: {e:?}"))?; + + let input_element: web_sys::HtmlInputElement = document + .create_element("input") + .map_err(|e| anyhow::anyhow!("Failed to create input element: {e:?}"))? + .dyn_into() + .map_err(|e| anyhow::anyhow!("Created element is not an input: {e:?}"))?; + let input_style = input_element.style(); + input_style.set_property("position", "fixed").ok(); + input_style.set_property("top", "0").ok(); + input_style.set_property("left", "0").ok(); + input_style.set_property("width", "1px").ok(); + input_style.set_property("height", "1px").ok(); + input_style.set_property("opacity", "0").ok(); + body.append_child(&input_element) + .map_err(|e| anyhow::anyhow!("Failed to append input to body: {e:?}"))?; + input_element.focus().ok(); + + let device_size = Size { + width: DevicePixels(0), + height: DevicePixels(0), + }; + + let renderer_config = WgpuSurfaceConfig { + size: device_size, + transparent: false, + preferred_present_mode: None, + }; + + let renderer = WgpuRenderer::new_from_canvas(context, &canvas, renderer_config)?; + + let display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); + + let initial_bounds = Bounds { + origin: Point::default(), + size: Size::default(), + }; + + let mutable_state = WebWindowMutableState { + renderer, + bounds: initial_bounds, + scale_factor: dpr, + max_texture_dimension, + title: String::new(), + input_handler: None, + is_fullscreen: false, + is_active: true, + is_hovered: false, + mouse_position: Point::default(), + modifiers: Modifiers::default(), + capslock: Capslock::default(), + }; + + let is_mac = is_mac_platform(&browser_window); + + let inner = Rc::new(WebWindowInner { + browser_window, + canvas, + input_element, + has_device_pixel_support, + is_mac, + state: RefCell::new(mutable_state), + callbacks: RefCell::new(WebWindowCallbacks::default()), + click_state: RefCell::new(ClickState::default()), + pressed_button: Cell::new(None), + last_physical_size: Cell::new((0, 0)), + notify_scale: Cell::new(false), + is_composing: Cell::new(false), + mql_handle: RefCell::new(None), + pending_physical_size: Cell::new(None), + }); + + let raf_closure = inner.create_raf_closure(); + inner.schedule_raf(&raf_closure); + + let resize_observer_closure = Self::create_resize_observer_closure(Rc::clone(&inner)); + let resize_observer = + web_sys::ResizeObserver::new(resize_observer_closure.as_ref().unchecked_ref()).ok(); + + if let Some(ref observer) = resize_observer { + inner.observe_canvas(observer); + inner.watch_dpr_changes(observer); + } + + let event_listeners = inner.register_event_listeners(); + + Ok(Self { + inner, + display, + handle, + _raf_closure: raf_closure, + _resize_observer: resize_observer, + _resize_observer_closure: resize_observer_closure, + _event_listeners: event_listeners, + }) + } + + fn create_resize_observer_closure( + inner: Rc, + ) -> Closure { + Closure::new(move |entries: js_sys::Array| { + let entry: web_sys::ResizeObserverEntry = match entries.get(0).dyn_into().ok() { + Some(entry) => entry, + None => return, + }; + + let dpr = inner.browser_window.device_pixel_ratio(); + let dpr_f32 = dpr as f32; + + let (physical_width, physical_height, logical_width, logical_height) = + if inner.has_device_pixel_support { + let size: web_sys::ResizeObserverSize = entry + .device_pixel_content_box_size() + .get(0) + .unchecked_into(); + let pw = size.inline_size() as u32; + let ph = size.block_size() as u32; + let lw = pw as f64 / dpr; + let lh = ph as f64 / dpr; + (pw, ph, lw as f32, lh as f32) + } else { + // Safari fallback: use contentRect (always CSS px). + let rect = entry.content_rect(); + let lw = rect.width() as f32; + let lh = rect.height() as f32; + let pw = (lw as f64 * dpr).round() as u32; + let ph = (lh as f64 * dpr).round() as u32; + (pw, ph, lw, lh) + }; + + let scale_changed = inner.notify_scale.replace(false); + let prev = inner.last_physical_size.get(); + let size_changed = prev != (physical_width, physical_height); + + if !scale_changed && !size_changed { + return; + } + inner + .last_physical_size + .set((physical_width, physical_height)); + + // Skip rendering to a zero-size canvas (e.g. display:none). + if physical_width == 0 || physical_height == 0 { + let mut s = inner.state.borrow_mut(); + s.bounds.size = Size::default(); + s.scale_factor = dpr_f32; + // Still fire the callback so GPUI knows the window is gone. + drop(s); + let mut cbs = inner.callbacks.borrow_mut(); + if let Some(ref mut callback) = cbs.resize { + callback(Size::default(), dpr_f32); + } + return; + } + + let max_texture_dimension = inner.state.borrow().max_texture_dimension; + let clamped_width = physical_width.min(max_texture_dimension); + let clamped_height = physical_height.min(max_texture_dimension); + + inner + .pending_physical_size + .set(Some((clamped_width, clamped_height))); + + { + let mut s = inner.state.borrow_mut(); + s.bounds.size = Size { + width: px(logical_width), + height: px(logical_height), + }; + s.scale_factor = dpr_f32; + } + + let new_size = Size { + width: px(logical_width), + height: px(logical_height), + }; + + let mut cbs = inner.callbacks.borrow_mut(); + if let Some(ref mut callback) = cbs.resize { + callback(new_size, dpr_f32); + } + }) + } +} + +impl WebWindowInner { + fn create_raf_closure(self: &Rc) -> Closure { + let raf_handle: Rc>> = Rc::new(RefCell::new(None)); + let raf_handle_inner = Rc::clone(&raf_handle); + + let this = Rc::clone(self); + let closure = Closure::new(move || { + { + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.request_frame { + callback(RequestFrameOptions { + require_presentation: true, + force_render: false, + }); + } + } + + // Re-schedule for the next frame + if let Some(ref func) = *raf_handle_inner.borrow() { + this.browser_window.request_animation_frame(func).ok(); + } + }); + + let js_func: js_sys::Function = + closure.as_ref().unchecked_ref::().clone(); + *raf_handle.borrow_mut() = Some(js_func); + + closure + } + + fn schedule_raf(&self, closure: &Closure) { + self.browser_window + .request_animation_frame(closure.as_ref().unchecked_ref()) + .ok(); + } + + fn observe_canvas(&self, observer: &web_sys::ResizeObserver) { + observer.unobserve(&self.canvas); + if self.has_device_pixel_support { + let options = web_sys::ResizeObserverOptions::new(); + options.set_box(web_sys::ResizeObserverBoxOptions::DevicePixelContentBox); + observer.observe_with_options(&self.canvas, &options); + } else { + observer.observe(&self.canvas); + } + } + + fn watch_dpr_changes(self: &Rc, observer: &web_sys::ResizeObserver) { + let current_dpr = self.browser_window.device_pixel_ratio(); + let media_query = + format!("(resolution: {current_dpr}dppx), (-webkit-device-pixel-ratio: {current_dpr})"); + let Some(mql) = self.browser_window.match_media(&media_query).ok().flatten() else { + return; + }; + + let this = Rc::clone(self); + let observer = observer.clone(); + + let closure = Closure::::new(move |_event: JsValue| { + this.notify_scale.set(true); + this.observe_canvas(&observer); + this.watch_dpr_changes(&observer); + }); + + mql.add_event_listener_with_callback("change", closure.as_ref().unchecked_ref()) + .ok(); + + *self.mql_handle.borrow_mut() = Some(MqlHandle { + mql, + _closure: closure, + }); + } + + pub(crate) fn register_visibility_change( + self: &Rc, + ) -> Option> { + let document = self.browser_window.document()?; + let this = Rc::clone(self); + + let closure = Closure::::new(move |_event: JsValue| { + let is_visible = this + .browser_window + .document() + .map(|doc| { + let state_str: String = js_sys::Reflect::get(&doc, &"visibilityState".into()) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(); + state_str == "visible" + }) + .unwrap_or(true); + + { + let mut state = this.state.borrow_mut(); + state.is_active = is_visible; + } + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.active_status_change { + callback(is_visible); + } + }); + + document + .add_event_listener_with_callback("visibilitychange", closure.as_ref().unchecked_ref()) + .ok(); + + Some(closure) + } + + pub(crate) fn with_input_handler( + &self, + f: impl FnOnce(&mut PlatformInputHandler) -> R, + ) -> Option { + let mut handler = self.state.borrow_mut().input_handler.take()?; + let result = f(&mut handler); + self.state.borrow_mut().input_handler = Some(handler); + Some(result) + } + + pub(crate) fn register_appearance_change( + self: &Rc, + ) -> Option> { + let mql = self + .browser_window + .match_media("(prefers-color-scheme: dark)") + .ok()??; + + let this = Rc::clone(self); + let closure = Closure::::new(move |_event: JsValue| { + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.appearance_changed { + callback(); + } + }); + + mql.add_event_listener_with_callback("change", closure.as_ref().unchecked_ref()) + .ok(); + + Some(closure) + } +} + +fn current_appearance(browser_window: &web_sys::Window) -> WindowAppearance { + let is_dark = browser_window + .match_media("(prefers-color-scheme: dark)") + .ok() + .flatten() + .map(|mql| mql.matches()) + .unwrap_or(false); + + if is_dark { + WindowAppearance::Dark + } else { + WindowAppearance::Light + } +} + +struct MqlHandle { + mql: web_sys::MediaQueryList, + _closure: Closure, +} + +impl Drop for MqlHandle { + fn drop(&mut self) { + self.mql + .remove_event_listener_with_callback("change", self._closure.as_ref().unchecked_ref()) + .ok(); + } +} + +// Safari does not support `devicePixelContentBoxSize`, so detect whether it's available. +fn check_device_pixel_support() -> bool { + let global: JsValue = js_sys::global().into(); + let Ok(constructor) = js_sys::Reflect::get(&global, &"ResizeObserverEntry".into()) else { + return false; + }; + let Ok(prototype) = js_sys::Reflect::get(&constructor, &"prototype".into()) else { + return false; + }; + let descriptor = js_sys::Object::get_own_property_descriptor( + &prototype.unchecked_into::(), + &"devicePixelContentBoxSize".into(), + ); + !descriptor.is_undefined() +} + +impl raw_window_handle::HasWindowHandle for WebWindow { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + let canvas_ref: &JsValue = self.inner.canvas.as_ref(); + let obj = std::ptr::NonNull::from(canvas_ref).cast::(); + let handle = raw_window_handle::WebCanvasWindowHandle::new(obj); + Ok(unsafe { raw_window_handle::WindowHandle::borrow_raw(handle.into()) }) + } +} + +impl raw_window_handle::HasDisplayHandle for WebWindow { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Ok(raw_window_handle::DisplayHandle::web()) + } +} + +impl PlatformWindow for WebWindow { + fn bounds(&self) -> Bounds { + self.inner.state.borrow().bounds + } + + fn is_maximized(&self) -> bool { + false + } + + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Windowed(self.bounds()) + } + + fn content_size(&self) -> Size { + self.inner.state.borrow().bounds.size + } + + fn resize(&mut self, size: Size) { + let style = self.inner.canvas.style(); + style + .set_property("width", &format!("{}px", f32::from(size.width))) + .ok(); + style + .set_property("height", &format!("{}px", f32::from(size.height))) + .ok(); + } + + fn scale_factor(&self) -> f32 { + self.inner.state.borrow().scale_factor + } + + fn appearance(&self) -> WindowAppearance { + current_appearance(&self.inner.browser_window) + } + + fn display(&self) -> Option> { + Some(self.display.clone()) + } + + fn mouse_position(&self) -> Point { + self.inner.state.borrow().mouse_position + } + + fn modifiers(&self) -> Modifiers { + self.inner.state.borrow().modifiers + } + + fn capslock(&self) -> Capslock { + self.inner.state.borrow().capslock + } + + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.inner.state.borrow_mut().input_handler = Some(input_handler); + } + + fn take_input_handler(&mut self) -> Option { + self.inner.state.borrow_mut().input_handler.take() + } + + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + None + } + + fn activate(&self) { + self.inner.state.borrow_mut().is_active = true; + } + + fn is_active(&self) -> bool { + self.inner.state.borrow().is_active + } + + fn is_hovered(&self) -> bool { + self.inner.state.borrow().is_hovered + } + + fn background_appearance(&self) -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Opaque + } + + fn set_title(&mut self, title: &str) { + self.inner.state.borrow_mut().title = title.to_owned(); + if let Some(document) = self.inner.browser_window.document() { + document.set_title(title); + } + } + + fn set_background_appearance(&self, _background: WindowBackgroundAppearance) {} + + fn minimize(&self) { + log::warn!("WebWindow::minimize is not supported in the browser"); + } + + fn zoom(&self) { + log::warn!("WebWindow::zoom is not supported in the browser"); + } + + fn toggle_fullscreen(&self) { + let mut state = self.inner.state.borrow_mut(); + state.is_fullscreen = !state.is_fullscreen; + + if state.is_fullscreen { + let canvas: &web_sys::Element = self.inner.canvas.as_ref(); + canvas.request_fullscreen().ok(); + } else { + if let Some(document) = self.inner.browser_window.document() { + document.exit_fullscreen(); + } + } + } + + fn is_fullscreen(&self) -> bool { + self.inner.state.borrow().is_fullscreen + } + + fn on_request_frame(&self, callback: Box) { + self.inner.callbacks.borrow_mut().request_frame = Some(callback); + } + + fn on_input(&self, callback: Box DispatchEventResult>) { + self.inner.callbacks.borrow_mut().input = Some(callback); + } + + fn on_active_status_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().active_status_change = Some(callback); + } + + fn on_hover_status_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); + } + + fn on_resize(&self, callback: Box, f32)>) { + self.inner.callbacks.borrow_mut().resize = Some(callback); + } + + fn on_moved(&self, callback: Box) { + self.inner.callbacks.borrow_mut().moved = Some(callback); + } + + fn on_should_close(&self, callback: Box bool>) { + self.inner.callbacks.borrow_mut().should_close = Some(callback); + } + + fn on_close(&self, callback: Box) { + self.inner.callbacks.borrow_mut().close = Some(callback); + } + + fn on_hit_test_window_control(&self, callback: Box Option>) { + self.inner.callbacks.borrow_mut().hit_test_window_control = Some(callback); + } + + fn on_appearance_changed(&self, callback: Box) { + self.inner.callbacks.borrow_mut().appearance_changed = Some(callback); + } + + fn draw(&self, scene: &Scene) { + if let Some((width, height)) = self.inner.pending_physical_size.take() { + if self.inner.canvas.width() != width || self.inner.canvas.height() != height { + self.inner.canvas.set_width(width); + self.inner.canvas.set_height(height); + } + + let mut state = self.inner.state.borrow_mut(); + state.renderer.update_drawable_size(Size { + width: DevicePixels(width as i32), + height: DevicePixels(height as i32), + }); + drop(state); + } + + self.inner.state.borrow_mut().renderer.draw(scene); + } + + fn completed_frame(&self) { + // On web, presentation happens automatically via wgpu surface present + } + + fn sprite_atlas(&self) -> Arc { + self.inner.state.borrow().renderer.sprite_atlas().clone() + } + + fn is_subpixel_rendering_supported(&self) -> bool { + self.inner + .state + .borrow() + .renderer + .supports_dual_source_blending() + } + + fn gpu_specs(&self) -> Option { + Some(self.inner.state.borrow().renderer.gpu_specs()) + } + + fn update_ime_position(&self, _bounds: Bounds) {} + + fn request_decorations(&self, _decorations: WindowDecorations) {} + + fn show_window_menu(&self, _position: Point) {} + + fn start_window_move(&self) {} + + fn start_window_resize(&self, _edge: ResizeEdge) {} + + fn window_decorations(&self) -> Decorations { + Decorations::Server + } + + fn set_app_id(&mut self, _app_id: &str) {} + + fn window_controls(&self) -> WindowControls { + WindowControls { + fullscreen: true, + maximize: false, + minimize: false, + window_menu: false, + } + } + + fn set_client_inset(&self, _inset: Pixels) {} +} diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index 51d187f82e9..61a5746df35 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -20,7 +20,7 @@ gpui.workspace = true anyhow.workspace = true bytemuck = "1" collections.workspace = true -cosmic-text = "0.17.0" +cosmic-text = "0.19.0" etagere = "0.2" itertools.workspace = true log.workspace = true @@ -51,4 +51,4 @@ criterion.workspace = true [[bench]] name = "layout_line" -harness = false \ No newline at end of file +harness = false diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index 6c9e5e03d89..456ba59c0a5 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -1,8 +1,8 @@ use anyhow::{Context as _, Ok, Result}; use collections::HashMap; use cosmic_text::{ - Attrs, AttrsList, Family, Font as CosmicTextFont, FontFeatures as CosmicFontFeatures, - FontSystem, ShapeBuffer, ShapeLine, + Attrs, AttrsList, Ellipsize, Family, Font as CosmicTextFont, + FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, }; use gpui::{ Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, @@ -544,6 +544,7 @@ impl CosmicTextSystemState { f32::from(font_size), None, // We do our own wrapping cosmic_text::Wrap::None, + Ellipsize::None, None, &mut layout_lines, None, diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index b700697f47b..933b88e84d7 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -951,10 +951,18 @@ fn fmod(a: f32, b: f32) -> f32 { struct Shadow { order: u32, blur_radius: f32, + // The shadow rect for drop shadows; the "hole" rect for inset shadows. bounds: Bounds, corner_radii: Corners, content_mask: Bounds, color: Hsla, + // Only consulted when `inset == 1u`: the element's own bounds, used as a rounded-rect + // clip so the shadow never escapes the element. + element_bounds: Bounds, + element_corner_radii: Corners, + // 0 = drop shadow, 1 = inset shadow. + inset: u32, + pad: u32, // align to 8 bytes } @group(1) @binding(0) var b_shadows: array; @@ -971,17 +979,22 @@ fn vs_shadow(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) ins let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); var shadow = b_shadows[instance_id]; - let margin = 3.0 * shadow.blur_radius; - // Set the bounds of the shadow and adjust its size based on the shadow's - // spread radius to achieve the spreading effect - shadow.bounds.origin -= vec2(margin); - shadow.bounds.size += 2.0 * vec2(margin); + var geometry: Bounds; + if (shadow.inset != 0u) { + geometry = shadow.element_bounds; + } else { + // Leave room for the gaussian tail outside the shadow rect. + let margin = 3.0 * shadow.blur_radius; + geometry = shadow.bounds; + geometry.origin -= vec2(margin); + geometry.size += 2.0 * vec2(margin); + } var out = ShadowVarying(); - out.position = to_device_position(unit_vertex, shadow.bounds); + out.position = to_device_position(unit_vertex, geometry); out.color = hsla_to_rgba(shadow.color); out.shadow_id = instance_id; - out.clip_distances = distance_from_clip_rect(unit_vertex, shadow.bounds, shadow.content_mask); + out.clip_distances = distance_from_clip_rect(unit_vertex, geometry, shadow.content_mask); return out; } @@ -999,21 +1012,36 @@ fn fs_shadow(input: ShadowVarying) -> @location(0) vec4 { let corner_radius = pick_corner_radius(center_to_point, shadow.corner_radii); - // The signal is only non-zero in a limited range, so don't waste samples - let low = center_to_point.y - half_size.y; - let high = center_to_point.y + half_size.y; - let start = clamp(-3.0 * shadow.blur_radius, low, high); - let end = clamp(3.0 * shadow.blur_radius, low, high); + var alpha: f32; + if (shadow.blur_radius == 0.0) { + let distance = quad_sdf(input.position.xy, shadow.bounds, shadow.corner_radii); + alpha = saturate(0.5 - distance); + } else { + // The signal is only non-zero in a limited range, so don't waste samples + let low = center_to_point.y - half_size.y; + let high = center_to_point.y + half_size.y; + let start = clamp(-3.0 * shadow.blur_radius, low, high); + let end = clamp(3.0 * shadow.blur_radius, low, high); - // Accumulate samples (we can get away with surprisingly few samples) - let step = (end - start) / 4.0; - var y = start + step * 0.5; - var alpha = 0.0; - for (var i = 0; i < 4; i += 1) { - let blur = blur_along_x(center_to_point.x, center_to_point.y - y, - shadow.blur_radius, corner_radius, half_size); - alpha += blur * gaussian(y, shadow.blur_radius) * step; - y += step; + // Accumulate samples (we can get away with surprisingly few samples) + let step = (end - start) / 4.0; + var y = start + step * 0.5; + alpha = 0.0; + for (var i = 0; i < 4; i += 1) { + let blur = blur_along_x(center_to_point.x, center_to_point.y - y, + shadow.blur_radius, corner_radius, half_size); + alpha += blur * gaussian(y, shadow.blur_radius) * step; + y += step; + } + } + + if (shadow.inset != 0u) { + // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. + // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. + alpha = 1.0 - alpha; + let element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); } return blend_color(input.color, alpha); diff --git a/crates/gpui_wgpu/src/shaders_subpixel.wgsl b/crates/gpui_wgpu/src/shaders_subpixel.wgsl index 37face0c482..8fd936469dc 100644 --- a/crates/gpui_wgpu/src/shaders_subpixel.wgsl +++ b/crates/gpui_wgpu/src/shaders_subpixel.wgsl @@ -1,56 +1,56 @@ -// --- subpixel sprites --- // - -struct SubpixelSprite { - order: u32, - pad: u32, - bounds: Bounds, - content_mask: Bounds, - color: Hsla, - tile: AtlasTile, - transformation: TransformationMatrix, -} -@group(1) @binding(0) var b_subpixel_sprites: array; - -struct SubpixelSpriteOutput { - @builtin(position) position: vec4, - @location(0) tile_position: vec2, - @location(1) @interpolate(flat) color: vec4, - @location(3) clip_distances: vec4, -} - -struct SubpixelSpriteFragmentOutput { - @location(0) @blend_src(0) foreground: vec4, - @location(0) @blend_src(1) alpha: vec4, -} - -@vertex -fn vs_subpixel_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> SubpixelSpriteOutput { - let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); - let sprite = b_subpixel_sprites[instance_id]; - - var out = SubpixelSpriteOutput(); - out.position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation); - out.tile_position = to_tile_position(unit_vertex, sprite.tile); - out.color = hsla_to_rgba(sprite.color); - out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); - return out; -} - -@fragment -fn fs_subpixel_sprite(input: SubpixelSpriteOutput) -> SubpixelSpriteFragmentOutput { - var sample = textureSample(t_sprite, s_sprite, input.tile_position).rgb; - if (gamma_params.is_bgr != 0u) { - sample = sample.bgr; - } - let alpha_corrected = apply_contrast_and_gamma_correction3(sample, input.color.rgb, gamma_params.subpixel_enhanced_contrast, gamma_params.gamma_ratios); - - // Alpha clip after using the derivatives. - if (any(input.clip_distances < vec4(0.0))) { - return SubpixelSpriteFragmentOutput(vec4(0.0), vec4(0.0)); - } - - var out = SubpixelSpriteFragmentOutput(); - out.foreground = vec4(input.color.rgb, 1.0); - out.alpha = vec4(input.color.a * alpha_corrected, 1.0); - return out; -} +// --- subpixel sprites --- // + +struct SubpixelSprite { + order: u32, + pad: u32, + bounds: Bounds, + content_mask: Bounds, + color: Hsla, + tile: AtlasTile, + transformation: TransformationMatrix, +} +@group(1) @binding(0) var b_subpixel_sprites: array; + +struct SubpixelSpriteOutput { + @builtin(position) position: vec4, + @location(0) tile_position: vec2, + @location(1) @interpolate(flat) color: vec4, + @location(3) clip_distances: vec4, +} + +struct SubpixelSpriteFragmentOutput { + @location(0) @blend_src(0) foreground: vec4, + @location(0) @blend_src(1) alpha: vec4, +} + +@vertex +fn vs_subpixel_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> SubpixelSpriteOutput { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + let sprite = b_subpixel_sprites[instance_id]; + + var out = SubpixelSpriteOutput(); + out.position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation); + out.tile_position = to_tile_position(unit_vertex, sprite.tile); + out.color = hsla_to_rgba(sprite.color); + out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); + return out; +} + +@fragment +fn fs_subpixel_sprite(input: SubpixelSpriteOutput) -> SubpixelSpriteFragmentOutput { + var sample = textureSample(t_sprite, s_sprite, input.tile_position).rgb; + if (gamma_params.is_bgr != 0u) { + sample = sample.bgr; + } + let alpha_corrected = apply_contrast_and_gamma_correction3(sample, input.color.rgb, gamma_params.subpixel_enhanced_contrast, gamma_params.gamma_ratios); + + // Alpha clip after using the derivatives. + if (any(input.clip_distances < vec4(0.0))) { + return SubpixelSpriteFragmentOutput(vec4(0.0), vec4(0.0)); + } + + var out = SubpixelSpriteFragmentOutput(); + out.foreground = vec4(input.color.rgb, 1.0); + out.alpha = vec4(input.color.a * alpha_corrected, 1.0); + return out; +} diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index 992a47dce9e..f5a93e7f5aa 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -20,6 +20,8 @@ screen-capture = ["gpui/screen-capture", "scap"] gpui.workspace = true [target.'cfg(target_os = "windows")'.dependencies] +accesskit.workspace = true +accesskit_windows.workspace = true anyhow.workspace = true collections.workspace = true etagere = "0.2" diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 77c4cde9788..b04b819a02b 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -112,6 +112,7 @@ impl WindowsWindowInner { WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam), + WM_GETOBJECT => self.handle_wm_getobject(wparam, lparam), _ => None, }; if let Some(n) = handled { @@ -728,6 +729,17 @@ impl WindowsWindowInner { fn handle_activate_msg(self: &Rc, wparam: WPARAM) -> Option { let activated = wparam.loword() > 0; + + let events = self + .state + .a11y + .try_borrow_mut() + .ok() + .and_then(|mut a11y| a11y.as_mut()?.adapter.update_window_focus_state(activated)); + if let Some(events) = events { + events.raise(); + } + let this = self.clone(); if !activated { @@ -764,6 +776,23 @@ impl WindowsWindowInner { None } + fn handle_wm_getobject(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + let result = { + let mut a11y = self.state.a11y.borrow_mut(); + let a11y = a11y.as_mut()?; + a11y.adapter.handle_wm_getobject( + accesskit_windows::WPARAM(wparam.0), + accesskit_windows::LPARAM(lparam.0), + &mut a11y.activation_handler, + )? + }; + // The borrow above must be dropped before calling `.into()`, because + // it calls `UiaReturnRawElementProvider` which may send a nested + // `WM_GETOBJECT` back into this window procedure. + let lresult: accesskit_windows::LRESULT = result.into(); + Some(lresult.0) + } + fn handle_create_msg(&self, handle: HWND) -> Option { if self.hide_title_bar { notify_frame_changed(handle); diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index d40c7241bd0..89c12489b24 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -854,6 +854,10 @@ struct Shadow { Corners corner_radii; Bounds content_mask; Hsla color; + Bounds element_bounds; + Corners element_corner_radii; + uint inset; + uint pad; // align to 8 bytes }; struct ShadowVertexOutput { @@ -875,10 +879,16 @@ ShadowVertexOutput shadow_vertex(uint vertex_id: SV_VertexID, uint shadow_id: SV float2 unit_vertex = float2(float(vertex_id & 1u), 0.5 * float(vertex_id & 2u)); Shadow shadow = shadows[shadow_id]; - float margin = 3.0 * shadow.blur_radius; - Bounds bounds = shadow.bounds; - bounds.origin -= margin; - bounds.size += 2.0 * margin; + Bounds bounds; + if (shadow.inset != 0u) { + bounds = shadow.element_bounds; + } else { + // Leave room for the gaussian tail outside the shadow rect. + float margin = 3.0 * shadow.blur_radius; + bounds = shadow.bounds; + bounds.origin -= margin; + bounds.size += 2.0 * margin; + } float4 device_position = to_device_position(unit_vertex, bounds); float4 clip_distance = distance_from_clip_rect(unit_vertex, bounds, shadow.content_mask); @@ -901,21 +911,36 @@ float4 shadow_fragment(ShadowFragmentInput input): SV_TARGET { float2 point0 = input.position.xy - center; float corner_radius = pick_corner_radius(point0, shadow.corner_radii); - // The signal is only non-zero in a limited range, so don't waste samples - float low = point0.y - half_size.y; - float high = point0.y + half_size.y; - float start = clamp(-3. * shadow.blur_radius, low, high); - float end = clamp(3. * shadow.blur_radius, low, high); + float alpha; + if (shadow.blur_radius == 0.) { + float distance = quad_sdf(input.position.xy, shadow.bounds, shadow.corner_radii); + alpha = saturate(0.5 - distance); + } else { + // The signal is only non-zero in a limited range, so don't waste samples + float low = point0.y - half_size.y; + float high = point0.y + half_size.y; + float start = clamp(-3. * shadow.blur_radius, low, high); + float end = clamp(3. * shadow.blur_radius, low, high); - // Accumulate samples (we can get away with surprisingly few samples) - float step = (end - start) / 4.; - float y = start + step * 0.5; - float alpha = 0.; - for (int i = 0; i < 4; i++) { - alpha += blur_along_x(point0.x, point0.y - y, shadow.blur_radius, - corner_radius, half_size) * - gaussian(y, shadow.blur_radius) * step; - y += step; + // Accumulate samples (we can get away with surprisingly few samples) + float step = (end - start) / 4.; + float y = start + step * 0.5; + alpha = 0.; + for (int i = 0; i < 4; i++) { + alpha += blur_along_x(point0.x, point0.y - y, shadow.blur_radius, + corner_radius, half_size) * + gaussian(y, shadow.blur_radius) * step; + y += step; + } + } + + if (shadow.inset != 0u) { + // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. + // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. + alpha = 1.0 - alpha; + float element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); } return input.color * float4(1., 1., 1., alpha); diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 178d750024f..b33d8702f2f 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -83,6 +83,7 @@ pub struct WindowsWindowState { fullscreen: Cell>, initial_placement: Cell>, hwnd: HWND, + pub(crate) a11y: RefCell>, } pub(crate) struct WindowsWindowInner { @@ -176,6 +177,7 @@ impl WindowsWindowState { hwnd, invalidate_devices, direct_manipulation, + a11y: RefCell::new(None), }) } @@ -972,6 +974,69 @@ impl PlatformWindow for WindowsWindow { // MB_OK: The sound specified as the Windows Default Beep sound. let _ = unsafe { MessageBeep(MB_OK) }; } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let action_handler = A11yActionHandler(callbacks.action); + let is_focused = unsafe { GetForegroundWindow() } == self.0.hwnd; + + let adapter = accesskit_windows::Adapter::new( + accesskit_windows::HWND(self.0.hwnd.0), + is_focused, + action_handler, + ); + + let activation_handler = A11yActivationHandler { + callback: callbacks.activation, + }; + + *self.state.a11y.borrow_mut() = Some(A11yState { + adapter, + activation_handler, + }); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let events = { + let mut a11y = self.state.a11y.borrow_mut(); + a11y.as_mut() + .and_then(|a11y| a11y.adapter.update_if_active(|| tree_update)) + }; + // The borrow must be dropped before raising events, because + // `events.raise()` calls `UiaRaiseAutomationPropertyChangedEvent` + // which may send a nested `WM_GETOBJECT` back into this window + // procedure, re-entering `handle_wm_getobject` which also borrows + // `self.state.a11y`. + if let Some(events) = events { + events.raise(); + } + } + + fn a11y_update_window_bounds(&self) { + // Windows UIA handles window bounds tracking automatically. + } +} + +pub(crate) struct A11yState { + pub(crate) adapter: accesskit_windows::Adapter, + pub(crate) activation_handler: A11yActivationHandler, +} + +pub(crate) struct A11yActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } } #[implement(IDropTarget)] diff --git a/crates/grammars/src/cpp/config.toml b/crates/grammars/src/cpp/config.toml index 09672742c5b..b0992c5c120 100644 --- a/crates/grammars/src/cpp/config.toml +++ b/crates/grammars/src/cpp/config.toml @@ -1,6 +1,6 @@ name = "C++" grammar = "cpp" -path_suffixes = ["cc", "ccm", "hh", "cpp", "cppm", "h", "hpp", "cxx", "cxxm", "hxx", "c++", "c++m", "h++", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"] +path_suffixes = ["cc", "ccm", "hh", "cpp", "cppm", "h", "hpp", "cxx", "cxxm", "hxx", "c++", "c++m", "h++", "hip", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"] modeline_aliases = ["c++", "cpp", "cxx"] line_comments = ["// ", "/// ", "//! "] first_line_pattern = '^//.*-\*-\s*C\+\+\s*-\*-' diff --git a/crates/grammars/src/cpp/runnables.scm b/crates/grammars/src/cpp/runnables.scm new file mode 100644 index 00000000000..fe540534793 --- /dev/null +++ b/crates/grammars/src/cpp/runnables.scm @@ -0,0 +1,6 @@ +; Tag the main function +((function_definition + declarator: (function_declarator + declarator: (identifier) @run)) @_cpp-main + (#eq? @run "main") + (#set! tag cpp-main)) diff --git a/crates/grammars/src/javascript/brackets.scm b/crates/grammars/src/javascript/brackets.scm index 69acbcd614e..a5f51bbbb11 100644 --- a/crates/grammars/src/javascript/brackets.scm +++ b/crates/grammars/src/javascript/brackets.scm @@ -7,14 +7,17 @@ ("{" @open "}" @close) -("<" @open +(("<" @open ">" @close) + (#set! rainbow.exclude)) -("<" @open +(("<" @open "/>" @close) + (#set! rainbow.exclude)) -("" @close) + (#set! rainbow.exclude)) (("\"" @open "\"" @close) diff --git a/crates/grammars/src/markdown/injections.scm b/crates/grammars/src/markdown/injections.scm index 46717b28a97..1b803b708e5 100644 --- a/crates/grammars/src/markdown/injections.scm +++ b/crates/grammars/src/markdown/injections.scm @@ -6,6 +6,9 @@ ((inline) @injection.content (#set! injection.language "markdown-inline")) +((pipe_table_cell) @injection.content + (#set! injection.language "markdown-inline")) + ((html_block) @injection.content (#set! injection.language "html")) diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index d67c4f76e62..d71fc42859f 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -46,6 +46,7 @@ pub enum IconName { BellOff, BellRing, Binary, + Bitbucket, Blocks, Bookmark, BoltFilled, @@ -70,6 +71,7 @@ pub enum IconName { Close, CloudDownload, Code, + Codeberg, Command, Control, Copilot, @@ -140,6 +142,7 @@ pub enum IconName { Font, FontSize, FontWeight, + Forgejo, ForwardArrow, ForwardArrowUp, GenericClose, @@ -152,7 +155,9 @@ pub enum IconName { GitGraph, GitMergeConflict, GitWorktree, + Gitea, Github, + Gitlab, Hash, HistoryRerun, Image, @@ -218,6 +223,7 @@ pub enum IconName { Send, Server, Settings, + Share, Shift, SignalHigh, SignalLow, @@ -238,6 +244,8 @@ pub enum IconName { Terminal, TerminalAlt, TextSnippet, + TextWrap, + TextUnwrap, ThinkingMode, ThinkingModeOff, Thread, diff --git a/crates/install_cli/src/install_cli_binary.rs b/crates/install_cli/src/install_cli_binary.rs index 095ed3cd315..4c6d8cde40c 100644 --- a/crates/install_cli/src/install_cli_binary.rs +++ b/crates/install_cli/src/install_cli_binary.rs @@ -1,101 +1,101 @@ -use super::register_zed_scheme; -use anyhow::{Context as _, Result}; -use gpui::{AppContext as _, AsyncApp, Context, PromptLevel, Window, actions}; -use release_channel::ReleaseChannel; -use std::ops::Deref; -use std::path::{Path, PathBuf}; -use util::ResultExt; -use workspace::notifications::{DetachAndPromptErr, NotificationId}; -use workspace::{Toast, Workspace}; - -actions!( - cli, - [ - /// Installs the Zed CLI tool to the system PATH. - InstallCliBinary, - ] -); - -async fn install_script(cx: &AsyncApp) -> Result { - let cli_path = cx.update(|cx| cx.path_for_auxiliary_executable("cli"))?; - let link_path = Path::new("/usr/local/bin/zed"); - let bin_dir_path = link_path.parent().unwrap(); - - // Don't re-create symlink if it points to the same CLI binary. - if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) { - return Ok(link_path.into()); - } - - // If the symlink is not there or is outdated, first try replacing it - // without escalating. - smol::fs::remove_file(link_path).await.log_err(); - if smol::fs::unix::symlink(&cli_path, link_path) - .await - .log_err() - .is_some() - { - return Ok(link_path.into()); - } - - // The symlink could not be created, so use osascript with admin privileges - // to create it. - let status = smol::process::Command::new("/usr/bin/osascript") - .args([ - "-e", - &format!( - "do shell script \" \ - mkdir -p \'{}\' && \ - ln -sf \'{}\' \'{}\' \ - \" with administrator privileges", - bin_dir_path.to_string_lossy(), - cli_path.to_string_lossy(), - link_path.to_string_lossy(), - ), - ]) - .stdout(smol::process::Stdio::inherit()) - .stderr(smol::process::Stdio::inherit()) - .output() - .await? - .status; - anyhow::ensure!(status.success(), "error running osascript"); - Ok(link_path.into()) -} - -pub fn install_cli_binary(window: &mut Window, cx: &mut Context) { - const LINUX_PROMPT_DETAIL: &str = "If you installed Zed from our official release add ~/.local/bin to your PATH.\n\nIf you installed Zed from a different source like your package manager, then you may need to create an alias/symlink manually.\n\nDepending on your package manager, the CLI might be named zeditor, zedit, zed-editor or something else."; - - cx.spawn_in(window, async move |workspace, cx| { - if cfg!(any(target_os = "linux", target_os = "freebsd")) { - let prompt = cx.prompt( - PromptLevel::Warning, - "CLI should already be installed", - Some(LINUX_PROMPT_DETAIL), - &["Ok"], - ); - cx.background_spawn(prompt).detach(); - return Ok(()); - } - let path = install_script(cx.deref()) - .await - .context("error creating CLI symlink")?; - - workspace.update_in(cx, |workspace, _, cx| { - struct InstalledZedCli; - - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - format!( - "Installed `zed` to {}. You can launch {} from your terminal.", - path.to_string_lossy(), - ReleaseChannel::global(cx).display_name() - ), - ), - cx, - ) - })?; - register_zed_scheme(cx).await.log_err(); - Ok(()) - }) - .detach_and_prompt_err("Error installing zed cli", window, cx, |_, _, _| None); -} +use super::register_zed_scheme; +use anyhow::{Context as _, Result}; +use gpui::{AppContext as _, AsyncApp, Context, PromptLevel, Window, actions}; +use release_channel::ReleaseChannel; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use util::ResultExt; +use workspace::notifications::{DetachAndPromptErr, NotificationId}; +use workspace::{Toast, Workspace}; + +actions!( + cli, + [ + /// Installs the Zed CLI tool to the system PATH. + InstallCliBinary, + ] +); + +async fn install_script(cx: &AsyncApp) -> Result { + let cli_path = cx.update(|cx| cx.path_for_auxiliary_executable("cli"))?; + let link_path = Path::new("/usr/local/bin/zed"); + let bin_dir_path = link_path.parent().unwrap(); + + // Don't re-create symlink if it points to the same CLI binary. + if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) { + return Ok(link_path.into()); + } + + // If the symlink is not there or is outdated, first try replacing it + // without escalating. + smol::fs::remove_file(link_path).await.log_err(); + if smol::fs::unix::symlink(&cli_path, link_path) + .await + .log_err() + .is_some() + { + return Ok(link_path.into()); + } + + // The symlink could not be created, so use osascript with admin privileges + // to create it. + let status = smol::process::Command::new("/usr/bin/osascript") + .args([ + "-e", + &format!( + "do shell script \" \ + mkdir -p \'{}\' && \ + ln -sf \'{}\' \'{}\' \ + \" with administrator privileges", + bin_dir_path.to_string_lossy(), + cli_path.to_string_lossy(), + link_path.to_string_lossy(), + ), + ]) + .stdout(smol::process::Stdio::inherit()) + .stderr(smol::process::Stdio::inherit()) + .output() + .await? + .status; + anyhow::ensure!(status.success(), "error running osascript"); + Ok(link_path.into()) +} + +pub fn install_cli_binary(window: &mut Window, cx: &mut Context) { + const LINUX_PROMPT_DETAIL: &str = "If you installed Zed from our official release add ~/.local/bin to your PATH.\n\nIf you installed Zed from a different source like your package manager, then you may need to create an alias/symlink manually.\n\nDepending on your package manager, the CLI might be named zeditor, zedit, zed-editor or something else."; + + cx.spawn_in(window, async move |workspace, cx| { + if cfg!(any(target_os = "linux", target_os = "freebsd")) { + let prompt = cx.prompt( + PromptLevel::Warning, + "CLI should already be installed", + Some(LINUX_PROMPT_DETAIL), + &["Ok"], + ); + cx.background_spawn(prompt).detach(); + return Ok(()); + } + let path = install_script(cx.deref()) + .await + .context("error creating CLI symlink")?; + + workspace.update_in(cx, |workspace, _, cx| { + struct InstalledZedCli; + + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + format!( + "Installed `zed` to {}. You can launch {} from your terminal.", + path.to_string_lossy(), + ReleaseChannel::global(cx).display_name() + ), + ), + cx, + ) + })?; + register_zed_scheme(cx).await.log_err(); + Ok(()) + }) + .detach_and_prompt_err("Error installing zed cli", window, cx, |_, _, _| None); +} diff --git a/crates/install_cli/src/register_zed_scheme.rs b/crates/install_cli/src/register_zed_scheme.rs index 5dac3ef5d8e..048b2dd50ab 100644 --- a/crates/install_cli/src/register_zed_scheme.rs +++ b/crates/install_cli/src/register_zed_scheme.rs @@ -1,14 +1,14 @@ -use client::ZED_URL_SCHEME; -use gpui::{AsyncApp, actions}; - -actions!( - cli, - [ - /// Registers the zed:// URL scheme handler. - RegisterZedScheme - ] -); - -pub async fn register_zed_scheme(cx: &AsyncApp) -> anyhow::Result<()> { - cx.update(|cx| cx.register_url_scheme(ZED_URL_SCHEME)).await -} +use client::ZED_URL_SCHEME; +use gpui::{AsyncApp, actions}; + +actions!( + cli, + [ + /// Registers the zed:// URL scheme handler. + RegisterZedScheme + ] +); + +pub async fn register_zed_scheme(cx: &AsyncApp) -> anyhow::Result<()> { + cx.update(|cx| cx.register_url_scheme(ZED_URL_SCHEME)).await +} diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index ec3b4327e1b..310788bc6b1 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -297,6 +297,19 @@ pub enum Operation { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BufferEditSource { + User, + Agent, + Remote, +} + +impl BufferEditSource { + pub fn is_local(self) -> bool { + !matches!(self, Self::Remote) + } +} + /// An event that occurs in a buffer. #[derive(Clone, Debug, PartialEq)] pub enum BufferEvent { @@ -307,7 +320,7 @@ pub enum BufferEvent { is_local: bool, }, /// The buffer was edited. - Edited { is_local: bool }, + Edited { source: BufferEditSource }, /// The buffer's `dirty` bit changed. DirtyChanged, /// The buffer was saved. @@ -2433,6 +2446,14 @@ impl Buffer { self.end_transaction_at(Instant::now(), cx) } + pub fn end_transaction_with_source( + &mut self, + source: BufferEditSource, + cx: &mut Context, + ) -> Option { + self.end_transaction_at_internal(Instant::now(), source, cx) + } + /// Terminates the current transaction, providing the current time. Subsequent transactions /// that occur within a short period of time will be grouped together. This /// is controlled by the buffer's undo grouping duration. @@ -2440,6 +2461,15 @@ impl Buffer { &mut self, now: Instant, cx: &mut Context, + ) -> Option { + self.end_transaction_at_internal(now, BufferEditSource::User, cx) + } + + fn end_transaction_at_internal( + &mut self, + now: Instant, + source: BufferEditSource, + cx: &mut Context, ) -> Option { assert!(self.transaction_depth > 0); self.transaction_depth -= 1; @@ -2449,7 +2479,7 @@ impl Buffer { false }; if let Some((transaction_id, start_version)) = self.text.end_transaction_at(now) { - self.did_edit(&start_version, was_dirty, true, cx); + self.did_edit(&start_version, was_dirty, source, cx); Some(transaction_id) } else { None @@ -2844,7 +2874,7 @@ impl Buffer { &mut self, old_version: &clock::Global, was_dirty: bool, - is_local: bool, + source: BufferEditSource, cx: &mut Context, ) { self.was_changed(); @@ -2854,7 +2884,7 @@ impl Buffer { } self.reparse(cx, true); - cx.emit(BufferEvent::Edited { is_local }); + cx.emit(BufferEvent::Edited { source }); let is_dirty = self.is_dirty(); if was_dirty != is_dirty { cx.emit(BufferEvent::DirtyChanged); @@ -2976,7 +3006,7 @@ impl Buffer { self.text.apply_ops(buffer_ops); self.deferred_ops.insert(deferred_ops); self.flush_deferred_ops(cx); - self.did_edit(&old_version, was_dirty, false, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::Remote, cx); // Notify independently of whether the buffer was edited as the operations could include a // selection update. cx.notify(); @@ -3131,7 +3161,7 @@ impl Buffer { if let Some((transaction_id, operation)) = self.text.undo() { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); self.restore_encoding_for_transaction(transaction_id, was_dirty); Some(transaction_id) } else { @@ -3149,7 +3179,7 @@ impl Buffer { let old_version = self.version.clone(); if let Some(operation) = self.text.undo_transaction(transaction_id) { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); true } else { false @@ -3171,7 +3201,7 @@ impl Buffer { self.send_operation(Operation::Buffer(operation), true, cx); } if undone { - self.did_edit(&old_version, was_dirty, true, cx) + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx) } undone } @@ -3181,7 +3211,7 @@ impl Buffer { let operation = self.text.undo_operations(counts); let old_version = self.version.clone(); self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); } /// Manually redoes a specific transaction in the buffer's redo history. @@ -3191,7 +3221,7 @@ impl Buffer { if let Some((transaction_id, operation)) = self.text.redo() { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); self.restore_encoding_for_transaction(transaction_id, was_dirty); Some(transaction_id) } else { @@ -3232,7 +3262,7 @@ impl Buffer { self.send_operation(Operation::Buffer(operation), true, cx); } if redone { - self.did_edit(&old_version, was_dirty, true, cx) + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx) } redone } @@ -3342,7 +3372,7 @@ impl Buffer { if !ops.is_empty() { for op in ops { self.send_operation(Operation::Buffer(op), true, cx); - self.did_edit(&old_version, was_dirty, true, cx); + self.did_edit(&old_version, was_dirty, BufferEditSource::User, cx); } } } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index a13678a27d2..b46b3611a5d 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -38,7 +38,7 @@ pub static TRAILING_WHITESPACE_REGEX: LazyLock = LazyLock::new(|| }); #[cfg(test)] -#[ctor::ctor] +#[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } @@ -460,16 +460,24 @@ fn test_edit_events(cx: &mut gpui::App) { assert_eq!( mem::take(&mut *buffer_1_events.lock()), vec![ - BufferEvent::Edited { is_local: true }, + BufferEvent::Edited { + source: BufferEditSource::User + }, BufferEvent::DirtyChanged, - BufferEvent::Edited { is_local: true }, - BufferEvent::Edited { is_local: true }, + BufferEvent::Edited { + source: BufferEditSource::User + }, + BufferEvent::Edited { + source: BufferEditSource::User + }, ] ); assert_eq!( mem::take(&mut *buffer_2_events.lock()), vec![ - BufferEvent::Edited { is_local: false }, + BufferEvent::Edited { + source: BufferEditSource::Remote + }, BufferEvent::DirtyChanged ] ); @@ -487,14 +495,18 @@ fn test_edit_events(cx: &mut gpui::App) { assert_eq!( mem::take(&mut *buffer_1_events.lock()), vec![ - BufferEvent::Edited { is_local: true }, + BufferEvent::Edited { + source: BufferEditSource::User + }, BufferEvent::DirtyChanged, ] ); assert_eq!( mem::take(&mut *buffer_2_events.lock()), vec![ - BufferEvent::Edited { is_local: false }, + BufferEvent::Edited { + source: BufferEditSource::Remote + }, BufferEvent::DirtyChanged ] ); diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index cb19b5e6dbb..7182a0cdfe9 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -608,7 +608,7 @@ pub trait LspInstaller { type BinaryVersion; fn check_if_user_installed( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: Option, _: &AsyncApp, ) -> impl Future> { @@ -617,7 +617,7 @@ pub trait LspInstaller { fn fetch_latest_server_version( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, pre_release: bool, cx: &mut AsyncApp, ) -> impl Future>; @@ -684,7 +684,7 @@ where delegate.update_status(name.clone(), BinaryStatus::CheckingForUpdate); let latest_version = self - .fetch_latest_server_version(delegate.as_ref(), pre_release, cx) + .fetch_latest_server_version(delegate, pre_release, cx) .await?; if let Some(binary) = cx @@ -730,7 +730,7 @@ where // for each worktree we might have open. if binary_options.allow_path_lookup && let Some(binary) = self - .check_if_user_installed(delegate.as_ref(), toolchain, &mut cx) + .check_if_user_installed(&delegate, toolchain, &mut cx) .await { log::info!( @@ -1400,7 +1400,7 @@ impl LspInstaller for FakeLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -1409,7 +1409,7 @@ impl LspInstaller for FakeLspAdapter { async fn check_if_user_installed( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: Option, _: &AsyncApp, ) -> Option { diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 3d90d8d06e6..de67375cccc 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -477,7 +477,6 @@ pub struct EditPredictionSettings { /// Settings specific to Ollama. pub ollama: Option, pub open_ai_compatible_api: Option, - pub examples_dir: Option>, /// Controls whether training data collection is enabled. /// /// `Default` means the value stored in the legacy KV store is used as a fallback, @@ -911,7 +910,6 @@ impl settings::Settings for AllLanguageSettings { codestral: codestral_settings, ollama: ollama_settings, open_ai_compatible_api: openai_compatible_settings, - examples_dir: edit_predictions.examples_dir, allow_data_collection: edit_predictions.allow_data_collection.unwrap_or_default(), }, defaults: default_language_settings, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 3c8ca94d4b4..7dc237a65dd 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -289,6 +289,20 @@ pub trait LanguageModelProvider: 'static { cx: &mut App, ) -> AnyView; fn reset_credentials(&self, cx: &mut App) -> Task>; + + /// Copy shown the first time a user enables fast mode for a model from + /// this provider. Returning `None` skips the confirmation prompt and lets + /// the toggle apply silently. + fn fast_mode_confirmation(&self, _cx: &App) -> Option { + None + } +} + +/// Provider-specific copy shown the first time a user enables fast mode. +#[derive(Debug, Clone)] +pub struct FastModeConfirmation { + pub title: SharedString, + pub message: SharedString, } #[derive(Default, Clone, PartialEq, Eq)] diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index b28e6087e48..b4dedbbd7ba 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -1,8 +1,8 @@ use std::io::{Cursor, Write}; use std::sync::Arc; -use anyhow::Result; -use base64::write::EncoderWriter; +use anyhow::{Result, anyhow}; +use base64::{Engine as _, write::EncoderWriter}; use gpui::{ App, AppContext as _, DevicePixels, Image, ImageFormat, ObjectFit, Size, Task, point, px, size, }; @@ -29,6 +29,7 @@ const MAX_IMAGE_DOWNSCALE_PASSES: usize = 8; pub trait LanguageModelImageExt { const FORMAT: ImageFormat; fn from_image(data: Arc, cx: &mut App) -> Task>; + fn from_base64_image(data: &str, mime_type: &str) -> Result>; } impl LanguageModelImageExt for LanguageModelImage { @@ -36,99 +37,104 @@ impl LanguageModelImageExt for LanguageModelImage { fn from_image(data: Arc, cx: &mut App) -> Task> { cx.background_spawn(async move { - let image_bytes = Cursor::new(data.bytes()); - let dynamic_image = match data.format() { - ImageFormat::Png => image::codecs::png::PngDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Jpeg => image::codecs::jpeg::JpegDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Webp => image::codecs::webp::WebPDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Gif => image::codecs::gif::GifDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Bmp => image::codecs::bmp::BmpDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - ImageFormat::Tiff => image::codecs::tiff::TiffDecoder::new(image_bytes) - .and_then(image::DynamicImage::from_decoder), - _ => return None, - } - .log_err()?; - - let width = dynamic_image.width(); - let height = dynamic_image.height(); - let image_size = size(DevicePixels(width as i32), DevicePixels(height as i32)); - - // First apply any provider-specific dimension constraints we know about (Anthropic). - let mut processed_image = if image_size.width.0 > ANTHROPIC_SIZE_LIMIT as i32 - || image_size.height.0 > ANTHROPIC_SIZE_LIMIT as i32 - { - let new_bounds = ObjectFit::ScaleDown.get_bounds( - gpui::Bounds { - origin: point(px(0.0), px(0.0)), - size: size(px(ANTHROPIC_SIZE_LIMIT), px(ANTHROPIC_SIZE_LIMIT)), - }, - image_size, - ); - dynamic_image.resize( - new_bounds.size.width.into(), - new_bounds.size.height.into(), - image::imageops::FilterType::Triangle, - ) - } else { - dynamic_image + let format = match data.format() { + ImageFormat::Png => image::ImageFormat::Png, + ImageFormat::Jpeg => image::ImageFormat::Jpeg, + ImageFormat::Webp => image::ImageFormat::WebP, + ImageFormat::Gif => image::ImageFormat::Gif, + ImageFormat::Bmp => image::ImageFormat::Bmp, + ImageFormat::Tiff => image::ImageFormat::Tiff, + ImageFormat::Ico => image::ImageFormat::Ico, + ImageFormat::Pnm => image::ImageFormat::Pnm, + ImageFormat::Svg => return None, }; - - // Then enforce a default per-image size cap on the encoded PNG bytes. - // - // We always send PNG bytes (either original PNG bytes, or re-encoded PNG) base64'd. - // The upstream provider limit we want to respect is effectively on the binary image - // payload size, so we enforce against the encoded PNG bytes before base64 encoding. - let mut encoded_png = encode_png_bytes(&processed_image).log_err()?; - for _pass in 0..MAX_IMAGE_DOWNSCALE_PASSES { - if encoded_png.len() <= DEFAULT_IMAGE_MAX_BYTES { - break; - } - - // Scale down geometrically to converge quickly. We don't know the final PNG size - // as a function of pixels, so we iteratively shrink. - let (w, h) = processed_image.dimensions(); - if w <= 1 || h <= 1 { - break; - } - - // Shrink by ~15% each pass (0.85). This is a compromise between speed and - // preserving image detail. - let new_w = ((w as f32) * 0.85).round().max(1.0) as u32; - let new_h = ((h as f32) * 0.85).round().max(1.0) as u32; - - processed_image = - processed_image.resize(new_w, new_h, image::imageops::FilterType::Triangle); - encoded_png = encode_png_bytes(&processed_image).log_err()?; - } - - if encoded_png.len() > DEFAULT_IMAGE_MAX_BYTES { - // Still too large after multiple passes; treat as non-convertible for now. - // (Provider-specific handling can be introduced later.) - return None; - } - - // Now base64 encode the PNG bytes. - let base64_image = encode_bytes_as_base64(encoded_png.as_slice()).log_err()?; - - // SAFETY: The base64 encoder should not produce non-UTF8. - let source = unsafe { String::from_utf8_unchecked(base64_image) }; - - let (final_width, final_height) = processed_image.dimensions(); - - Some(LanguageModelImage { - size: Some(ImageSize { - width: final_width as i32, - height: final_height as i32, - }), - source: source.into(), - }) + let dynamic_image = + image::load_from_memory_with_format(data.bytes(), format).log_err()?; + language_model_image_from_dynamic_image(dynamic_image) + .log_err() + .flatten() }) } + + fn from_base64_image(data: &str, mime_type: &str) -> Result> { + let format = image::ImageFormat::from_mime_type(mime_type) + .ok_or_else(|| anyhow!("unsupported image MIME type `{}`", mime_type))?; + let bytes = base64::engine::general_purpose::STANDARD.decode(data.as_bytes())?; + let dynamic_image = image::load_from_memory_with_format(&bytes, format)?; + language_model_image_from_dynamic_image(dynamic_image) + } +} + +fn language_model_image_from_dynamic_image( + dynamic_image: image::DynamicImage, +) -> Result> { + let width = dynamic_image.width(); + let height = dynamic_image.height(); + let image_size = size(DevicePixels(width as i32), DevicePixels(height as i32)); + + // First apply any provider-specific dimension constraints we know about (Anthropic). + let mut processed_image = if image_size.width.0 > ANTHROPIC_SIZE_LIMIT as i32 + || image_size.height.0 > ANTHROPIC_SIZE_LIMIT as i32 + { + let new_bounds = ObjectFit::ScaleDown.get_bounds( + gpui::Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(ANTHROPIC_SIZE_LIMIT), px(ANTHROPIC_SIZE_LIMIT)), + }, + image_size, + ); + dynamic_image.resize( + new_bounds.size.width.into(), + new_bounds.size.height.into(), + image::imageops::FilterType::Triangle, + ) + } else { + dynamic_image + }; + + // Then enforce a default per-image size cap on the encoded PNG bytes. + // + // We always send PNG bytes (either original PNG bytes, or re-encoded PNG) base64'd. + // The upstream provider limit we want to respect is effectively on the binary image + // payload size, so we enforce against the encoded PNG bytes before base64 encoding. + let mut encoded_png = encode_png_bytes(&processed_image)?; + for _pass in 0..MAX_IMAGE_DOWNSCALE_PASSES { + if encoded_png.len() <= DEFAULT_IMAGE_MAX_BYTES { + break; + } + + // Scale down geometrically to converge quickly. We don't know the final PNG size + // as a function of pixels, so we iteratively shrink. + let (width, height) = processed_image.dimensions(); + if width <= 1 || height <= 1 { + break; + } + + // Shrink by ~15% each pass (0.85). This is a compromise between speed and + // preserving image detail. + let new_width = ((width as f32) * 0.85).round().max(1.0) as u32; + let new_height = ((height as f32) * 0.85).round().max(1.0) as u32; + + processed_image = + processed_image.resize(new_width, new_height, image::imageops::FilterType::Triangle); + encoded_png = encode_png_bytes(&processed_image)?; + } + + if encoded_png.len() > DEFAULT_IMAGE_MAX_BYTES { + // Still too large after multiple passes; treat as non-convertible for now. + // (Provider-specific handling can be introduced later.) + return Ok(None); + } + + // Now base64 encode the PNG bytes. + let base64_image = encode_bytes_as_base64(encoded_png.as_slice())?; + + // SAFETY: The base64 encoder should not produce non-UTF8. + let source = unsafe { String::from_utf8_unchecked(base64_image) }; + + Ok(Some(LanguageModelImage { + source: source.into(), + })) } fn encode_png_bytes(image: &image::DynamicImage) -> Result> { @@ -168,7 +174,6 @@ pub fn gpui_size_to_image_size(size: Size) -> ImageSize { #[cfg(test)] mod tests { use super::*; - use base64::Engine as _; use gpui::TestAppContext; fn base64_to_png_bytes(base64: &str) -> Vec { @@ -208,13 +213,46 @@ mod tests { raw_png.len() ); - let image = Arc::new(gpui::Image::from_bytes(ImageFormat::Png, raw_png)); + let image = Arc::new(gpui::Image::from_bytes(ImageFormat::Png, raw_png.clone())); let lm_image = cx .update(|cx| LanguageModelImage::from_image(Arc::clone(&image), cx)) .await .expect("from_image should succeed"); - let decoded_png = base64_to_png_bytes(lm_image.source.as_ref()); + assert_downscaled_from_original(lm_image.source.as_ref(), 4096, 4096); + + let base64_png = base64::engine::general_purpose::STANDARD.encode(raw_png); + let lm_image = LanguageModelImage::from_base64_image(&base64_png, "image/png") + .expect("from_base64_image should not error") + .expect("from_base64_image should succeed"); + + assert_downscaled_from_original(lm_image.source.as_ref(), 4096, 4096); + } + + #[test] + fn test_from_base64_image_converts_jpeg_to_png() { + use image::ImageEncoder as _; + + let mut jpeg_bytes = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut jpeg_bytes) + .write_image(&[255, 0, 0], 1, 1, image::ExtendedColorType::Rgb8) + .expect("encode jpeg"); + let jpeg_data = base64::engine::general_purpose::STANDARD.encode(jpeg_bytes); + + let image = LanguageModelImage::from_base64_image(&jpeg_data, "image/jpeg") + .expect("from_base64_image should not error") + .expect("from_base64_image should succeed"); + let png_bytes = base64_to_png_bytes(image.source.as_ref()); + + assert_eq!( + image::guess_format(&png_bytes).expect("guess image format"), + image::ImageFormat::Png + ); + assert_eq!(png_dimensions(&png_bytes), (1, 1)); + } + + fn assert_downscaled_from_original(base64_png: &str, width: u32, height: u32) { + let decoded_png = base64_to_png_bytes(base64_png); assert!( decoded_png.len() <= DEFAULT_IMAGE_MAX_BYTES, "Encoded PNG should be ≤ {} bytes after downscale, but was {} bytes", @@ -222,22 +260,12 @@ mod tests { decoded_png.len() ); - let (w, h) = png_dimensions(&decoded_png); + let (downsized_width, downsized_height) = png_dimensions(&decoded_png); assert!( - w < 4096 && h < 4096, + downsized_width < width && downsized_height < height, "Dimensions should have shrunk: got {}×{}", - w, - h - ); - - let size = lm_image.size.expect("ImageSize should be present"); - assert_eq!( - size.width, w as i32, - "ImageSize.width should match the encoded PNG width after downscaling" - ); - assert_eq!( - size.height, h as i32, - "ImageSize.height should match the encoded PNG height after downscaling" + downsized_width, + downsized_height ); } } diff --git a/crates/language_model_core/Cargo.toml b/crates/language_model_core/Cargo.toml index e9aa06400b6..c254989b4d5 100644 --- a/crates/language_model_core/Cargo.toml +++ b/crates/language_model_core/Cargo.toml @@ -19,6 +19,7 @@ cloud_llm_client.workspace = true futures.workspace = true gpui_shared_string.workspace = true http_client.workspace = true +log.workspace = true partial-json-fixer.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index b2b42c091bc..867d087ab31 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -16,8 +16,6 @@ pub struct ImageSize { pub struct LanguageModelImage { /// A base64-encoded PNG image. pub source: SharedString, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub size: Option, } impl LanguageModelImage { @@ -30,59 +28,26 @@ impl LanguageModelImage { } pub fn empty() -> Self { - Self { - source: "".into(), - size: None, - } + Self { source: "".into() } } /// Parse Self from a JSON object with case-insensitive field names pub fn from_json(obj: &serde_json::Map) -> Option { let mut source = None; - let mut size_obj = None; for (k, v) in obj.iter() { match k.to_lowercase().as_str() { "source" => source = v.as_str(), - "size" => size_obj = v.as_object(), _ => {} } } let source = source?; - let size_obj = size_obj?; - - let mut width = None; - let mut height = None; - - for (k, v) in size_obj.iter() { - match k.to_lowercase().as_str() { - "width" => width = v.as_i64().map(|w| w as i32), - "height" => height = v.as_i64().map(|h| h as i32), - _ => {} - } - } - Some(Self { - size: Some(ImageSize { - width: width?, - height: height?, - }), source: SharedString::from(source.to_string()), }) } - pub fn estimate_tokens(&self) -> usize { - let Some(size) = self.size.as_ref() else { - return 0; - }; - let width = size.width.unsigned_abs() as usize; - let height = size.height.unsigned_abs() as usize; - - // From: https://docs.anthropic.com/en/docs/build-with-claude/vision#calculate-image-costs - (width * height) / 750 - } - pub fn to_base64_url(&self) -> String { format!("data:image/png;base64,{}", self.source) } @@ -92,7 +57,6 @@ impl std::fmt::Debug for LanguageModelImage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LanguageModelImage") .field("source", &format!("<{} bytes>", self.source.len())) - .field("size", &self.size) .finish() } } @@ -329,7 +293,7 @@ pub struct LanguageModelRequestMessage { pub content: Vec, pub cache: bool, #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_details: Option, + pub reasoning_details: Option>, } impl LanguageModelRequestMessage { @@ -474,15 +438,11 @@ mod tests { // Test image object let json = serde_json::json!({ "source": "base64encodedimagedata", - "size": {"width": 100, "height": 200} }); let content: LanguageModelToolResultContent = serde_json::from_value(json).unwrap(); match content { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "base64encodedimagedata"); - let size = image.size.expect("size"); - assert_eq!(size.width, 100); - assert_eq!(size.height, 200); } _ => panic!("Expected Image variant"), } @@ -491,16 +451,12 @@ mod tests { let json = serde_json::json!({ "image": { "source": "wrappedimagedata", - "size": {"width": 50, "height": 75} } }); let content: LanguageModelToolResultContent = serde_json::from_value(json).unwrap(); match content { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "wrappedimagedata"); - let size = image.size.expect("size"); - assert_eq!(size.width, 50); - assert_eq!(size.height, 75); } _ => panic!("Expected Image variant"), } @@ -508,15 +464,11 @@ mod tests { // Test case insensitive let json = serde_json::json!({ "Source": "caseinsensitive", - "Size": {"Width": 30, "Height": 40} }); let content: LanguageModelToolResultContent = serde_json::from_value(json).unwrap(); match content { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "caseinsensitive"); - let size = image.size.expect("size"); - assert_eq!(size.width, 30); - assert_eq!(size.height, 40); } _ => panic!("Expected Image variant"), } @@ -524,15 +476,11 @@ mod tests { // Test direct image object let json = serde_json::json!({ "source": "directimage", - "size": {"width": 200, "height": 300} }); let content: LanguageModelToolResultContent = serde_json::from_value(json).unwrap(); match content { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "directimage"); - let size = image.size.expect("size"); - assert_eq!(size.width, 200); - assert_eq!(size.height, 300); } _ => panic!("Expected Image variant"), } diff --git a/crates/language_model_core/src/tool_schema.rs b/crates/language_model_core/src/tool_schema.rs index 86e6d6d137e..13e6e665244 100644 --- a/crates/language_model_core/src/tool_schema.rs +++ b/crates/language_model_core/src/tool_schema.rs @@ -4,7 +4,7 @@ use schemars::{ generate::SchemaSettings, transform::{Transform, transform_subschemas}, }; -use serde_json::Value; +use serde_json::{Map, Value, json}; /// Indicates the format used to define the input schema for a language model tool. #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] @@ -39,21 +39,31 @@ struct ToJsonSchemaSubsetTransform; impl Transform for ToJsonSchemaSubsetTransform { fn transform(&mut self, schema: &mut Schema) { - // Ensure that the type field is not an array, this happens when we use - // Option, the type will be [T, "null"]. - if let Some(type_field) = schema.get_mut("type") - && let Some(types) = type_field.as_array() - && let Some(first_type) = types.first() - { - *type_field = first_type.clone(); - } + if let Some(obj) = schema.as_object_mut() { + // `Option` produces `type: [T, "null"]`. Convert to OpenAPI 3.0's + // `nullable: true` so nullability isn't silently dropped. + convert_null_in_types_to_nullable(obj); - // oneOf is not supported, use anyOf instead - if let Some(one_of) = schema.remove("oneOf") { - schema.insert("anyOf".to_string(), one_of); + // Any remaining multi-type array (uncommon in Rust-generated schemas) + // is collapsed to its first entry to keep this schema subset-compatible. + if let Some(type_field) = obj.get_mut("type") + && let Some(types) = type_field.as_array() + && let Some(first_type) = types.first().cloned() + { + *type_field = first_type; + } + + // oneOf is not supported, use anyOf instead + if let Some(one_of) = obj.remove("oneOf") { + obj.insert("anyOf".to_string(), one_of); + } } transform_subschemas(self, schema); + + if let Some(obj) = schema.as_object_mut() { + collapse_nullable_only_any_of(obj); + } } } @@ -64,6 +74,8 @@ pub fn adapt_schema_to_format( json: &mut Value, format: LanguageModelToolSchemaFormat, ) -> Result<()> { + log::trace!("Adapting schema to format {:?}: {}", format, json); + if let Value::Object(obj) = json { obj.remove("$schema"); obj.remove("title"); @@ -73,7 +85,10 @@ pub fn adapt_schema_to_format( match format { LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json), LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json), - } + }?; + + log::trace!("Adapted schema: {}", json); + Ok(()) } fn preprocess_json_schema(json: &mut Value) -> Result<()> { @@ -118,8 +133,12 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { } } - // Ensure that the type field is not an array. This can happen with MCP tool - // schemas that use multiple types (e.g. `["string", "number"]` or `["string", "null"]`). + convert_null_in_types_to_nullable(obj); + convert_types_to_any_of_defs(obj); + + // After the conversions above, `type` should only ever still be an array + // if a malformed input had a single-element type array (e.g. `["string"]`). + // Collapse it to a single value so downstream consumers see a scalar. if let Some(type_value) = obj.get_mut("type") && let Some(types) = type_value.as_array() && let Some(first_type) = types.first().cloned() @@ -141,7 +160,7 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { { let subschemas_clone = subschemas.clone(); obj.remove("oneOf"); - obj.insert("anyOf".to_string(), subschemas_clone); + push_any_of_constraint(obj, subschemas_clone); } for (_, value) in obj.iter_mut() { @@ -149,6 +168,11 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { adapt_to_json_schema_subset(value)?; } } + + // Children may have been rewritten from `{"type": "null"}` into + // `{"nullable": true}`. Fold those into the parent so the result matches + // OpenAPI 3.0's convention of `nullable: true` as a sibling of `type`. + collapse_nullable_only_any_of(obj); } else if let Value::Array(arr) = json { for item in arr.iter_mut() { adapt_to_json_schema_subset(item)?; @@ -157,11 +181,292 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { Ok(()) } +fn convert_null_in_types_to_nullable(obj: &mut Map) { + let mut nullable_found_in_type = false; + + if let Some(type_entry) = obj.get_mut("type") { + if let Some(types) = type_entry.as_array_mut() { + let mut had_null_type = false; + types.retain(|t| { + if t.as_str() == Some("null") { + had_null_type = true; + false + } else { + true + } + }); + + if had_null_type { + nullable_found_in_type = true; + if types.len() == 1 { + *type_entry = types.remove(0); + } else if types.is_empty() { + obj.remove("type"); + } + } + } else if let Some(type_str) = type_entry.as_str() { + if type_str == "null" { + nullable_found_in_type = true; + obj.remove("type"); + } + } + } + if nullable_found_in_type { + obj.insert("nullable".to_string(), Value::Bool(true)); + } +} + +fn convert_types_to_any_of_defs(obj: &mut Map) { + let is_multi_type = obj + .get("type") + .and_then(|v| v.as_array()) + .is_some_and(|types| types.len() > 1); + if !is_multi_type { + return; + } + + let Some(Value::Array(types)) = obj.remove("type") else { + return; + }; + let any_of_schemas = types.into_iter().map(|t| json!({"type": t})).collect(); + push_any_of_constraint(obj, Value::Array(any_of_schemas)); +} + +fn push_any_of_constraint(obj: &mut Map, any_of_schemas: Value) { + if let Some(existing_any_of) = obj.remove("anyOf") { + let mut all_of = match obj.remove("allOf") { + Some(Value::Array(arr)) => arr, + _ => Vec::new(), + }; + // Always preserve the pre-existing `anyOf` — earlier this push was + // skipped when `allOf` was non-empty, which silently dropped it. + all_of.push(json!({"anyOf": existing_any_of})); + all_of.push(json!({"anyOf": any_of_schemas})); + obj.insert("allOf".to_string(), Value::Array(all_of)); + } else if let Some(all_of) = obj.get_mut("allOf").and_then(|v| v.as_array_mut()) { + all_of.push(json!({"anyOf": any_of_schemas})); + } else { + obj.insert("anyOf".to_string(), any_of_schemas); + } +} + +/// Folds `{nullable: true}`-only entries out of an `anyOf` array and onto the +/// parent object. This matches OpenAPI 3.0 semantics, where nullability is +/// expressed as a sibling of `type` rather than a separate variant. +fn collapse_nullable_only_any_of(obj: &mut Map) { + let Some(Value::Array(mut any_of)) = obj.remove("anyOf") else { + return; + }; + + let mut found_nullable_only = false; + any_of.retain(|entry| { + let is_nullable_only = entry + .as_object() + .is_some_and(|m| m.len() == 1 && matches!(m.get("nullable"), Some(Value::Bool(true)))); + if is_nullable_only { + found_nullable_only = true; + false + } else { + true + } + }); + + if !found_nullable_only { + obj.insert("anyOf".to_string(), Value::Array(any_of)); + return; + } + + obj.insert("nullable".to_string(), Value::Bool(true)); + + if any_of.is_empty() { + return; + } + + // If a single variant remains and its keys don't collide with the parent's + // existing keys, inline it. `anyOf` with a single entry is equivalent to + // just that entry, and inlining produces the canonical OpenAPI form + // (e.g. `{type: "string", nullable: true}`). + if any_of.len() == 1 + && let Value::Object(entry_obj) = &any_of[0] + && entry_obj.keys().all(|k| !obj.contains_key(k)) + { + let entry = any_of.remove(0); + if let Value::Object(entry_obj) = entry { + for (k, v) in entry_obj { + obj.insert(k, v); + } + } + return; + } + + obj.insert("anyOf".to_string(), Value::Array(any_of)); +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + fn obj(value: Value) -> Map { + match value { + Value::Object(map) => map, + other => panic!("expected JSON object, got {other}"), + } + } + + #[test] + fn test_convert_null_in_types_to_nullable() { + // ["string", "null"] -> "string", nullable: true + let mut o = obj(json!({"type": ["string", "null"]})); + convert_null_in_types_to_nullable(&mut o); + assert_eq!(o, obj(json!({"type": "string", "nullable": true}))); + + // "null" -> nullable: true + let mut o = obj(json!({"type": "null"})); + convert_null_in_types_to_nullable(&mut o); + assert_eq!(o, obj(json!({"nullable": true}))); + + // ["string", "number", "null"] -> ["string", "number"], nullable: true (anyOf handled elsewhere) + let mut o = obj(json!({"type": ["string", "number", "null"]})); + convert_null_in_types_to_nullable(&mut o); + assert_eq!( + o, + obj(json!({"type": ["string", "number"], "nullable": true})) + ); + + // "string" (no change, not nullable) + let mut o = obj(json!({"type": "string"})); + convert_null_in_types_to_nullable(&mut o); + assert_eq!(o, obj(json!({"type": "string"}))); + + // ["string", "number"] (no change, not nullable) + let mut o = obj(json!({"type": ["string", "number"]})); + convert_null_in_types_to_nullable(&mut o); + assert_eq!(o, obj(json!({"type": ["string", "number"]}))); + + // object with other properties, ["boolean", "null"] + let mut o = obj(json!({ + "description": "A test field", + "type": ["boolean", "null"] + })); + convert_null_in_types_to_nullable(&mut o); + assert_eq!( + o, + obj(json!({ + "description": "A test field", + "type": "boolean", + "nullable": true + })) + ); + } + + #[test] + fn test_convert_types_to_any_of_defs() { + // ["string", "number"] -> anyOf with string and number + let mut o = obj(json!({"type": ["string", "number"]})); + convert_types_to_any_of_defs(&mut o); + assert_eq!( + o, + obj(json!({ + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + })) + ); + + // "string" (no change) + let mut o = obj(json!({"type": "string"})); + convert_types_to_any_of_defs(&mut o); + assert_eq!(o, obj(json!({"type": "string"}))); + + // single-element array (no change, fallback in caller collapses it) + let mut o = obj(json!({"type": ["string"]})); + convert_types_to_any_of_defs(&mut o); + assert_eq!(o, obj(json!({"type": ["string"]}))); + + // object with other properties, ["string", "number"] + let mut o = obj(json!({ + "description": "A test field", + "type": ["string", "number"] + })); + convert_types_to_any_of_defs(&mut o); + assert_eq!( + o, + obj(json!({ + "description": "A test field", + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + })) + ); + + // anyOf already present (no change) + let mut o = obj(json!({ + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + })); + convert_types_to_any_of_defs(&mut o); + assert_eq!( + o, + obj(json!({ + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + })) + ); + + // both type array and anyOf present + let mut o = obj(json!({ + "type": ["string", "number"], + "anyOf": [ + {"format": "email"} + ] + })); + convert_types_to_any_of_defs(&mut o); + assert_eq!( + o, + obj(json!({ + "allOf": [ + { + "anyOf": [ + {"format": "email"} + ] + }, + { + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + } + ] + })) + ); + + // type array + anyOf + pre-existing allOf: pre-existing anyOf must not + // be silently dropped just because allOf is non-empty. + let mut o = obj(json!({ + "type": ["string", "number"], + "anyOf": [{"minLength": 5}], + "allOf": [{"maxLength": 100}] + })); + convert_types_to_any_of_defs(&mut o); + assert_eq!( + o, + obj(json!({ + "allOf": [ + {"maxLength": 100}, + {"anyOf": [{"minLength": 5}]}, + {"anyOf": [{"type": "string"}, {"type": "number"}]} + ] + })) + ); + } + #[test] fn test_transform_adds_type_when_missing() { let mut json = json!({ @@ -259,6 +564,95 @@ mod tests { ); } + #[test] + fn test_transform_null_in_any_of() { + let mut json = json!({ + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + // Should collapse to the canonical OpenAPI 3.0 form: `nullable: true` + // as a sibling of `type`, rather than a separate anyOf variant. + assert_eq!( + json, + json!({ + "type": "string", + "nullable": true + }) + ); + } + + #[test] + fn test_transform_null_in_any_of_with_multiple_non_null_variants() { + let mut json = json!({ + "anyOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "null" } + ] + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + // When more than one non-null variant remains, keep the anyOf and just + // hoist `nullable: true` onto the parent. + assert_eq!( + json, + json!({ + "nullable": true, + "anyOf": [ + { "type": "string" }, + { "type": "number" } + ] + }) + ); + } + + #[test] + fn test_transform_conflicting_any_of_sources() { + let mut json = json!({ + "type": ["string", "number"], + "anyOf": [ + { "minLength": 5 } + ], + "oneOf": [ + { "pattern": "^a" }, + { "pattern": "^b" } + ] + }); + + adapt_to_json_schema_subset(&mut json).unwrap(); + + assert_eq!( + json, + json!({ + "allOf": [ + { + "anyOf": [ + { "minLength": 5 }, + ] + }, + { + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ] + }, + { + "anyOf": [ + { "pattern": "^a" }, + { "pattern": "^b" } + ] + } + ] + }) + ); + } + #[test] fn test_transform_one_of_to_any_of() { let mut json = json!({ @@ -306,10 +700,8 @@ mod tests { "type": "object", "properties": { "nested": { - "anyOf": [ - { "type": "string" }, - { "type": "null" } - ] + "type": "string", + "nullable": true } } }) @@ -340,12 +732,16 @@ mod tests { "type": "object", "properties": { "projectSlugOrId": { - "type": "string", + "anyOf": [ + {"type": "string"}, + {"type": "number"} + ], "description": "Project slug or numeric ID" }, "optionalName": { "type": "string", - "description": "An optional name" + "description": "An optional name", + "nullable": true } } }) diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index dbea9bbf336..b919e2b6109 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -9,10 +9,11 @@ use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::HttpClient; use language_model::{ ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyState, AuthenticateError, - ConfigurationViewTargetAgent, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, RateLimiter, env_var, + ConfigurationViewTargetAgent, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, + env_var, }; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; @@ -275,6 +276,17 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { self.state .update(cx, |state, cx| state.set_api_key(None, cx)) } + + fn fast_mode_confirmation(&self, _cx: &App) -> Option { + Some(FastModeConfirmation { + title: "Enable Fast Mode for Anthropic?".into(), + message: "Fast mode lets requests use your Anthropic Priority Tier capacity, which \ + Anthropic prioritizes over standard requests during peak load. Requires a \ + Priority Tier commitment with Anthropic; without one, requests behave the same \ + as the standard tier." + .into(), + }) + } } /// Pick the model from `models` whose id starts with the earliest matching diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 23e5e4939a8..9d595b516ad 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -670,12 +670,22 @@ impl LanguageModel for BedrockModel { value: "high".into(), is_default: true, }, + language_model::LanguageModelEffortLevel { + name: "XHigh".into(), + value: "xhigh".into(), + is_default: false, + }, language_model::LanguageModelEffortLevel { name: "Max".into(), value: "max".into(), is_default: false, }, ] + .into_iter() + .filter(|effort_level| { + effort_level.value != "xhigh" || self.model.supports_xhigh_adaptive_thinking() + }) + .collect() } else { Vec::new() } @@ -1128,6 +1138,7 @@ pub fn into_bedrock( "low" => Some(bedrock::BedrockAdaptiveThinkingEffort::Low), "medium" => Some(bedrock::BedrockAdaptiveThinkingEffort::Medium), "high" => Some(bedrock::BedrockAdaptiveThinkingEffort::High), + "xhigh" => Some(bedrock::BedrockAdaptiveThinkingEffort::XHigh), "max" => Some(bedrock::BedrockAdaptiveThinkingEffort::Max), _ => None, }) diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 3cb9fcf1b86..8f7cbe6864c 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -9,21 +9,24 @@ use futures::StreamExt; use futures::future::BoxFuture; use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task, TaskExt}; use language_model::{ - AuthenticateError, IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, ZED_CLOUD_PROVIDER_ID, - ZED_CLOUD_PROVIDER_NAME, + AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, }; use language_models_cloud::{CloudLlmTokenProvider, CloudModelProvider}; +use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; use release_channel::AppVersion; use settings::SettingsStore; pub use settings::ZedDotDevAvailableModel as AvailableModel; pub use settings::ZedDotDevAvailableProvider as AvailableProvider; use std::sync::Arc; +use std::time::Duration; use ui::{TintColor, prelude::*}; const PROVIDER_ID: LanguageModelProviderId = ZED_CLOUD_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = ZED_CLOUD_PROVIDER_NAME; +const MODELS_REFRESH_DEBOUNCE: Duration = Duration::from_secs(5 * 60); struct ClientTokenProvider { client: Arc, @@ -84,10 +87,12 @@ pub struct State { user_store: Entity, status: client::Status, provider: Entity>, + pending_models_refresh: Option>, _user_store_subscription: Subscription, _settings_subscription: Subscription, _llm_token_subscription: Subscription, _provider_subscription: Subscription, + _cloud_reconnect_task: Task<()>, } impl State { @@ -112,10 +117,32 @@ impl State { ) }); + let cloud_reconnect_task = cx.spawn({ + let client = client.clone(); + async move |this, cx| { + let mut connection_id_rx = client.cloud_connection_id(); + while let Some(connection_id) = connection_id_rx.next().await { + // The initial value `0` means no connection has been + // established since this `Client` was created; only real + // reconnects trigger a refresh. + if connection_id == 0 { + continue; + } + if this + .update(cx, |this, cx| this.schedule_debounced_models_refresh(cx)) + .is_err() + { + break; + } + } + } + }); + Self { client: client.clone(), user_store: user_store.clone(), status, + pending_models_refresh: None, _provider_subscription: cx.observe(&provider, |_, _, cx| cx.notify()), provider, _user_store_subscription: cx.subscribe( @@ -141,6 +168,7 @@ impl State { this.refresh_models(cx); }, ), + _cloud_reconnect_task: cloud_reconnect_task, } } @@ -167,6 +195,24 @@ impl State { provider.refresh_models(cx).detach_and_log_err(cx); }); } + + /// Schedules a model list refresh, replacing any previously scheduled + /// refresh. + fn schedule_debounced_models_refresh(&mut self, cx: &mut Context) { + self.pending_models_refresh = Some(cx.spawn(async move |this, cx| { + #[cfg(any(test, feature = "test-support"))] + let mut rng = StdRng::seed_from_u64(0); + #[cfg(not(any(test, feature = "test-support")))] + let mut rng = StdRng::from_os_rng(); + let jitter = Duration::from_millis( + rng.random_range(0..MODELS_REFRESH_DEBOUNCE.as_millis() as u64), + ); + cx.background_executor() + .timer(MODELS_REFRESH_DEBOUNCE + jitter) + .await; + this.update(cx, |this, cx| this.refresh_models(cx)).ok(); + })); + } } impl CloudLanguageModelProvider { @@ -306,6 +352,16 @@ impl LanguageModelProvider for CloudLanguageModelProvider { fn reset_credentials(&self, _cx: &mut App) -> Task> { Task::ready(Ok(())) } + + fn fast_mode_confirmation(&self, _cx: &App) -> Option { + Some(FastModeConfirmation { + title: "Enable Fast Mode for Zed?".into(), + message: "Fast mode routes requests through the upstream provider's fast mode or priority tier. The \ + upstream provider's premium per-token pricing applies and is passed through to \ + your Zed billing." + .into(), + }) + } } #[derive(IntoElement, RegisterComponent)] @@ -665,7 +721,13 @@ impl Component for ZedAiConfiguration { ComponentScope::Onboarding } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "The configuration surface for Zed's hosted AI models, \ + showing the user's connection status, current plan, trial eligibility, \ + and entry points for enabling the Zed model provider." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { struct PreviewConfiguration { plan: Option, is_connected: bool, @@ -685,94 +747,92 @@ impl Component for ZedAiConfiguration { .into_any_element() }; - Some( - v_flex() - .p_4() - .gap_4() - .children(vec![ - single_example( - "Not connected", - configuration(PreviewConfiguration { - plan: None, - is_connected: false, - is_zed_model_provider_enabled: true, - eligible_for_trial: false, - }), - ), - single_example( - "Accept Terms of Service", - configuration(PreviewConfiguration { - plan: None, - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "No Plan - Not eligible for trial", - configuration(PreviewConfiguration { - plan: None, - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: false, - }), - ), - single_example( - "No Plan - Eligible for trial", - configuration(PreviewConfiguration { - plan: None, - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "Free Plan", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedFree), - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "Zed Pro Trial Plan", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedProTrial), - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "Zed Pro Plan", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedPro), - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: true, - }), - ), - single_example( - "Business Plan - Zed models enabled", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedBusiness), - is_connected: true, - is_zed_model_provider_enabled: true, - eligible_for_trial: false, - }), - ), - single_example( - "Business Plan - Zed models disabled", - configuration(PreviewConfiguration { - plan: Some(Plan::ZedBusiness), - is_connected: true, - is_zed_model_provider_enabled: false, - eligible_for_trial: false, - }), - ), - ]) - .into_any_element(), - ) + v_flex() + .p_4() + .gap_4() + .children(vec![ + single_example( + "Not connected", + configuration(PreviewConfiguration { + plan: None, + is_connected: false, + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + }), + ), + single_example( + "Accept Terms of Service", + configuration(PreviewConfiguration { + plan: None, + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "No Plan - Not eligible for trial", + configuration(PreviewConfiguration { + plan: None, + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + }), + ), + single_example( + "No Plan - Eligible for trial", + configuration(PreviewConfiguration { + plan: None, + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "Free Plan", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedFree), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "Zed Pro Trial Plan", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedProTrial), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "Zed Pro Plan", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedPro), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "Business Plan - Zed models enabled", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedBusiness), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + }), + ), + single_example( + "Business Plan - Zed models disabled", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedBusiness), + is_connected: true, + is_zed_model_provider_enabled: false, + eligible_for_trial: false, + }), + ), + ]) + .into_any_element() } } diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index cf8f11a5bb0..f42fad657c4 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -659,12 +659,14 @@ pub fn map_to_language_model_completion_events( pub struct CopilotResponsesEventMapper { pending_stop_reason: Option, + reasoning_items: Vec, } impl CopilotResponsesEventMapper { pub fn new() -> Self { Self { pending_stop_reason: None, + reasoning_items: Vec::new(), } } @@ -740,13 +742,13 @@ impl CopilotResponsesEventMapper { events } copilot_responses::ResponseOutputItem::Reasoning { + id, summary, encrypted_content, - .. } => { let mut events = Vec::new(); - if let Some(blocks) = summary { + if let Some(blocks) = summary.as_ref() { let mut text = String::new(); for block in blocks { text.push_str(&block.text); @@ -759,8 +761,10 @@ impl CopilotResponsesEventMapper { } } - if let Some(data) = encrypted_content { - events.push(Ok(LanguageModelCompletionEvent::RedactedThinking { data })); + if let Some(reasoning_item) = + reasoning_input_item_from_output(&id, encrypted_content) + { + events.extend(self.capture_reasoning_item(reasoning_item)); } events @@ -840,6 +844,94 @@ impl CopilotResponsesEventMapper { | copilot_responses::StreamEvent::Unknown => Vec::new(), } } + + fn capture_reasoning_item( + &mut self, + reasoning_item: copilot_responses::ResponseReasoningInputItem, + ) -> Vec> { + if self.reasoning_items.contains(&reasoning_item) { + return Vec::new(); + } + + if let Some(id) = reasoning_item.id.as_ref() + && let Some(existing_reasoning_item) = self + .reasoning_items + .iter_mut() + .find(|existing_reasoning_item| existing_reasoning_item.id.as_ref() == Some(id)) + { + *existing_reasoning_item = reasoning_item; + } else { + self.reasoning_items.push(reasoning_item); + } + + self.emit_response_message_metadata() + } + + fn emit_response_message_metadata( + &self, + ) -> Vec> { + let details = serde_json::to_value(CopilotResponseMessageMetadata { + reasoning_items: self.reasoning_items.clone(), + }); + + match details { + Ok(details) => vec![Ok(LanguageModelCompletionEvent::ReasoningDetails(details))], + Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))], + } + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct CopilotResponseMessageMetadata { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + reasoning_items: Vec, +} + +fn append_reasoning_details_to_response_items( + reasoning_details: Option<&serde_json::Value>, + replayed_reasoning_item_indexes: &mut HashMap, + input_items: &mut Vec, +) { + let Some(reasoning_details) = reasoning_details else { + return; + }; + + let Some(metadata) = + serde_json::from_value::(reasoning_details.clone()).ok() + else { + return; + }; + + for mut reasoning_item in metadata.reasoning_items { + reasoning_item.summary.clear(); + if let Some(id) = reasoning_item.id.as_ref() { + if let Some(index) = replayed_reasoning_item_indexes.get(id) { + input_items[*index] = + copilot_responses::ResponseInputItem::Reasoning(reasoning_item); + return; + } + + replayed_reasoning_item_indexes.insert(id.clone(), input_items.len()); + } + + input_items.push(copilot_responses::ResponseInputItem::Reasoning( + reasoning_item, + )); + } +} + +fn reasoning_input_item_from_output( + id: &str, + encrypted_content: Option, +) -> Option { + if encrypted_content.is_none() { + return None; + } + Some(copilot_responses::ResponseReasoningInputItem { + id: Some(id.to_string()), + summary: Vec::new(), + encrypted_content, + }) } fn into_copilot_chat( @@ -1100,6 +1192,7 @@ fn into_copilot_responses( } = request; let mut input_items: Vec = Vec::new(); + let mut replayed_reasoning_item_indexes = HashMap::default(); for message in messages { match message.role { @@ -1181,6 +1274,12 @@ fn into_copilot_responses( } Role::Assistant => { + append_reasoning_details_to_response_items( + message.reasoning_details.as_deref(), + &mut replayed_reasoning_item_indexes, + &mut input_items, + ); + for content in &message.content { if let MessageContent::ToolUse(tool_use) = content { input_items.push(responses::ResponseInputItem::FunctionCall { @@ -1193,16 +1292,6 @@ fn into_copilot_responses( } } - for content in &message.content { - if let MessageContent::RedactedThinking(data) = content { - input_items.push(responses::ResponseInputItem::Reasoning { - id: None, - summary: Vec::new(), - encrypted_content: data.clone(), - }); - } - } - let mut parts: Vec = Vec::new(); for content in &message.content { match content { @@ -1297,6 +1386,7 @@ mod tests { use super::*; use copilot_chat::responses; use futures::StreamExt; + use serde_json::json; fn map_events(events: Vec) -> Vec { futures::executor::block_on(async { @@ -1310,6 +1400,37 @@ mod tests { }) } + fn test_responses_model() -> CopilotChatModel { + serde_json::from_value(json!({ + "billing": { + "is_premium": false, + "multiplier": 1.0 + }, + "capabilities": { + "family": "test", + "limits": { + "max_context_window_tokens": 128000, + "max_output_tokens": 4096 + }, + "supports": { + "streaming": true, + "tool_calls": true, + "parallel_tool_calls": false, + "vision": false + }, + "type": "chat" + }, + "id": "test-model", + "is_chat_default": false, + "is_chat_fallback": false, + "model_picker_enabled": true, + "name": "Test Model", + "vendor": "OpenAI", + "supported_endpoints": ["/responses"] + })) + .expect("valid test model") + } + #[test] fn responses_stream_maps_text_and_usage() { let events = vec![ @@ -1435,10 +1556,134 @@ mod tests { mapped[0], LanguageModelCompletionEvent::Thinking { ref text, signature: None } if text == "Chain" )); - assert!(matches!( - mapped[1], - LanguageModelCompletionEvent::RedactedThinking { ref data } if data == "ENC" - )); + match &mapped[1] { + LanguageModelCompletionEvent::ReasoningDetails(details) => assert_eq!( + details, + &json!({ + "reasoning_items": [ + { + "id": "r1", + "summary": [], + "encrypted_content": "ENC" + } + ] + }) + ), + other => panic!("expected reasoning details, got {other:?}"), + } + } + + #[test] + fn responses_stream_ignores_reasoning_items_repeated_in_completed_output() { + let events = vec![ + responses::StreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: responses::ResponseOutputItem::Reasoning { + id: "r1".into(), + summary: Some(Vec::new()), + encrypted_content: Some("ENC1".into()), + }, + }, + responses::StreamEvent::Completed { + response: responses::Response { + output: vec![ + responses::ResponseOutputItem::Reasoning { + id: "r1".into(), + summary: Some(Vec::new()), + encrypted_content: Some("ENC1".into()), + }, + responses::ResponseOutputItem::Reasoning { + id: "r2".into(), + summary: Some(Vec::new()), + encrypted_content: Some("ENC2".into()), + }, + ], + ..Default::default() + }, + }, + ]; + + let mapped = map_events(events); + let reasoning_details = mapped + .iter() + .filter_map(|event| match event { + LanguageModelCompletionEvent::ReasoningDetails(details) => Some(details), + _ => None, + }) + .collect::>(); + + assert_eq!( + reasoning_details, + vec![&json!({ + "reasoning_items": [ + { + "id": "r1", + "summary": [], + "encrypted_content": "ENC1" + } + ] + })] + ); + } + + #[test] + fn into_copilot_responses_replays_reasoning_details() { + let model = test_responses_model(); + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::RedactedThinking("legacy-redacted".into()), + MessageContent::Text("Done".into()), + ], + cache: false, + reasoning_details: Some(Arc::new(json!({ + "reasoning_items": [ + { + "id": "r1", + "summary": [ + { + "type": "summary_text", + "text": "Chain" + } + ], + "encrypted_content": "ENC" + } + ] + }))), + }], + ..Default::default() + }; + + let serialized = serde_json::to_value(into_copilot_responses(&model, request)) + .expect("serialized request"); + let input = serialized["input"].as_array().expect("input items"); + + assert_eq!( + input.first(), + Some(&json!({ + "type": "reasoning", + "id": "r1", + "summary": [], + "encrypted_content": "ENC" + })) + ); + assert_eq!( + input.get(1), + Some(&json!({ + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Done" + } + ], + "status": "completed" + })) + ); + assert!(!serialized.to_string().contains("legacy-redacted")); } #[test] diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index d5b47bf4583..774de74a6b2 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -2,8 +2,8 @@ use anyhow::{Context as _, Result}; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; +use google_ai::GenerateContentResponse; pub use google_ai::completion::{GoogleEventMapper, into_google}; -use google_ai::{GenerateContentResponse, GoogleModelMode}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ @@ -11,9 +11,9 @@ use language_model::{ LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat, }; use language_model::{ - GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, IconOrSvg, LanguageModel, LanguageModelId, - LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, RateLimiter, + GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, IconOrSvg, LanguageModel, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -300,7 +300,20 @@ impl LanguageModel for GoogleLanguageModel { } fn supports_thinking(&self) -> bool { - matches!(self.model.mode(), GoogleModelMode::Thinking { .. }) + self.model.supports_thinking() + } + + fn supported_effort_levels(&self) -> Vec { + let default_level = self.model.default_thinking_level(); + self.model + .supported_thinking_levels() + .iter() + .map(|level| LanguageModelEffortLevel { + name: level.name().into(), + value: level.value().into(), + is_default: Some(*level) == default_level, + }) + .collect() } fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 9776dfffbc8..30e3836e8bb 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -1003,7 +1003,6 @@ mod tests { MessageContent::Text("What's in this image?".into()), MessageContent::Image(LanguageModelImage { source: "base64data".into(), - size: None, }), ], cache: false, diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index ddf8b0e6885..1ef535f39a6 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, - OPEN_AI_PROVIDER_NAME, RateLimiter, env_var, + ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, RateLimiter, env_var, }; use menu; use open_ai::{ @@ -215,6 +215,16 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider { self.state .update(cx, |state, cx| state.set_api_key(None, cx)) } + + fn fast_mode_confirmation(&self, _cx: &App) -> Option { + Some(FastModeConfirmation { + title: "Enable Fast Mode for OpenAI?".into(), + message: "Fast mode sends requests using OpenAI's Priority processing tier, which \ + targets significantly lower latency than the standard tier and is billed at a \ + premium per-token rate." + .into(), + }) + } } fn default_thinking_reasoning_effort(model: &open_ai::Model) -> Option { @@ -454,6 +464,10 @@ impl LanguageModel for OpenAiLanguageModel { supports_selectable_thinking_effort(&self.model) } + fn supports_fast_mode(&self) -> bool { + self.model.supports_priority() + } + fn supported_effort_levels(&self) -> Vec { supported_thinking_effort_levels(&self.model) } @@ -476,7 +490,7 @@ impl LanguageModel for OpenAiLanguageModel { fn stream_completion( &self, - request: LanguageModelRequest, + mut request: LanguageModelRequest, cx: &AsyncApp, ) -> BoxFuture< 'static, @@ -488,6 +502,9 @@ impl LanguageModel for OpenAiLanguageModel { LanguageModelCompletionError, >, > { + if !self.model.supports_priority() { + request.speed = None; + } if self.model.uses_responses_api() { let request = into_open_ai_response( request, diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index c0b0e330629..d9632c402f0 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -540,7 +540,7 @@ fn add_message_content_part( new_part: open_router::MessagePart, role: Role, messages: &mut Vec, - reasoning_details: Option, + reasoning_details: Option>, ) { match (role, messages.last_mut()) { (Role::User, Some(open_router::RequestMessage::User { content })) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 2da10003a43..66716ebdadb 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -6,10 +6,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture, future::Shared}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use language_model::{ - AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, + AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, RateLimiter, }; use open_ai::{ReasoningEffort, responses::stream_response}; use rand::RngCore as _; @@ -251,6 +252,16 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { fn reset_credentials(&self, cx: &mut App) -> Task> { self.sign_out(cx) } + + fn fast_mode_confirmation(&self, _cx: &App) -> Option { + Some(FastModeConfirmation { + title: "Enable Fast Mode for OpenAI?".into(), + message: "Fast mode sends requests using OpenAI's Priority processing tier, which \ + targets significantly lower latency than the standard tier and is billed at a \ + premium per-token rate." + .into(), + }) + } } // @@ -345,6 +356,13 @@ impl ChatGptModel { fn supports_prompt_cache_key(&self) -> bool { true } + + fn supports_priority(&self) -> bool { + match self { + Self::Gpt55 | Self::Gpt54 => true, + Self::Gpt54Mini | Self::Gpt53Codex | Self::Gpt52 => false, + } + } } struct OpenAiSubscribedLanguageModel { @@ -392,6 +410,10 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { true } + fn supports_fast_mode(&self) -> bool { + self.model.supports_priority() + } + fn supported_effort_levels(&self) -> Vec { let default_effort = self.model.default_reasoning_effort(); self.model @@ -431,7 +453,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { fn stream_completion( &self, - request: LanguageModelRequest, + mut request: LanguageModelRequest, cx: &AsyncApp, ) -> BoxFuture< 'static, @@ -443,6 +465,10 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { LanguageModelCompletionError, >, > { + if !self.model.supports_priority() { + request.speed = None; + } + // The Codex backend rejects `max_output_tokens` (`Unsupported parameter`), // unlike the public OpenAI Responses API. Pass `None` so the field is // omitted from the serialized request body entirely. diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 1d77c59f5d9..a5c98151feb 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -49,7 +49,7 @@ fn reasoning_effort_display(effort: ReasoningEffort) -> (&'static str, &'static ReasoningEffort::Low => ("Low", "low"), ReasoningEffort::Medium => ("Medium", "medium"), ReasoningEffort::High => ("High", "high"), - ReasoningEffort::XHigh => ("Max", "max"), + ReasoningEffort::XHigh => ("XHigh", "xhigh"), } } @@ -602,11 +602,11 @@ impl LanguageModel for OpenCodeLanguageModel { } fn max_token_count(&self) -> u64 { - self.model.max_token_count() + self.model.max_token_count(self.subscription) } fn max_output_tokens(&self) -> Option { - self.model.max_output_tokens() + self.model.max_output_tokens(self.subscription) } fn stream_completion( @@ -646,7 +646,9 @@ impl LanguageModel for OpenCodeLanguageModel { request, self.model.id().to_string(), 1.0, - self.model.max_output_tokens().unwrap_or(8192), + self.model + .max_output_tokens(self.subscription) + .unwrap_or(8192), mode, anthropic::completion::AnthropicPromptCacheMode::Automatic, ); @@ -671,7 +673,7 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.id(), false, false, - self.model.max_output_tokens(), + self.model.max_output_tokens(self.subscription), reasoning_effort, self.model.interleaved_reasoning(), ); @@ -692,7 +694,7 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.id(), false, false, - self.model.max_output_tokens(), + self.model.max_output_tokens(self.subscription), None, supports_none_reasoning_effort, ); diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 623853b5214..51eb2e3b81d 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -6,10 +6,10 @@ use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, - env_var, + LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolSchemaFormat, RateLimiter, env_var, }; use open_ai::ResponseStreamEvent; pub use settings::XaiAvailableModel as AvailableModel; @@ -255,6 +255,75 @@ impl XAiLanguageModel { } } +fn x_ai_reasoning_efforts(model: &x_ai::Model) -> &'static [open_ai::ReasoningEffort] { + if model.supports_reasoning_effort() { + &[ + open_ai::ReasoningEffort::None, + open_ai::ReasoningEffort::Low, + open_ai::ReasoningEffort::Medium, + open_ai::ReasoningEffort::High, + ] + } else { + &[] + } +} + +fn default_thinking_reasoning_effort(model: &x_ai::Model) -> Option { + if model.supports_reasoning_effort() { + Some(open_ai::ReasoningEffort::Low) + } else { + None + } +} + +fn reasoning_effort_for_request( + request: &LanguageModelRequest, + model: &x_ai::Model, +) -> Option { + let supported_efforts = x_ai_reasoning_efforts(model); + if supported_efforts.is_empty() { + return None; + } + + if request.thinking_allowed { + request + .thinking_effort + .as_deref() + .and_then(|effort| effort.parse::().ok()) + .filter(|effort| supported_efforts.contains(effort)) + .filter(|effort| *effort != open_ai::ReasoningEffort::None) + .or_else(|| default_thinking_reasoning_effort(model)) + } else if supported_efforts.contains(&open_ai::ReasoningEffort::None) { + Some(open_ai::ReasoningEffort::None) + } else { + None + } +} + +fn supported_thinking_effort_levels(model: &x_ai::Model) -> Vec { + let default_effort = default_thinking_reasoning_effort(model); + x_ai_reasoning_efforts(model) + .iter() + .copied() + .filter_map(|effort| { + let (name, value) = match effort { + open_ai::ReasoningEffort::None => return None, + open_ai::ReasoningEffort::Minimal => ("Minimal", "minimal"), + open_ai::ReasoningEffort::Low => ("Low", "low"), + open_ai::ReasoningEffort::Medium => ("Medium", "medium"), + open_ai::ReasoningEffort::High => ("High", "high"), + open_ai::ReasoningEffort::XHigh => ("Extra High", "xhigh"), + }; + + Some(LanguageModelEffortLevel { + name: name.into(), + value: value.into(), + is_default: Some(effort) == default_effort, + }) + }) + .collect() +} + impl LanguageModel for XAiLanguageModel { fn id(&self) -> LanguageModelId { self.id.clone() @@ -291,6 +360,15 @@ impl LanguageModel for XAiLanguageModel { | LanguageModelToolChoice::None => true, } } + + fn supports_thinking(&self) -> bool { + self.model.supports_reasoning_effort() + } + + fn supported_effort_levels(&self) -> Vec { + supported_thinking_effort_levels(&self.model) + } + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { if self.model.requires_json_schema_subset() { LanguageModelToolSchemaFormat::JsonSchemaSubset @@ -329,13 +407,14 @@ impl LanguageModel for XAiLanguageModel { LanguageModelCompletionError, >, > { + let reasoning_effort = reasoning_effort_for_request(&request, &self.model); let request = crate::provider::open_ai::into_open_ai( request, self.model.id(), self.model.supports_parallel_tool_calls(), self.model.supports_prompt_cache_key(), self.max_output_tokens(), - None, + reasoning_effort, false, ); let completions = self.stream_completion(request, cx); @@ -428,6 +507,56 @@ impl ConfigurationView { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grok_43_supports_selectable_thinking_effort_levels() { + let effort_levels = supported_thinking_effort_levels(&x_ai::Model::Grok43); + let values = effort_levels + .iter() + .map(|level| level.value.as_ref()) + .collect::>(); + + assert_eq!(values, ["low", "medium", "high"]); + assert_eq!( + effort_levels + .iter() + .find(|level| level.is_default) + .map(|level| level.value.as_ref()), + Some("low") + ); + } + + #[test] + fn grok_43_request_uses_selected_reasoning_effort() { + let request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("high".to_string()), + ..Default::default() + }; + + assert_eq!( + reasoning_effort_for_request(&request, &x_ai::Model::Grok43), + Some(open_ai::ReasoningEffort::High) + ); + } + + #[test] + fn grok_43_request_uses_none_when_thinking_is_disabled() { + let request = LanguageModelRequest { + thinking_allowed: false, + ..Default::default() + }; + + assert_eq!( + reasoning_effort_for_request(&request, &x_ai::Model::Grok43), + Some(open_ai::ReasoningEffort::None) + ); + } +} + impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); diff --git a/crates/language_tools/src/highlights_tree_view.rs b/crates/language_tools/src/highlights_tree_view.rs index 96f673fa1dc..1b58e830153 100644 --- a/crates/language_tools/src/highlights_tree_view.rs +++ b/crates/language_tools/src/highlights_tree_view.rs @@ -10,8 +10,9 @@ use gpui::{ }; use language::{BufferId, Point, ToOffset}; use menu::{SelectNext, SelectPrevious}; -use std::{mem, ops::Range}; +use std::{mem, ops::Range, sync::Arc, time::Duration}; use theme::ActiveTheme; +use theme::SyntaxTheme; use ui::{ ButtonCommon, ButtonLike, ButtonStyle, Color, ContextMenu, FluentBuilder as _, IconButton, IconName, IconPosition, IconSize, Label, LabelCommon, LabelSize, PopoverMenu, @@ -147,6 +148,7 @@ pub struct HighlightsTreeView { show_syntax_tokens: bool, show_semantic_tokens: bool, skip_next_scroll: bool, + refresh_task: Task<()>, } pub struct HighlightsTreeToolbarItemView { @@ -160,6 +162,19 @@ struct EditorState { _subscription: gpui::Subscription, } +struct SemanticHighlightEntry { + range: Range, + style: HighlightStyle, + category: HighlightCategory, +} + +struct HighlightRefreshInput { + multi_buffer_snapshot: MultiBufferSnapshot, + text_highlights: Vec<(HighlightKey, Arc<(HighlightStyle, Vec>)>)>, + semantic_highlights: Vec, + syntax_theme: Arc, +} + impl HighlightsTreeView { pub fn new( workspace_handle: WeakEntity, @@ -181,6 +196,7 @@ impl HighlightsTreeView { show_syntax_tokens: true, show_semantic_tokens: true, skip_next_scroll: false, + refresh_task: Task::ready(()), }; this.handle_item_updated(active_item, window, cx); @@ -254,6 +270,7 @@ impl HighlightsTreeView { } fn clear(&mut self, cx: &mut Context) { + self.refresh_task = Task::ready(()); self.cached_entries.clear(); self.display_items.clear(); self.selected_item_ix = None; @@ -274,9 +291,9 @@ impl HighlightsTreeView { let subscription = cx.subscribe_in(&editor, window, |this, _, event, window, cx| match event { - editor::EditorEvent::Reparsed(_) - | editor::EditorEvent::SelectionsChanged { .. } => { - this.refresh_highlights(window, cx); + editor::EditorEvent::Reparsed(_) => this.schedule_refresh_highlights(window, cx), + editor::EditorEvent::SelectionsChanged { .. } => { + this.update_selection_from_editor(cx); } _ => return, }); @@ -285,221 +302,152 @@ impl HighlightsTreeView { editor, _subscription: subscription, }); - self.refresh_highlights(window, cx); + self.refresh_task = Task::ready(()); + self.cached_entries.clear(); + self.display_items.clear(); + self.selected_item_ix = None; + self.hovered_item_ix = None; + self.schedule_refresh_highlights(window, cx); + cx.notify(); } - fn refresh_highlights(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(editor_state) = self.editor.as_ref() else { - self.clear(cx); - return; - }; + fn schedule_refresh_highlights(&mut self, window: &mut Window, cx: &mut Context) { + self.refresh_task = cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; - let (display_map, project, multi_buffer, cursor_position) = { - let editor = editor_state.editor.read(cx); - let cursor = editor.selections.newest_anchor().head(); - ( - editor.display_map.clone(), - editor.project().cloned(), - editor.buffer().clone(), - cursor, - ) - }; - let Some(project) = project else { - return; - }; + let Some(input) = this + .update(cx, |this, cx| this.highlight_refresh_input(cx)) + .ok() + .flatten() + else { + return; + }; + let new_highlights = cx + .background_spawn(async move { build_highlight_entries(input) }) + .await; + + this.update_in(cx, |this, _window, cx| { + this.apply_highlight_refresh(new_highlights, cx); + }) + .ok(); + }); + } + + fn highlight_refresh_input(&self, cx: &mut Context) -> Option { + let editor_state = self.editor.as_ref()?; + let editor = editor_state.editor.read(cx); + let display_map = editor.display_map.clone(); + let project = editor.project().cloned()?; + let multi_buffer = editor.buffer().clone(); let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx); - let is_singleton = multi_buffer_snapshot.is_singleton(); - self.is_singleton = is_singleton; + let syntax_theme = cx.theme().syntax().clone(); - let mut entries = Vec::new(); + let (text_highlights, semantic_token_highlights) = + display_map.update(cx, |display_map, _| { + let text_highlights = display_map + .all_text_highlights() + .map(|(key, highlights)| (*key, highlights.clone())) + .collect::>(); + let semantic_token_highlights = display_map + .all_semantic_token_highlights() + .map(|(buffer_id, (tokens, interner))| { + (*buffer_id, tokens.clone(), interner.clone()) + }) + .collect::>(); + (text_highlights, semantic_token_highlights) + }); - let semantic_theme = cx.theme().syntax().clone(); - display_map.update(cx, |display_map, cx| { - for (key, text_highlights) in display_map.all_text_highlights() { - for range in &text_highlights.1 { - let Some((range_display, buffer_id, buffer_point_range)) = - format_anchor_range(range, &multi_buffer_snapshot) - else { + let mut semantic_highlights = Vec::new(); + project.read(cx).lsp_store().update(cx, |lsp_store, cx| { + for (buffer_id, tokens, interner) in semantic_token_highlights { + let language_name = multi_buffer + .read(cx) + .buffer(buffer_id) + .and_then(|buffer| buffer.read(cx).language().map(|language| language.name())); + + for token in tokens.iter() { + let Some(stylizer) = lsp_store.get_or_create_token_stylizer( + token.server_id, + language_name.as_ref(), + cx, + ) else { continue; }; - entries.push(HighlightEntry { - range: range.clone(), - buffer_id, - range_display, - style: text_highlights.0, - category: HighlightCategory::Text(*key), - buffer_point_range, + + let theme_key = stylizer + .rules_for_token(token.token_type) + .and_then(|rules| { + rules + .iter() + .filter(|rule| { + rule.token_modifiers.iter().all(|modifier| { + stylizer.has_modifier(token.token_modifiers, modifier) + }) + }) + .fold(None, |theme_key, rule| { + rule.style + .iter() + .find(|style_name| { + syntax_theme.style_for_name(style_name).is_some() + }) + .map(|style_name| SharedString::from(style_name.clone())) + .or(theme_key) + }) + }); + + semantic_highlights.push(SemanticHighlightEntry { + range: token.range.start..token.range.end, + style: interner[token.style], + category: HighlightCategory::SemanticToken { + token_type: stylizer.token_type_name(token.token_type).cloned(), + token_modifiers: stylizer + .token_modifiers(token.token_modifiers) + .map(SharedString::from), + theme_key, + }, }); } } - - project.read(cx).lsp_store().update(cx, |lsp_store, cx| { - for (buffer_id, (tokens, interner)) in display_map.all_semantic_token_highlights() { - let language_name = multi_buffer - .read(cx) - .buffer(*buffer_id) - .and_then(|buf| buf.read(cx).language().map(|l| l.name())); - for token in tokens.iter() { - let range = token.range.start..token.range.end; - let Some((range_display, entry_buffer_id, buffer_point_range)) = - format_anchor_range(&range, &multi_buffer_snapshot) - else { - continue; - }; - let Some(stylizer) = lsp_store.get_or_create_token_stylizer( - token.server_id, - language_name.as_ref(), - cx, - ) else { - continue; - }; - - let theme_key = - stylizer - .rules_for_token(token.token_type) - .and_then(|rules| { - rules - .iter() - .filter(|rule| { - rule.token_modifiers.iter().all(|modifier| { - stylizer - .has_modifier(token.token_modifiers, modifier) - }) - }) - .fold(None, |theme_key, rule| { - rule.style - .iter() - .find(|style_name| { - semantic_theme - .style_for_name(style_name) - .is_some() - }) - .map(|style_name| { - SharedString::from(style_name.clone()) - }) - .or(theme_key) - }) - }); - - entries.push(HighlightEntry { - range, - buffer_id: entry_buffer_id, - range_display, - style: interner[token.style], - category: HighlightCategory::SemanticToken { - token_type: stylizer.token_type_name(token.token_type).cloned(), - token_modifiers: stylizer - .token_modifiers(token.token_modifiers) - .map(SharedString::from), - theme_key, - }, - buffer_point_range, - }); - } - } - }); }); - let syntax_theme = cx.theme().syntax().clone(); - for excerpt_range in multi_buffer_snapshot.excerpts() { - let Some(buffer_snapshot) = - multi_buffer_snapshot.buffer_for_id(excerpt_range.context.start.buffer_id) - else { - continue; - }; + Some(HighlightRefreshInput { + multi_buffer_snapshot, + text_highlights, + semantic_highlights, + syntax_theme, + }) + } - let start_offset = excerpt_range.context.start.to_offset(buffer_snapshot); - let end_offset = excerpt_range.context.end.to_offset(buffer_snapshot); - let range = start_offset..end_offset; + fn apply_highlight_refresh( + &mut self, + new_highlights: Vec, + cx: &mut Context, + ) { + let Some(editor) = self.editor.as_ref().map(|state| state.editor.clone()) else { + return; + }; + let editor = editor.read(cx); + let cursor_position = editor.selections.newest_anchor().head(); + let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let captures = buffer_snapshot.captures(range, |grammar| { - grammar.highlights_config.as_ref().map(|c| &c.query) - }); - let grammars: Vec<_> = captures.grammars().to_vec(); - let highlight_maps: Vec<_> = grammars.iter().map(|g| g.highlight_map()).collect(); - - for capture in captures { - let Some(highlight_id) = highlight_maps[capture.grammar_index].get(capture.index) - else { - continue; - }; - let Some(style) = syntax_theme.get(highlight_id).cloned() else { - continue; - }; - - let theme_key = syntax_theme - .get_capture_name(highlight_id) - .map(|theme_key| SharedString::from(theme_key.to_string())); - - let capture_name = grammars[capture.grammar_index] - .highlights_config - .as_ref() - .and_then(|config| config.query.capture_names().get(capture.index as usize)) - .map(|capture_name| SharedString::from((*capture_name).to_string())) - .unwrap_or_else(|| SharedString::from("unknown")); - - let start_anchor = buffer_snapshot.anchor_before(capture.node.start_byte()); - let end_anchor = buffer_snapshot.anchor_after(capture.node.end_byte()); - - let start = multi_buffer_snapshot.anchor_in_excerpt(start_anchor); - let end = multi_buffer_snapshot.anchor_in_excerpt(end_anchor); - - let (start, end) = match (start, end) { - (Some(s), Some(e)) => (s, e), - _ => continue, - }; - - let range = start..end; - let Some((range_display, buffer_id, buffer_point_range)) = - format_anchor_range(&range, &multi_buffer_snapshot) - else { - continue; - }; - - entries.push(HighlightEntry { - range, - buffer_id, - range_display, - style, - category: HighlightCategory::SyntaxToken { - capture_name, - theme_key, - }, - buffer_point_range, - }); - } - } - - entries.sort_by(|a, b| { - a.buffer_id - .cmp(&b.buffer_id) - .then_with(|| a.buffer_point_range.start.cmp(&b.buffer_point_range.start)) - .then_with(|| a.buffer_point_range.end.cmp(&b.buffer_point_range.end)) - .then_with(|| a.category.cmp(&b.category)) - }); - entries.dedup_by(|a, b| { - a.buffer_id == b.buffer_id - && a.buffer_point_range == b.buffer_point_range - && a.category == b.category - }); - - self.cached_entries = entries; + self.is_singleton = multi_buffer_snapshot.is_singleton(); + self.cached_entries = new_highlights; + self.hovered_item_ix = None; self.rebuild_display_items(&multi_buffer_snapshot, cx); - if self.skip_next_scroll { - self.skip_next_scroll = false; - } else { - self.scroll_to_cursor_position(&cursor_position, &multi_buffer_snapshot); - } + let should_scroll = !mem::take(&mut self.skip_next_scroll); + self.update_selection_for_cursor(&cursor_position, &multi_buffer_snapshot, should_scroll); + self.sync_editor_highlight_for_selected_entry(cx); cx.notify(); } fn rebuild_display_items(&mut self, snapshot: &MultiBufferSnapshot, cx: &App) { self.display_items.clear(); - let mut last_range_end: Option = None; - + let mut last_range_end = None; for (entry_ix, entry) in self.cached_entries.iter().enumerate() { if !self.should_show_entry(entry) { continue; @@ -531,7 +479,47 @@ impl HighlightsTreeView { } } - fn scroll_to_cursor_position(&mut self, cursor: &Anchor, snapshot: &MultiBufferSnapshot) { + fn update_selection_from_editor(&mut self, cx: &mut Context) { + let Some(editor_state) = self.editor.as_ref() else { + return; + }; + + let (cursor_position, multi_buffer_snapshot) = { + let editor = editor_state.editor.read(cx); + ( + editor.selections.newest_anchor().head(), + editor.buffer().read(cx).snapshot(cx), + ) + }; + + let should_update_selection = !mem::take(&mut self.skip_next_scroll); + if !should_update_selection { + self.sync_editor_highlight_for_selected_entry(cx); + return; + } + + let should_notify = + self.update_selection_for_cursor(&cursor_position, &multi_buffer_snapshot, true); + self.sync_editor_highlight_for_selected_entry(cx); + if should_notify { + cx.notify(); + } + } + + fn update_selection_for_cursor( + &mut self, + cursor: &Anchor, + snapshot: &MultiBufferSnapshot, + should_scroll: bool, + ) -> bool { + let cursor_point = cursor.to_point(snapshot); + let Some((cursor_buffer, cursor_point)) = snapshot.point_to_buffer_point(cursor_point) + else { + let changed = self.selected_item_ix.take().is_some(); + return changed; + }; + let cursor_buffer_id = cursor_buffer.remote_id(); + let best = self .display_items .iter() @@ -544,12 +532,17 @@ impl HighlightsTreeView { _ => None, }) .filter(|(_, _, entry)| { - entry.range.start.cmp(&cursor, snapshot).is_le() - && cursor.cmp(&entry.range.end, snapshot).is_lt() + entry.buffer_id == cursor_buffer_id + && entry.buffer_point_range.start <= cursor_point + && cursor_point < entry.buffer_point_range.end }) .min_by_key(|(_, _, entry)| { ( - entry.buffer_point_range.end.row - entry.buffer_point_range.start.row, + entry + .buffer_point_range + .end + .row + .saturating_sub(entry.buffer_point_range.start.row), entry .buffer_point_range .end @@ -559,11 +552,17 @@ impl HighlightsTreeView { }) .map(|(display_ix, entry_ix, _)| (display_ix, entry_ix)); - if let Some((display_ix, entry_ix)) = best { - self.selected_item_ix = Some(entry_ix); + let selected_item_ix = best.map(|(_, entry_ix)| entry_ix); + let changed = self.selected_item_ix != selected_item_ix; + self.selected_item_ix = selected_item_ix; + + if should_scroll && let Some((display_ix, _)) = best { self.list_scroll_handle .scroll_to_item(display_ix, ScrollStrategy::Center); + return true; } + + changed } fn update_editor_with_range_for_entry( @@ -707,6 +706,25 @@ impl HighlightsTreeView { ); } + fn sync_editor_highlight_for_selected_entry(&self, cx: &mut Context) { + let Some(editor_state) = self.editor.as_ref() else { + return; + }; + let key = cx.entity_id().as_u64() as usize; + let range = self + .selected_item_ix + .and_then(|entry_ix| self.cached_entries.get(entry_ix)) + .map(|entry| entry.range.clone()); + + editor_state.editor.update(cx, |editor, cx| { + if let Some(range) = range { + Self::set_editor_highlights(editor, key, &[range], cx); + } else { + editor.clear_background_highlights(HighlightKey::HighlightsTreeView(key), cx); + } + }); + } + fn clear_editor_highlights(editor: &Entity, cx: &mut Context) { let highlight_key = HighlightKey::HighlightsTreeView(cx.entity_id().as_u64() as usize); editor.update(cx, |editor, cx| { @@ -1105,6 +1123,142 @@ fn excerpt_label_for( path_label.into() } +fn build_highlight_entries( + HighlightRefreshInput { + multi_buffer_snapshot, + text_highlights, + semantic_highlights, + syntax_theme, + }: HighlightRefreshInput, +) -> Vec { + let mut entries = Vec::new(); + + for (key, text_highlights) in text_highlights { + for range in &text_highlights.1 { + let Some((range_display, buffer_id, buffer_point_range)) = + format_anchor_range(range, &multi_buffer_snapshot) + else { + continue; + }; + entries.push(HighlightEntry { + range: range.clone(), + buffer_id, + range_display, + style: text_highlights.0, + category: HighlightCategory::Text(key), + buffer_point_range, + }); + } + } + + for highlight in semantic_highlights { + let Some((range_display, buffer_id, buffer_point_range)) = + format_anchor_range(&highlight.range, &multi_buffer_snapshot) + else { + continue; + }; + + entries.push(HighlightEntry { + range: highlight.range, + buffer_id, + range_display, + style: highlight.style, + category: highlight.category, + buffer_point_range, + }); + } + + for excerpt_range in multi_buffer_snapshot.excerpts() { + let Some(buffer_snapshot) = + multi_buffer_snapshot.buffer_for_id(excerpt_range.context.start.buffer_id) + else { + continue; + }; + + let start_offset = excerpt_range.context.start.to_offset(buffer_snapshot); + let end_offset = excerpt_range.context.end.to_offset(buffer_snapshot); + let range = start_offset..end_offset; + + let captures = buffer_snapshot.captures(range, |grammar| { + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) + }); + let grammars = captures.grammars().to_vec(); + let highlight_maps = grammars + .iter() + .map(|grammar| grammar.highlight_map()) + .collect::>(); + + for capture in captures { + let Some(highlight_id) = highlight_maps[capture.grammar_index].get(capture.index) + else { + continue; + }; + let Some(style) = syntax_theme.get(highlight_id).cloned() else { + continue; + }; + + let theme_key = syntax_theme + .get_capture_name(highlight_id) + .map(|theme_key| SharedString::from(theme_key.to_string())); + + let capture_name = grammars[capture.grammar_index] + .highlights_config + .as_ref() + .and_then(|config| config.query.capture_names().get(capture.index as usize)) + .map(|capture_name| SharedString::from((*capture_name).to_string())) + .unwrap_or_else(|| SharedString::from("unknown")); + + let start_anchor = buffer_snapshot.anchor_before(capture.node.start_byte()); + let end_anchor = buffer_snapshot.anchor_after(capture.node.end_byte()); + + let start = multi_buffer_snapshot.anchor_in_excerpt(start_anchor); + let end = multi_buffer_snapshot.anchor_in_excerpt(end_anchor); + + let (start, end) = match (start, end) { + (Some(start), Some(end)) => (start, end), + _ => continue, + }; + + let range = start..end; + let Some((range_display, buffer_id, buffer_point_range)) = + format_anchor_range(&range, &multi_buffer_snapshot) + else { + continue; + }; + + entries.push(HighlightEntry { + range, + buffer_id, + range_display, + style, + category: HighlightCategory::SyntaxToken { + capture_name, + theme_key, + }, + buffer_point_range, + }); + } + } + + entries.sort_by(|a, b| { + a.buffer_id + .cmp(&b.buffer_id) + .then_with(|| a.buffer_point_range.start.cmp(&b.buffer_point_range.start)) + .then_with(|| a.buffer_point_range.end.cmp(&b.buffer_point_range.end)) + .then_with(|| a.category.cmp(&b.category)) + }); + entries.dedup_by(|a, b| { + a.buffer_id == b.buffer_id + && a.buffer_point_range == b.buffer_point_range + && a.category == b.category + }); + + entries +} + fn format_anchor_range( range: &Range, snapshot: &MultiBufferSnapshot, diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 8b7088dc228..e7c6d5b2160 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -13,12 +13,12 @@ use language::language_settings::{EditPredictionProvider, all_language_settings} use client::proto; use collections::HashSet; use editor::{Editor, EditorEvent}; -use gpui::{Anchor, App, Entity, Subscription, Task, TaskExt, WeakEntity, actions}; +use gpui::{Action as _, Anchor, App, Entity, Subscription, Task, TaskExt, WeakEntity, actions}; use language::{BinaryStatus, BufferId, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; use project::{ LspStore, LspStoreEvent, Worktree, lsp_store::log_store::GlobalLogStore, - project_settings::ProjectSettings, + project_settings::ProjectSettings, trusted_worktrees::TrustedWorktrees, }; use settings::{Settings as _, SettingsStore}; use ui::{ @@ -26,7 +26,7 @@ use ui::{ }; use util::{ResultExt, paths::PathExt, rel_path::RelPath}; -use workspace::{StatusItemView, Workspace}; +use workspace::{StatusItemView, ToggleWorktreeSecurity, Workspace}; use crate::lsp_log_view; @@ -221,6 +221,45 @@ impl LanguageServerState { return menu; }; + let is_restricted = self + .workspace + .upgrade() + .map(|workspace| { + let worktree_store = workspace.read(cx).project().read(cx).worktree_store(); + TrustedWorktrees::has_restricted_worktrees(&worktree_store, cx) + }) + .unwrap_or(false); + + if is_restricted { + menu = menu.custom_entry( + move |_window, _cx| { + v_flex() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::XSmall), + ) + .child( + Label::new("Project is in Restricted Mode") + .size(LabelSize::Small), + ), + ) + .child( + Label::new("Language Servers can't run until you trust this project.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + }, + move |window, cx| { + window.dispatch_action(ToggleWorktreeSecurity.boxed_clone(), cx); + }, + ); + } + let server_metadata = self .lsp_store .update(cx, |lsp_store, _| { @@ -832,12 +871,18 @@ impl LspButton { lsp_menu_refresh: Task::ready(()), _subscriptions: vec![settings_subscription, lsp_store_subscription], }; - if !lsp_button - .server_state - .read(cx) - .language_servers - .binary_statuses - .is_empty() + let is_restricted = TrustedWorktrees::has_restricted_worktrees( + &workspace.project().read(cx).worktree_store(), + cx, + ); + + if is_restricted + || !lsp_button + .server_state + .read(cx) + .language_servers + .binary_statuses + .is_empty() { lsp_button.refresh_lsp_menu(true, window, cx); } @@ -1258,7 +1303,20 @@ impl StatusItemView for LspButton { impl Render for LspButton { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl ui::IntoElement { - if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() { + let is_restricted = self + .server_state + .read(cx) + .workspace + .upgrade() + .map(|workspace| { + let worktree_store = workspace.read(cx).project().read(cx).worktree_store(); + TrustedWorktrees::has_restricted_worktrees(&worktree_store, cx) + }) + .unwrap_or(false); + + if !is_restricted + && (self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none()) + { return div().hidden(); } @@ -1288,7 +1346,12 @@ impl Render for LspButton { } } - let (indicator, description) = if has_errors { + let (indicator, description) = if is_restricted { + ( + Some(Indicator::dot().color(Color::Warning)), + "Restricted Mode", + ) + } else if has_errors { ( Some(Indicator::dot().color(Color::Error)), "Server with errors", @@ -1333,6 +1396,7 @@ impl Render for LspButton { IconButton::new("zed-lsp-tool-button", IconName::BoltOutlined) .when_some(indicator, IconButton::indicator) .icon_size(IconSize::Small) + .when(is_restricted, |s| s.icon_color(Color::Warning)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)), move |_window, cx| { Tooltip::with_meta("Language Servers", Some(&ToggleMenu), description, cx) diff --git a/crates/languages/src/bash.rs b/crates/languages/src/bash.rs index 438090e2aa9..2335b0a1700 100644 --- a/crates/languages/src/bash.rs +++ b/crates/languages/src/bash.rs @@ -76,7 +76,7 @@ impl LspInstaller for BashLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &gpui::AsyncApp, ) -> Option { @@ -130,7 +130,7 @@ impl LspInstaller for BashLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut gpui::AsyncApp, ) -> Result { @@ -141,7 +141,7 @@ impl LspInstaller for BashLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: std::path::PathBuf, delegate: &Arc, ) -> impl Send + Future> + use<> { @@ -152,13 +152,9 @@ impl LspInstaller for BashLspAdapter { let server_path = container_dir .join("node_modules") .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; let env = delegate.shell_env().await; Ok(LanguageServerBinary { diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index d2e92904c6d..80d794cd617 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -23,7 +23,7 @@ impl LspInstaller for CLspAdapter { async fn fetch_latest_server_version( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, pre_release: bool, _: &mut AsyncApp, ) -> Result { @@ -54,7 +54,7 @@ impl LspInstaller for CLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { diff --git a/crates/languages/src/css.rs b/crates/languages/src/css.rs index dfa0bc9fd3d..5f796ed78ef 100644 --- a/crates/languages/src/css.rs +++ b/crates/languages/src/css.rs @@ -38,7 +38,7 @@ impl LspInstaller for CssLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -49,7 +49,7 @@ impl LspInstaller for CssLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { @@ -67,7 +67,7 @@ impl LspInstaller for CssLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -75,13 +75,9 @@ impl LspInstaller for CssLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index 063cf85affd..2d0c88bd016 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -83,7 +83,7 @@ impl LspInstaller for EsLintLspAdapter { async fn fetch_latest_server_version( &self, - _delegate: &dyn LspAdapterDelegate, + _delegate: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 3bedd62b8e6..75aaf8048ad 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -69,7 +69,7 @@ impl LspInstaller for GoLspAdapter { async fn fetch_latest_server_version( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: bool, cx: &mut AsyncApp, ) -> Result> { @@ -106,7 +106,7 @@ impl LspInstaller for GoLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 9cd6c1565ad..b97429c685b 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -150,7 +150,7 @@ impl LspInstaller for JsonLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -161,7 +161,7 @@ impl LspInstaller for JsonLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { @@ -213,7 +213,7 @@ impl LspInstaller for JsonLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -221,13 +221,9 @@ impl LspInstaller for JsonLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, @@ -438,7 +434,7 @@ impl LspInstaller for NodeVersionAdapter { async fn fetch_latest_server_version( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -475,7 +471,7 @@ impl LspInstaller for NodeVersionAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 483430bd75d..5a3f6939830 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -399,7 +399,7 @@ impl LspInstaller for TyLspAdapter { type BinaryVersion = GitHubLspBinaryVersion; async fn fetch_latest_server_version( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -420,7 +420,7 @@ impl LspInstaller for TyLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, toolchain: Option, _: &AsyncApp, ) -> Option { @@ -744,7 +744,7 @@ impl LspInstaller for PyrightLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -755,7 +755,7 @@ impl LspInstaller for PyrightLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { @@ -786,7 +786,7 @@ impl LspInstaller for PyrightLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, delegate: &Arc, ) -> impl Send + Future> + use<> { @@ -795,13 +795,8 @@ impl LspInstaller for PyrightLspAdapter { async move { let server_path = container_dir.join(Self::SERVER_PATH); - let latest_version = latest_version.to_string(); - - node.npm_install_packages( - &container_dir, - &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::SERVER_NAME.as_ref()]) + .await?; let env = delegate.shell_env().await; Ok(LanguageServerBinary { @@ -1910,7 +1905,7 @@ impl LspInstaller for PyLspAdapter { type BinaryVersion = (); async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, toolchain: Option, _: &AsyncApp, ) -> Option { @@ -1959,7 +1954,7 @@ impl LspInstaller for PyLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result<()> { @@ -2209,7 +2204,7 @@ impl LspInstaller for BasedPyrightLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -2220,7 +2215,7 @@ impl LspInstaller for BasedPyrightLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { @@ -2252,7 +2247,7 @@ impl LspInstaller for BasedPyrightLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, delegate: &Arc, ) -> impl Send + Future> + use<> { @@ -2261,13 +2256,8 @@ impl LspInstaller for BasedPyrightLspAdapter { async move { let server_path = container_dir.join(Self::SERVER_PATH); - let latest_version = latest_version.to_string(); - - node.npm_install_packages( - &container_dir, - &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::SERVER_NAME.as_ref()]) + .await?; let env = delegate.shell_env().await; Ok(LanguageServerBinary { @@ -2548,7 +2538,7 @@ impl LspInstaller for RuffLspAdapter { type BinaryVersion = GitHubLspBinaryVersion; async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, toolchain: Option, _: &AsyncApp, ) -> Option { @@ -2578,7 +2568,7 @@ impl LspInstaller for RuffLspAdapter { async fn fetch_latest_server_version( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index de219d30928..445bcfa5de3 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -27,7 +27,7 @@ use std::{ sync::{Arc, LazyLock}, }; use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName}; -use util::command::Stdio; +use util::command::{Stdio, new_command}; use util::fs::{make_file_executable, remove_matching}; use util::merge_json_value_into; use util::rel_path::RelPath; @@ -200,6 +200,58 @@ impl RustLspAdapter { format!("{}-{}", Self::ARCH_SERVER_NAME, libc) } + async fn rustup_rust_analyzer_for_worktree( + delegate: &dyn LspAdapterDelegate, + ) -> Option { + if !Self::workspace_has_rust_toolchain_override(delegate).await { + return None; + } + + let rustup = delegate.which("rustup".as_ref()).await?; + let env = delegate.shell_env().await; + let worktree_root = delegate.worktree_root_path(); + let output = new_command(rustup) + .args(["which", "rust-analyzer"]) + .envs(env.iter()) + .current_dir(worktree_root) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await; + let output = match output { + Ok(output) if output.status.success() => output, + Ok(output) => { + log::debug!( + "failed to locate rust-analyzer through rustup in {worktree_root:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + return None; + } + Err(err) => { + log::debug!( + "failed to run `rustup which rust-analyzer` in {worktree_root:?}: {err:#}" + ); + return None; + } + }; + + let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()); + Some(path).filter(|p| !p.as_os_str().is_empty()) + } + + async fn workspace_has_rust_toolchain_override(delegate: &dyn LspAdapterDelegate) -> bool { + for file_name in ["rust-toolchain.toml", "rust-toolchain"] { + if fs::metadata(delegate.resolve_relative_path(PathBuf::from(file_name))) + .await + .is_ok() + { + return true; + } + } + + false + } + async fn build_asset_name() -> String { let extension = match Self::GITHUB_ASSET_KIND { AssetKind::TarGz => "tar.gz", @@ -671,42 +723,64 @@ impl LspInstaller for RustLspAdapter { type BinaryVersion = GitHubLspBinaryVersion; async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, - _: &AsyncApp, + cx: &AsyncApp, ) -> Option { - let path = delegate.which("rust-analyzer".as_ref()).await?; - let env = delegate.shell_env().await; + let delegate = delegate.clone(); + cx.background_spawn(async move { + let env = delegate.shell_env().await; + if let Some(path) = Self::rustup_rust_analyzer_for_worktree(delegate.as_ref()).await { + let result = delegate + .try_exec(LanguageServerBinary { + path: path.clone(), + arguments: vec!["--help".into()], + env: Some(env.clone()), + }) + .await; + if result.is_ok() { + log::debug!("found rust-analyzer in rustup toolchain override"); + return Some(LanguageServerBinary { + path, + env: Some(env), + arguments: vec![], + }); + } + } - // It is surprisingly common for ~/.cargo/bin/rust-analyzer to be a symlink to - // /usr/bin/rust-analyzer that fails when you run it; so we need to test it. - log::debug!("found rust-analyzer in PATH. trying to run `rust-analyzer --help`"); - let result = delegate - .try_exec(LanguageServerBinary { - path: path.clone(), - arguments: vec!["--help".into()], - env: Some(env.clone()), - }) - .await; - if let Err(err) = result { - log::debug!( - "failed to run rust-analyzer after detecting it in PATH: binary: {:?}: {}", + let path = delegate.which("rust-analyzer".as_ref()).await?; + + // It is surprisingly common for ~/.cargo/bin/rust-analyzer to be a symlink to + // /usr/bin/rust-analyzer that fails when you run it; so we need to test it. + log::debug!("found rust-analyzer in PATH. trying to run `rust-analyzer --help`"); + let result = delegate + .try_exec(LanguageServerBinary { + path: path.clone(), + arguments: vec!["--help".into()], + env: Some(env.clone()), + }) + .await; + if let Err(err) = result { + log::debug!( + "failed to run rust-analyzer after detecting it in PATH: binary: {:?}: {}", + path, + err + ); + return None; + } + + Some(LanguageServerBinary { path, - err - ); - return None; - } - - Some(LanguageServerBinary { - path, - env: Some(env), - arguments: vec![], + env: Some(env), + arguments: vec![], + }) }) + .await } async fn fetch_latest_server_version( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, pre_release: bool, _: &mut AsyncApp, ) -> Result { diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index 41fa248a935..0af7ae68b2c 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -45,7 +45,7 @@ impl LspInstaller for TailwindLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -56,7 +56,7 @@ impl LspInstaller for TailwindLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { @@ -72,7 +72,7 @@ impl LspInstaller for TailwindLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -80,13 +80,9 @@ impl LspInstaller for TailwindLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, diff --git a/crates/languages/src/tailwindcss.rs b/crates/languages/src/tailwindcss.rs index dcc9e8bf4ef..ac5e12e07ff 100644 --- a/crates/languages/src/tailwindcss.rs +++ b/crates/languages/src/tailwindcss.rs @@ -41,7 +41,7 @@ impl LspInstaller for TailwindCssLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -52,7 +52,7 @@ impl LspInstaller for TailwindCssLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { @@ -68,7 +68,7 @@ impl LspInstaller for TailwindCssLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -76,13 +76,9 @@ impl LspInstaller for TailwindCssLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index d6889d8cbb8..5572e43f049 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -654,7 +654,7 @@ impl LspInstaller for TypeScriptLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -718,7 +718,7 @@ impl LspInstaller for TypeScriptLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -726,15 +726,10 @@ impl LspInstaller for TypeScriptLspAdapter { async move { let server_path = container_dir.join(Self::NEW_SERVER_PATH); - let typescript_version = latest_version.typescript_version.to_string(); - let server_version = latest_version.server_version.to_string(); - node.npm_install_packages( + node.npm_install_latest_packages( &container_dir, - &[ - (Self::PACKAGE_NAME, typescript_version.as_str()), - (Self::SERVER_PACKAGE_NAME, server_version.as_str()), - ], + &[Self::PACKAGE_NAME, Self::SERVER_PACKAGE_NAME], ) .await?; diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 4bc4401ff30..acf8aea5e59 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -96,7 +96,7 @@ impl LspInstaller for VtslsLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -111,7 +111,7 @@ impl LspInstaller for VtslsLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { @@ -126,7 +126,7 @@ impl LspInstaller for VtslsLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -135,21 +135,44 @@ impl LspInstaller for VtslsLspAdapter { async move { let server_path = container_dir.join(Self::SERVER_PATH); - let typescript_version = latest_version.typescript_version.to_string(); - let server_version = latest_version.server_version.to_string(); + node.npm_install_latest_packages( + &container_dir, + &[Self::PACKAGE_NAME, Self::TYPESCRIPT_PACKAGE_NAME], + ) + .await?; - let mut packages_to_install = Vec::new(); + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: typescript_server_binary_arguments(&server_path), + }) + } + } + + fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let typescript_version = version.typescript_version.clone(); + let server_version = version.server_version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(Self::SERVER_PATH); if node .should_install_npm_package( Self::PACKAGE_NAME, &server_path, &container_dir, - VersionStrategy::Latest(&latest_version.server_version), + VersionStrategy::Latest(&server_version), ) .await { - packages_to_install.push((Self::PACKAGE_NAME, server_version.as_str())); + return None; } if node @@ -157,19 +180,15 @@ impl LspInstaller for VtslsLspAdapter { Self::TYPESCRIPT_PACKAGE_NAME, &container_dir.join(Self::TYPESCRIPT_TSDK_PATH), &container_dir, - VersionStrategy::Latest(&latest_version.typescript_version), + VersionStrategy::Latest(&typescript_version), ) .await { - packages_to_install - .push((Self::TYPESCRIPT_PACKAGE_NAME, typescript_version.as_str())); + return None; } - node.npm_install_packages(&container_dir, &packages_to_install) - .await?; - - Ok(LanguageServerBinary { - path: node.binary_path().await?, + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, env: None, arguments: typescript_server_binary_arguments(&server_path), }) diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index 22781acf25a..41586a469fd 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -41,7 +41,7 @@ impl LspInstaller for YamlLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result { @@ -52,7 +52,7 @@ impl LspInstaller for YamlLspAdapter { async fn check_if_user_installed( &self, - delegate: &dyn LspAdapterDelegate, + delegate: &Arc, _: Option, _: &AsyncApp, ) -> Option { @@ -68,7 +68,7 @@ impl LspInstaller for YamlLspAdapter { fn fetch_server_binary( &self, - latest_version: Self::BinaryVersion, + _latest_version: Self::BinaryVersion, container_dir: PathBuf, _: &Arc, ) -> impl Send + Future> + use<> { @@ -76,13 +76,9 @@ impl LspInstaller for YamlLspAdapter { async move { let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); - node.npm_install_packages( - &container_dir, - &[(Self::PACKAGE_NAME, latest_version.as_str())], - ) - .await?; + node.npm_install_latest_packages(&container_dir, &[Self::PACKAGE_NAME]) + .await?; Ok(LanguageServerBinary { path: node.binary_path().await?, diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index cea5b1169b0..8a72b5df40e 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -49,8 +49,27 @@ pub(crate) struct AudioStack { impl AudioStack { pub(crate) fn new(executor: BackgroundExecutor) -> Self { + // AGC2's `adaptive_digital` is what actually levels speech toward a target; + // the `gain_controller2.enabled` master switch alone leaves it off, which + // historically meant capture was effectively unleveled. Defaults match + // what Chrome/Meet ship with -- in particular `max_gain_db = 50` paired + // with `max_output_noise_level_dbfs = -50`, which lets the AGC reach + // very quiet talkers while the noise-level estimator backs off before + // boosting amplifies the noise floor. let apm = Arc::new(Mutex::new(apm::AudioProcessingModule::new( - true, true, true, true, + apm::AudioProcessingConfig { + echo_canceller_enabled: true, + gain_controller2: apm::GainController2Config { + enabled: true, + adaptive_digital: apm::AdaptiveDigitalConfig { + enabled: true, + ..Default::default() + }, + ..Default::default() + }, + high_pass_filter_enabled: true, + noise_suppression_enabled: true, + }, ))); let mixer = Arc::new(Mutex::new(audio_mixer::AudioMixer::new())); Self { diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 00fe0f59f1c..c9ccfa4a3c2 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -1000,6 +1000,10 @@ impl LanguageServer { color_provider: Some(DocumentColorClientCapabilities { dynamic_registration: Some(true), }), + document_link: Some(DocumentLinkClientCapabilities { + dynamic_registration: Some(true), + tooltip_support: Some(true), + }), folding_range: Some(FoldingRangeClientCapabilities { dynamic_registration: Some(true), line_folding_only: Some(false), @@ -2079,7 +2083,7 @@ mod tests { use gpui::TestAppContext; use std::str::FromStr; - #[ctor::ctor] + #[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index be12bf2fe7f..3982cf9506a 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -29,7 +29,7 @@ language.workspace = true linkify.workspace = true log.workspace = true markup5ever_rcdom.workspace = true -mermaid-rs-renderer.workspace = true +mermaid_render = { path = "../mermaid_render" } pulldown-cmark.workspace = true settings.workspace = true stacksafe.workspace = true diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 6387164922c..25e869625fb 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -561,6 +561,8 @@ mod tests { }); } + use crate::WrapButtonVisibility; + fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText { struct TestWindow; @@ -582,6 +584,7 @@ mod tests { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }, ) @@ -642,6 +645,7 @@ mod tests { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }, ) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 69feee416da..7fcbf393fb4 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -34,13 +34,14 @@ use gpui::{ FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image, ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, - StyleRefinement, StyledImage, StyledText, Task, TextAlign, TextLayout, TextRun, TextStyle, - TextStyleRefinement, actions, img, point, quad, + StyleRefinement, StyledImage, StyledText, Subscription, Task, TextAlign, TextLayout, TextRun, + TextStyle, TextStyleRefinement, actions, img, point, quad, }; use language::{CharClassifier, Language, LanguageRegistry, Rope}; use parser::CodeBlockMetadata; use parser::{ - MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options, + MarkdownEvent, MarkdownTag, MarkdownTagEnd, ParsedMetadataBlock, parse_links_only, + parse_markdown_with_options, }; use pulldown_cmark::{Alignment, BlockQuoteKind}; use sum_tree::TreeMap; @@ -53,6 +54,7 @@ use crate::parser::CodeBlockKind; /// A callback function that can be used to customize the style of links based on the destination URL. /// If the callback returns `None`, the default link style will be used. type LinkStyleCallback = Rc Option>; +pub type CodeSpanLinkCallback = Arc Option + 'static>; type SourceClickCallback = Box bool>; type CheckboxToggleCallback = Rc, bool, &mut Window, &mut App)>; @@ -332,8 +334,10 @@ pub struct Markdown { fallback_code_block_language: Option, options: MarkdownOptions, mermaid_state: MermaidState, + _mermaid_theme_subscription: Option, mermaid_showing_code: HashSet, copied_code_blocks: HashSet, + wrapped_code_blocks: HashSet, code_block_scroll_handles: BTreeMap, context_menu_link: Option, context_menu_selected_text: Option, @@ -347,6 +351,7 @@ pub struct MarkdownOptions { pub parse_html: bool, pub render_mermaid_diagrams: bool, pub parse_heading_slugs: bool, + pub render_metadata_blocks: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -356,9 +361,17 @@ pub enum CopyButtonVisibility { VisibleOnHover, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WrapButtonVisibility { + Hidden, + AlwaysVisible, + VisibleOnHover, +} + pub enum CodeBlockRenderer { Default { copy_button_visibility: CopyButtonVisibility, + wrap_button_visibility: WrapButtonVisibility, border: bool, }, Custom { @@ -401,12 +414,12 @@ enum EscapeAction { } impl EscapeAction { - fn output_len(&self) -> usize { + fn output_len(&self, c: char) -> usize { match self { - Self::PassThrough => 1, + Self::PassThrough => c.len_utf8(), Self::Nbsp(count) => count * '\u{00A0}'.len_utf8(), Self::DoubleNewline => 2, - Self::PrefixBackslash => 2, + Self::PrefixBackslash => '\\'.len_utf8() + c.len_utf8(), } } @@ -431,8 +444,6 @@ impl EscapeAction { } } -// Valid to operate on raw bytes since multi-byte UTF-8 -// sequences never contain ASCII-range bytes. struct MarkdownEscaper { in_leading_whitespace: bool, } @@ -446,21 +457,21 @@ impl MarkdownEscaper { } } - fn next(&mut self, byte: u8) -> EscapeAction { - let action = if self.in_leading_whitespace && byte == b'\t' { + fn next(&mut self, c: char) -> EscapeAction { + let action = if self.in_leading_whitespace && c == '\t' { EscapeAction::Nbsp(Self::TAB_SIZE) - } else if self.in_leading_whitespace && byte == b' ' { + } else if self.in_leading_whitespace && c == ' ' { EscapeAction::Nbsp(1) - } else if byte == b'\n' { + } else if c == '\n' { EscapeAction::DoubleNewline - } else if byte.is_ascii_punctuation() { + } else if c.is_ascii_punctuation() { EscapeAction::PrefixBackslash } else { EscapeAction::PassThrough }; self.in_leading_whitespace = - byte == b'\n' || (self.in_leading_whitespace && (byte == b' ' || byte == b'\t')); + c == '\n' || (self.in_leading_whitespace && (c == ' ' || c == '\t')); action } } @@ -489,6 +500,16 @@ impl Markdown { cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); + + let theme_subscription = if options.render_mermaid_diagrams { + Some( + cx.observe_global::(|this: &mut Self, cx| { + this.invalidate_mermaid_cache(cx); + }), + ) + } else { + None + }; let mut this = Self { source, selection: Selection::default(), @@ -505,8 +526,10 @@ impl Markdown { fallback_code_block_language, options, mermaid_state: MermaidState::default(), + _mermaid_theme_subscription: theme_subscription, mermaid_showing_code: HashSet::default(), copied_code_blocks: HashSet::default(), + wrapped_code_blocks: HashSet::default(), code_block_scroll_handles: BTreeMap::default(), context_menu_link: None, context_menu_selected_text: None, @@ -530,6 +553,16 @@ impl Markdown { ) } + fn is_code_block_wrapped(&self, id: usize) -> bool { + self.wrapped_code_blocks.contains(&id) + } + + fn toggle_code_block_wrap(&mut self, id: usize) { + if !self.wrapped_code_blocks.remove(&id) { + self.wrapped_code_blocks.insert(id); + } + } + fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle { self.code_block_scroll_handles .entry(id) @@ -542,15 +575,15 @@ impl Markdown { .retain(|id, _| ids.contains(id)); } - /// Used in the agent panel to force a re-render when the theme changes pub fn invalidate_mermaid_cache(&mut self, cx: &mut Context) { - if self.options.render_mermaid_diagrams && !self.parsed_markdown.mermaid_diagrams.is_empty() + if !self.options.render_mermaid_diagrams || self.parsed_markdown.mermaid_diagrams.is_empty() { - self.mermaid_state.clear(); - let parsed_markdown = self.parsed_markdown.clone(); - self.mermaid_state.update(&parsed_markdown, cx); - cx.notify(); + return; } + + self.mermaid_state.clear(); + self.mermaid_state.update(&self.parsed_markdown, cx); + cx.notify(); } pub(crate) fn is_mermaid_showing_code(&self, source_offset: usize) -> bool { @@ -611,6 +644,28 @@ impl Markdown { &self.source } + pub fn first_code_block_language(&self) -> Option> { + self.parsed_markdown.events.iter().find_map(|(_, event)| { + let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, .. }) = event else { + return None; + }; + + match kind { + CodeBlockKind::FencedLang(language) => self + .parsed_markdown + .languages_by_name + .get(language) + .cloned(), + CodeBlockKind::FencedSrc(path_range) => self + .parsed_markdown + .languages_by_path + .get(&path_range.path) + .cloned(), + CodeBlockKind::Fenced | CodeBlockKind::Indented => None, + } + }) + } + pub fn append(&mut self, text: &str, cx: &mut Context) { self.source = SharedString::new(self.source.to_string() + text); self.parse(cx); @@ -675,7 +730,7 @@ impl Markdown { pub fn escape(s: &str) -> Cow<'_, str> { let output_len: usize = { let mut escaper = MarkdownEscaper::new(); - s.bytes().map(|byte| escaper.next(byte).output_len()).sum() + s.chars().map(|c| escaper.next(c).output_len(c)).sum() }; if output_len == s.len() { @@ -685,7 +740,7 @@ impl Markdown { let mut escaper = MarkdownEscaper::new(); let mut output = String::with_capacity(output_len); for c in s.chars() { - escaper.next(c as u8).write_to(c, &mut output); + escaper.next(c).write_to(c, &mut output); } output.into() } @@ -794,6 +849,7 @@ impl Markdown { let should_parse_html = self.options.parse_html; let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams; let should_parse_heading_slugs = self.options.parse_heading_slugs; + let should_parse_metadata_blocks = self.options.render_metadata_blocks; let language_registry = self.language_registry.clone(); let fallback = self.fallback_code_block_language.clone(); @@ -807,6 +863,7 @@ impl Markdown { languages_by_path: TreeMap::default(), root_block_starts: Arc::default(), html_blocks: BTreeMap::default(), + metadata_blocks: BTreeMap::default(), mermaid_diagrams: BTreeMap::default(), heading_slugs: HashMap::default(), footnote_definitions: HashMap::default(), @@ -815,13 +872,18 @@ impl Markdown { ); } - let parsed = - parse_markdown_with_options(&source, should_parse_html, should_parse_heading_slugs); + let parsed = parse_markdown_with_options( + &source, + should_parse_html, + should_parse_heading_slugs, + should_parse_metadata_blocks, + ); let events = parsed.events; let language_names = parsed.language_names; let paths = parsed.language_paths; let root_block_starts = parsed.root_block_starts; let html_blocks = parsed.html_blocks; + let metadata_blocks = parsed.metadata_blocks; let heading_slugs = parsed.heading_slugs; let footnote_definitions = parsed.footnote_definitions; let mermaid_diagrams = if should_render_mermaid_diagrams { @@ -889,6 +951,7 @@ impl Markdown { languages_by_path, root_block_starts: Arc::from(root_block_starts), html_blocks, + metadata_blocks, mermaid_diagrams, heading_slugs, footnote_definitions, @@ -1017,6 +1080,7 @@ pub struct ParsedMarkdown { pub languages_by_path: TreeMap, Arc>, pub root_block_starts: Arc<[usize]>, pub(crate) html_blocks: BTreeMap, + pub(crate) metadata_blocks: BTreeMap, pub(crate) mermaid_diagrams: BTreeMap, pub heading_slugs: HashMap, pub footnote_definitions: HashMap, @@ -1061,6 +1125,7 @@ pub struct MarkdownElement { style: MarkdownStyle, code_block_renderer: CodeBlockRenderer, on_url_click: Option>, + code_span_link: Option, on_source_click: Option, on_checkbox_toggle: Option, image_resolver: Option Option>>, @@ -1075,9 +1140,11 @@ impl MarkdownElement { style, code_block_renderer: CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::VisibleOnHover, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }, on_url_click: None, + code_span_link: None, on_source_click: None, on_checkbox_toggle: None, image_resolver: None, @@ -1120,6 +1187,14 @@ impl MarkdownElement { self } + pub fn on_code_span_link( + mut self, + callback: impl Fn(&str, &App) -> Option + 'static, + ) -> Self { + self.code_span_link = Some(Arc::new(callback)); + self + } + pub fn on_source_click( mut self, handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static, @@ -1154,6 +1229,44 @@ impl MarkdownElement { self } + fn push_markdown_code_span( + &self, + builder: &mut MarkdownElementBuilder, + text: &str, + range: Range, + cx: &App, + ) { + let link_url = if builder.code_block_stack.is_empty() + && builder.link_depth == 0 + && !self.style.prevent_mouse_interaction + { + self.code_span_link + .as_ref() + .and_then(|callback| callback(text, cx)) + } else { + None + }; + + if let Some(url) = link_url { + builder.push_link(url.clone(), range.clone()); + let link_style = self + .style + .link_callback + .as_ref() + .and_then(|callback| callback(url.as_ref(), cx)) + .unwrap_or_else(|| self.style.link.clone()); + builder.push_text_style(self.style.inline_code.clone()); + builder.push_text_style(link_style); + builder.push_text(text, range); + builder.pop_text_style(); + builder.pop_text_style(); + } else { + builder.push_text_style(self.style.inline_code.clone()); + builder.push_text(text, range); + builder.pop_text_style(); + } + } + fn push_markdown_image( &self, builder: &mut MarkdownElementBuilder, @@ -1296,6 +1409,114 @@ impl MarkdownElement { builder.pop_text_style(); } + fn push_metadata_block( + &self, + builder: &mut MarkdownElementBuilder, + source: &str, + metadata_block: &ParsedMetadataBlock, + markdown_end: usize, + cx: &App, + ) { + let content_range = &metadata_block.content_range; + if let Some(rows) = metadata_block.rows.as_deref() { + builder.push_div( + div() + .grid() + .grid_cols(2) + .w_full() + .mb_2() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_sm() + .overflow_hidden(), + content_range, + markdown_end, + ); + + for (row_index, row) in rows.iter().enumerate() { + self.push_metadata_cell( + builder, + source, + row.key.clone(), + content_range, + markdown_end, + MetadataCellStyle { + row_index, + is_key: true, + }, + cx, + ); + self.push_metadata_cell( + builder, + source, + row.value.clone(), + content_range, + markdown_end, + MetadataCellStyle { + row_index, + is_key: false, + }, + cx, + ); + } + + builder.pop_div(); + } else { + let mut metadata_block = div().w_full().rounded_md(); + metadata_block.style().refine(&self.style.code_block); + builder.push_text_style(self.style.code_block.text.to_owned()); + builder.push_code_block(None); + builder.push_div(metadata_block, content_range, markdown_end); + builder.push_text(&source[content_range.clone()], content_range.clone()); + builder.trim_trailing_newline(); + builder.pop_div(); + builder.pop_code_block(); + builder.pop_text_style(); + } + } + + fn push_metadata_cell( + &self, + builder: &mut MarkdownElementBuilder, + source: &str, + text_range: Range, + block_range: &Range, + markdown_end: usize, + cell_style: MetadataCellStyle, + cx: &App, + ) { + builder.push_div( + div() + .flex() + .flex_col() + .min_w_0() + .px_2() + .py_1() + .border_color(cx.theme().colors().border) + .when(cell_style.row_index > 0, |this| this.border_t_1()) + .when(!cell_style.is_key, |this| this.border_l_1()) + .when(cell_style.is_key, |this| { + this.bg(cx.theme().colors().panel_background) + }), + block_range, + markdown_end, + ); + + let text_style = if cell_style.is_key { + TextStyleRefinement { + color: Some(cx.theme().colors().text_muted), + font_weight: Some(FontWeight::SEMIBOLD), + ..Default::default() + } + } else { + TextStyleRefinement::default() + }; + builder.push_text_style(text_style); + builder.push_text(&source[text_range.clone()], text_range); + builder.pop_text_style(); + builder.pop_div(); + } + fn push_markdown_list_item( &self, builder: &mut MarkdownElementBuilder, @@ -1466,18 +1687,28 @@ impl MarkdownElement { return; } } - let (range, mode) = match event.click_count { + let (range, mode, reversed) = match event.click_count { + 1 if event.modifiers.shift => { + let tail = markdown.selection.tail(); + let reversed = source_index < tail; + let range = if reversed { + source_index..tail + } else { + tail..source_index + }; + (range, SelectMode::Character, reversed) + } 1 => { let range = source_index..source_index; - (range, SelectMode::Character) + (range, SelectMode::Character, false) } 2 => { let range = rendered_text.surrounding_word_range(source_index); - (range.clone(), SelectMode::Word(range)) + (range.clone(), SelectMode::Word(range), false) } 3 => { let range = rendered_text.surrounding_line_range(source_index); - (range.clone(), SelectMode::Line(range)) + (range.clone(), SelectMode::Line(range), false) } _ => { let range = 0..rendered_text @@ -1485,13 +1716,13 @@ impl MarkdownElement { .last() .map(|line| line.source_end) .unwrap_or(0); - (range, SelectMode::All) + (range, SelectMode::All, false) } }; markdown.selection = Selection { start: range.start, end: range.end, - reversed: false, + reversed, pending: true, mode, }; @@ -1697,6 +1928,7 @@ impl Element for MarkdownElement { let mut current_img_block_range: Option> = None; let mut handled_html_block = false; let mut rendered_mermaid_block = false; + let mut rendered_metadata_block = false; for (index, (range, event)) in parsed_markdown.events.iter().enumerate() { // Skip alt text for images that rendered if let Some(current_img_block_range) = ¤t_img_block_range @@ -1720,6 +1952,13 @@ impl Element for MarkdownElement { continue; } + if rendered_metadata_block { + if matches!(event, MarkdownEvent::End(MarkdownTagEnd::MetadataBlock(_))) { + rendered_metadata_block = false; + } + continue; + } + match event { MarkdownEvent::RootStart => { if self.show_root_block_markers { @@ -1893,11 +2132,18 @@ impl Element for MarkdownElement { parent_container.style().refine(&self.style.code_block); builder.push_div(parent_container, range, markdown_end); + let is_wrapped = + self.markdown.read(cx).is_code_block_wrapped(range.start); + let code_block = div() .id(("code-block", range.start)) .rounded_lg() .map(|mut code_block| { - if let Some(scroll_handle) = scroll_handle.as_ref() { + if is_wrapped { + code_block.w_full() + } else if let Some(scroll_handle) = + scroll_handle.as_ref() + { code_block.style().restrict_scroll_to_axis = Some(true); code_block @@ -1987,6 +2233,7 @@ impl Element for MarkdownElement { } MarkdownTag::Link { dest_url, .. } => { if builder.code_block_stack.is_empty() { + builder.link_depth += 1; builder.push_link(dest_url.clone(), range.clone()); let style = self .style @@ -2027,7 +2274,20 @@ impl Element for MarkdownElement { ); builder.push_div(div().flex_1().w_0(), range, markdown_end); } - MarkdownTag::MetadataBlock(_) => {} + MarkdownTag::MetadataBlock(_) => { + if let Some(metadata_block) = + parsed_markdown.metadata_blocks.get(&range.start) + { + self.push_metadata_block( + &mut builder, + &parsed_markdown.source, + metadata_block, + markdown_end, + cx, + ); + rendered_metadata_block = true; + } + } MarkdownTag::Table(alignments) => { builder.table.start(alignments.clone()); @@ -2136,10 +2396,14 @@ impl Element for MarkdownElement { if let CodeBlockRenderer::Default { copy_button_visibility, + wrap_button_visibility, .. } = &self.code_block_renderer - && *copy_button_visibility != CopyButtonVisibility::Hidden + && (*copy_button_visibility != CopyButtonVisibility::Hidden + || *wrap_button_visibility != WrapButtonVisibility::Hidden) { + let copy_button_visibility = *copy_button_visibility; + let wrap_button_visibility = *wrap_button_visibility; builder.modify_current_div(|el| { let content_range = parser::extract_code_block_content_range( &parsed_markdown.source()[range.clone()], @@ -2148,28 +2412,48 @@ impl Element for MarkdownElement { ..content_range.end + range.start; let code = parsed_markdown.source()[content_range].to_string(); - let codeblock = render_copy_code_block_button( - range.end, - code, - self.markdown.clone(), - ); - el.child( - h_flex() - .w_4() - .absolute() - .justify_end() - .when_else( - *copy_button_visibility - == CopyButtonVisibility::VisibleOnHover, - |this| { - this.top_0() - .right_0() - .visible_on_hover("code_block") - }, - |this| this.top_1p5().right_1p5(), - ) - .child(codeblock), - ) + + let any_hover = copy_button_visibility + == CopyButtonVisibility::VisibleOnHover + || wrap_button_visibility + == WrapButtonVisibility::VisibleOnHover; + let any_always = copy_button_visibility + == CopyButtonVisibility::AlwaysVisible + || wrap_button_visibility + == WrapButtonVisibility::AlwaysVisible; + let use_hover = any_hover && !any_always; + + let mut button_row = h_flex() + .gap_0p5() + .absolute() + .bg(cx.theme().colors().editor_background) + .when_else( + use_hover, + |this| { + this.top_1().right_1().visible_on_hover("code_block") + }, + |this| this.top_1p5().right_1p5(), + ); + + if wrap_button_visibility != WrapButtonVisibility::Hidden { + let is_wrapped = + self.markdown.read(cx).is_code_block_wrapped(range.start); + button_row = button_row.child(render_wrap_code_block_button( + range.start, + is_wrapped, + self.markdown.clone(), + )); + } + + if copy_button_visibility != CopyButtonVisibility::Hidden { + button_row = button_row.child(render_copy_code_block_button( + range.end, + code, + self.markdown.clone(), + )); + } + + el.child(button_row) }); } @@ -2189,6 +2473,7 @@ impl Element for MarkdownElement { MarkdownTagEnd::Strikethrough => builder.pop_text_style(), MarkdownTagEnd::Link => { if builder.code_block_stack.is_empty() { + builder.link_depth = builder.link_depth.saturating_sub(1); builder.pop_text_style() } } @@ -2214,6 +2499,7 @@ impl Element for MarkdownElement { builder.pop_div(); builder.pop_div(); } + MarkdownTagEnd::MetadataBlock(_) => {} _ => log::debug!("unsupported markdown tag end: {:?}", tag), }, MarkdownEvent::Text => { @@ -2223,9 +2509,12 @@ impl Element for MarkdownElement { builder.push_text(text, range.clone()); } MarkdownEvent::Code => { - builder.push_text_style(self.style.inline_code.clone()); - builder.push_text(&parsed_markdown.source[range.clone()], range.clone()); - builder.pop_text_style(); + self.push_markdown_code_span( + &mut builder, + &parsed_markdown.source[range.clone()], + range.clone(), + cx, + ); } MarkdownEvent::Html => { let html = &parsed_markdown.source[range.clone()]; @@ -2243,6 +2532,19 @@ impl Element for MarkdownElement { } MarkdownEvent::InlineHtml => { let html = &parsed_markdown.source[range.clone()]; + if let Some(code) = html + .strip_prefix("") + .and_then(|html| html.strip_suffix("")) + { + let code_start = range.start + "".len(); + self.push_markdown_code_span( + &mut builder, + code, + code_start..code_start + code.len(), + cx, + ); + continue; + } if html.starts_with("") { builder.push_text_style(self.style.inline_code.clone()); continue; @@ -2420,6 +2722,29 @@ fn apply_heading_style( heading } +fn render_wrap_code_block_button( + id: usize, + is_wrapped: bool, + markdown: Entity, +) -> impl IntoElement { + let (icon, tooltip) = if is_wrapped { + (IconName::TextUnwrap, "Unwrap Content") + } else { + (IconName::TextWrap, "Wrap Content") + }; + + IconButton::new(("wrap-code-block", id), icon) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(tooltip)) + .on_click(move |_event, _window, cx| { + markdown.update(cx, |markdown, cx| { + markdown.toggle_code_block_wrap(id); + cx.notify(); + }); + }) +} + fn render_copy_code_block_button( id: usize, code: String, @@ -2568,6 +2893,11 @@ fn alignment_to_text_align(alignment: Alignment) -> Option { } } +struct MetadataCellStyle { + row_index: usize, + is_key: bool, +} + struct MarkdownElementBuilder { div_stack: Vec, rendered_lines: Vec, @@ -2580,6 +2910,7 @@ struct MarkdownElementBuilder { base_text_style: TextStyle, text_style_stack: Vec, code_block_stack: Vec>>, + link_depth: usize, list_stack: Vec, table: TableState, syntax_theme: Arc, @@ -2618,6 +2949,7 @@ impl MarkdownElementBuilder { base_text_style, text_style_stack: Vec::new(), code_block_stack: Vec::new(), + link_depth: 0, list_stack: Vec::new(), table: TableState::default(), syntax_theme, @@ -2789,33 +3121,36 @@ impl MarkdownElementBuilder { self.pending_line.text.push_str(text); self.current_source_index = source_range.end; + // Compute the base text style once + let text_style = self.text_style(); + if let Some(Some(language)) = self.code_block_stack.last() { let mut offset = 0; for (range, highlight_id) in language.highlight_text(&Rope::from(text), 0..text.len()) { if range.start > offset { self.pending_line .runs - .push(self.text_style().to_run(range.start - offset)); + .push(text_style.to_run(range.start - offset)); } - let mut run_style = self.text_style(); + let run_len = range.len(); if let Some(highlight) = self.syntax_theme.get(highlight_id).cloned() { - run_style = run_style.highlight(highlight); + self.pending_line + .runs + .push(text_style.clone().highlight(highlight).to_run(run_len)); + } else { + self.pending_line.runs.push(text_style.to_run(run_len)); } - - self.pending_line.runs.push(run_style.to_run(range.len())); offset = range.end; } if offset < text.len() { self.pending_line .runs - .push(self.text_style().to_run(text.len() - offset)); + .push(text_style.to_run(text.len() - offset)); } } else { - self.pending_line - .runs - .push(self.text_style().to_run(text.len())); + self.pending_line.runs.push(text_style.to_run(text.len())); } } @@ -3326,7 +3661,10 @@ mod tests { use super::*; use gpui::{TestAppContext, size}; use language::{Language, LanguageConfig, LanguageMatcher}; - use std::sync::Arc; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; fn ensure_theme_initialized(cx: &mut TestAppContext) { cx.update(|cx| { @@ -3394,6 +3732,77 @@ mod tests { render_markdown_with_language_registry(markdown, None, cx) } + #[gpui::test] + fn test_frontmatter_renders_without_delimiters(cx: &mut TestAppContext) { + let rendered = render_markdown_with_options( + "---\ntitle: Post\n---\nBody", + None, + MarkdownOptions { + render_metadata_blocks: true, + ..Default::default() + }, + cx, + ); + assert_eq!(rendered.text_for_range(0..24), "title\nPost\nBody"); + } + + #[gpui::test] + fn test_frontmatter_falls_back_to_code_block_for_nested_yaml(cx: &mut TestAppContext) { + let rendered = render_markdown_with_options( + "---\ntags:\n - zed\n---\nBody", + None, + MarkdownOptions { + render_metadata_blocks: true, + ..Default::default() + }, + cx, + ); + assert_eq!(rendered.text_for_range(0..26), "tags:\n - zed\nBody"); + } + + fn render_markdown_with_code_span_link( + markdown: &str, + callback: impl Fn(&str, &App) -> Option + 'static, + cx: &mut TestAppContext, + ) -> RenderedText { + render_markdown_with_code_span_link_style(markdown, MarkdownStyle::default(), callback, cx) + } + + fn render_markdown_with_code_span_link_style( + markdown: &str, + style: MarkdownStyle, + callback: impl Fn(&str, &App) -> Option + 'static, + cx: &mut TestAppContext, + ) -> RenderedText { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx)); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, style) + .on_code_span_link(callback) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, + border: false, + }) + }, + ); + rendered.text + } + fn render_markdown_with_language_registry( markdown: &str, language_registry: Option>, @@ -3436,6 +3845,7 @@ mod tests { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }, ) @@ -3637,7 +4047,7 @@ mod tests { #[test] fn test_table_checkbox_detection() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; - let events = crate::parser::parse_markdown_with_options(md, false, false).events; + let events = crate::parser::parse_markdown_with_options(md, false, false, false).events; let mut in_table = false; let mut cell_texts: Vec = Vec::new(); @@ -3679,7 +4089,7 @@ mod tests { #[test] fn test_table_checkbox_marker_source_range() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; - let events = crate::parser::parse_markdown_with_options(md, false, false).events; + let events = crate::parser::parse_markdown_with_options(md, false, false, false).events; let mut in_cell = false; let mut pending_text = String::new(); @@ -3931,8 +4341,32 @@ mod tests { ); } + #[test] + fn test_escape_non_ascii() { + // Cyrillic characters should not have backslashes added before them, + // but ASCII punctuation should still be escaped. + assert_eq!(Markdown::escape("Привет, мир"), r"Привет\, мир"); + // Test with markdown special characters mixed in + assert_eq!(Markdown::escape("Привет, *мир*"), r"Привет\, \*мир\*"); + // Test with the exact example from the issue (single quotes are also ASCII punctuation) + assert_eq!( + Markdown::escape("Отсутствует пробел справа от ','"), + r"Отсутствует пробел справа от \'\,\'" + ); + // Test more non-ASCII scripts + assert_eq!( + Markdown::escape("こんにちは *world*"), + r"こんにちは \*world\*" + ); + assert_eq!(Markdown::escape("العربيّة [link]"), r"العربيّة \[link\]"); + assert_eq!(Markdown::escape("Ελληνικά _text_"), r"Ελληνικά \_text\_"); + assert_eq!(Markdown::escape("עברית `code`"), r"עברית \`code\`"); + // Non-ASCII followed by ASCII punctuation + assert_eq!(Markdown::escape("Test: тест"), r"Test\: тест"); + } + fn has_code_block(markdown: &str) -> bool { - let parsed_data = parse_markdown_with_options(markdown, false, false); + let parsed_data = parse_markdown_with_options(markdown, false, false, false); parsed_data .events .iter() @@ -3959,12 +4393,12 @@ mod tests { ]; for input in cases { let mut escaper = MarkdownEscaper::new(); - let precomputed: usize = input.bytes().map(|b| escaper.next(b).output_len()).sum(); + let precomputed: usize = input.chars().map(|c| escaper.next(c).output_len(c)).sum(); let mut escaper = MarkdownEscaper::new(); let mut output = String::new(); for c in input.chars() { - escaper.next(c as u8).write_to(c, &mut output); + escaper.next(c).write_to(c, &mut output); } assert_eq!(precomputed, output.len(), "length mismatch for {:?}", input); @@ -4004,6 +4438,75 @@ mod tests { assert!(rendered.link_for_source_index(5).is_none()); } + #[gpui::test] + fn test_code_span_link_detected_for_source_index(cx: &mut TestAppContext) { + let source = "see `foo.rs` for details"; + let rendered = render_markdown_with_code_span_link( + source, + |text, _cx| (text == "foo.rs").then(|| "file:///tmp/foo.rs".into()), + cx, + ); + + assert_eq!(rendered.links.len(), 1); + assert_eq!(rendered.links[0].destination_url, "file:///tmp/foo.rs"); + + let code_index = source.find("foo.rs").unwrap(); + let link = rendered.link_for_source_index(code_index); + assert!(link.is_some()); + assert_eq!(link.unwrap().destination_url, "file:///tmp/foo.rs"); + + assert!( + rendered + .link_for_source_index(source.find("see").unwrap()) + .is_none() + ); + } + + #[gpui::test] + fn test_code_span_link_ignores_code_when_mouse_interaction_is_prevented( + cx: &mut TestAppContext, + ) { + let callback_count = Arc::new(AtomicUsize::new(0)); + let rendered = render_markdown_with_code_span_link_style( + "see `foo.rs` for details", + MarkdownStyle { + prevent_mouse_interaction: true, + ..MarkdownStyle::default() + }, + { + let callback_count = callback_count.clone(); + move |text, _cx| { + callback_count.fetch_add(1, Ordering::Relaxed); + (text == "foo.rs").then(|| "file:///tmp/foo.rs".into()) + } + }, + cx, + ); + + assert!(rendered.links.is_empty()); + assert_eq!(callback_count.load(Ordering::Relaxed), 0); + } + + #[gpui::test] + fn test_code_span_link_ignores_code_without_callback(cx: &mut TestAppContext) { + let rendered = render_markdown("see `foo.rs` for details", cx); + + assert!(rendered.links.is_empty()); + } + + #[gpui::test] + fn test_code_span_link_ignores_code_inside_markdown_link(cx: &mut TestAppContext) { + let source = "see [`foo.rs`](https://example.com) for details"; + let rendered = render_markdown_with_code_span_link( + source, + |text, _cx| (text == "foo.rs").then(|| "file:///tmp/foo.rs".into()), + cx, + ); + + assert_eq!(rendered.links.len(), 1); + assert_eq!(rendered.links[0].destination_url, "https://example.com"); + } + #[gpui::test] fn test_context_menu_link_initial_state(cx: &mut TestAppContext) { struct TestWindow; diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 250edeea3a5..4acceb2577b 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -1,7 +1,7 @@ use collections::HashMap; use gpui::{ - Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, Hsla, - ImageSource, RenderImage, Rgba, StyledText, Task, img, pulsating_between, + Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, ImageSource, + RenderImage, StyledText, Task, img, pulsating_between, }; use std::collections::BTreeMap; use std::ops::Range; @@ -104,18 +104,12 @@ impl CachedMermaidDiagram { let render_image_clone = render_image.clone(); let svg_renderer = cx.svg_renderer(); let mermaid_theme = build_mermaid_theme(cx); - let accent_classdefs = build_accent_classdefs(cx); let task = cx.spawn(async move |this, cx| { let value = cx .background_spawn(async move { - let options = mermaid_rs_renderer::RenderOptions { - theme: mermaid_theme, - layout: mermaid_rs_renderer::LayoutConfig::default(), - }; - let full_source = format!("{}\n{}", contents.contents, accent_classdefs); let svg_string = - mermaid_rs_renderer::render_with_options(&full_source, options)?; + mermaid_render::render_to_svg(&contents.contents, &mermaid_theme)?; let scale = contents.scale as f32 / 100.0; svg_renderer .render_single_frame(svg_string.as_bytes(), scale) @@ -153,128 +147,71 @@ impl CachedMermaidDiagram { } } -/// Converts an HSLA color to a CSS hex string (e.g. `#1a2b3c`). -fn hsla_to_hex(color: Hsla) -> String { - let rgba: Rgba = color.to_rgb(); - let r = (rgba.r * 255.0).round() as u8; - let g = (rgba.g * 255.0).round() as u8; - let b = (rgba.b * 255.0).round() as u8; - format!("#{r:02x}{g:02x}{b:02x}") +/// Merman has somewhat limited text measurement capabilities. +/// +/// When it doesn't have metrics for any of the specified fonts, it chooses a +/// fairly narrow width, which causes visible overflow. Adding `sans-serif` +/// allows it to fall back to a more conservative (i.e. wider) measurement. +/// +/// This isn't perfect - very wide fonts will likely still cause overflow. A +/// proper fix would involve somehow piping `resvg`'s actual measurements into +/// `merman`, but that is a lot of work for a fairly uncommon edge case. +fn mermaid_font_family(font_family: &str) -> String { + let font_family = gpui::font_name_with_fallbacks(font_family, "system-ui"); + if font_family + .split(',') + .any(|family| family.trim().eq_ignore_ascii_case("sans-serif")) + { + font_family.to_string() + } else { + format!("{font_family}, sans-serif") + } } -fn mermaid_font_family(font_family: &str) -> &str { - gpui::font_name_with_fallbacks(font_family, "system-ui") -} - -fn build_mermaid_theme(cx: &Context) -> mermaid_rs_renderer::Theme { +fn build_mermaid_theme(cx: &Context) -> mermaid_render::MermaidTheme { let colors = cx.theme().colors(); let theme_settings = ThemeSettings::get_global(cx); - let mut theme = mermaid_rs_renderer::Theme::modern(); - - theme.font_family = mermaid_font_family(theme_settings.ui_font.family.as_ref()).to_string(); - theme.background = hsla_to_hex(colors.editor_background); - theme.primary_color = hsla_to_hex(colors.surface_background); - theme.primary_text_color = hsla_to_hex(colors.text); - theme.primary_border_color = hsla_to_hex(colors.border); - theme.line_color = hsla_to_hex(colors.border); - theme.secondary_color = hsla_to_hex(colors.element_background); - theme.tertiary_color = hsla_to_hex(colors.ghost_element_hover); - theme.edge_label_background = hsla_to_hex(colors.editor_background); - theme.cluster_background = hsla_to_hex(colors.panel_background); - theme.cluster_border = hsla_to_hex(colors.border_variant); - theme.text_color = hsla_to_hex(colors.text); - let accents = cx.theme().accents(); - let pie_colors: [String; 12] = - std::array::from_fn(|i| hsla_to_hex(accents.color_for_index(i as u32))); - theme.pie_colors = pie_colors; - theme.pie_title_text_color = hsla_to_hex(colors.text); - theme.pie_section_text_color = "#fff".to_string(); - theme.pie_legend_text_color = hsla_to_hex(colors.text); - theme.pie_stroke_color = hsla_to_hex(colors.border); - theme.pie_outer_stroke_color = hsla_to_hex(colors.border); - - theme.sequence_actor_fill = hsla_to_hex(colors.element_background); - theme.sequence_actor_border = hsla_to_hex(colors.border); - theme.sequence_actor_line = hsla_to_hex(colors.border); - theme.sequence_note_fill = hsla_to_hex(colors.surface_background); - theme.sequence_note_border = hsla_to_hex(colors.border_variant); - theme.sequence_activation_fill = hsla_to_hex(colors.ghost_element_hover); - theme.sequence_activation_border = hsla_to_hex(colors.border); + let is_dark = !cx.theme().appearance.is_light(); let players = cx.theme().players(); - theme.git_colors = std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].cursor)); - theme.git_inv_colors = - std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].background)); - theme.git_branch_label_colors = std::array::from_fn(|_| "#fff".to_string()); - theme.git_commit_label_color = hsla_to_hex(colors.text); - theme.git_commit_label_background = hsla_to_hex(colors.element_background); - theme.git_tag_label_color = hsla_to_hex(colors.text); - theme.git_tag_label_background = hsla_to_hex(colors.element_background); - theme.git_tag_label_border = hsla_to_hex(colors.border); + let git_branch_colors = std::array::from_fn(|i| players.0[i % players.0.len()].cursor); + let git_branch_label_colors = git_branch_colors.map(mermaid_render::text_color_for_background); - theme -} - -fn build_accent_classdefs(cx: &Context) -> String { - use std::fmt::Write; - let players = &cx.theme().players(); - let is_light = cx.theme().appearance.is_light(); - let mut defs = String::new(); - for (i, player) in players.0.iter().enumerate() { - let (fill, text_color) = accent_fill_and_text(player.background, is_light); - let fill = hsla_to_hex(fill); - let stroke = hsla_to_hex(player.cursor); - let text_color = hsla_to_hex(text_color); - writeln!( - defs, - "classDef accent{i} fill:{fill},stroke:{stroke},color:{text_color}" - ) - .ok(); + mermaid_render::MermaidTheme { + dark_mode: is_dark, + font_family: mermaid_font_family(theme_settings.ui_font.family.as_ref()), + background: colors.editor_background, + primary_color: colors.surface_background, + primary_text_color: colors.text, + primary_border_color: colors.border, + secondary_color: colors.element_background, + tertiary_color: colors.ghost_element_hover, + line_color: colors.border, + text_color: colors.text, + edge_label_background: colors.editor_background, + cluster_background: colors.panel_background, + cluster_border: colors.border_variant, + note_background: colors.surface_background, + note_border: colors.border_variant, + actor_background: colors.element_background, + actor_border: colors.border, + activation_background: colors.ghost_element_hover, + activation_border: colors.border, + git_branch_colors, + git_branch_label_colors, + er_attr_bg_odd: colors.surface_background, + er_attr_bg_even: colors.element_background, + error_color: cx.theme().status().error, + warning_color: cx.theme().status().warning, + accent_colors: players + .0 + .iter() + .map(|player| mermaid_render::AccentColor { + foreground: player.cursor, + background: player.background, + }) + .collect(), } - defs -} - -/// Adjusts an accent fill color to ensure readable text contrast. -/// -/// On dark themes, darkens the fill and uses white text. -/// On light themes, lightens the fill and uses black text. -/// The fill is adjusted until it meets a minimum WCAG contrast ratio -/// of ~4.5:1 against the chosen text color. -fn accent_fill_and_text(color: Hsla, is_light: bool) -> (Hsla, Hsla) { - let mut fill = color; - if is_light { - // Lighten fill until luminance is high enough for black text. - // Target: relative luminance >= 0.35 → contrast ratio ~8:1 with black. - for _ in 0..50 { - if relative_luminance(fill) >= 0.35 { - break; - } - fill.l = (fill.l + 0.02).min(1.0); - } - (fill, gpui::black()) - } else { - // Darken fill until luminance is low enough for white text. - // Target: relative luminance <= 0.18 → contrast ratio ~4.6:1 with white. - for _ in 0..50 { - if relative_luminance(fill) <= 0.18 { - break; - } - fill.l = (fill.l - 0.02).max(0.0); - } - (fill, gpui::white()) - } -} - -fn relative_luminance(color: Hsla) -> f32 { - let rgba: Rgba = color.to_rgb(); - fn linearize(c: f32) -> f32 { - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } - } - 0.2126 * linearize(rgba.r) + 0.7152 * linearize(rgba.g) + 0.0722 * linearize(rgba.b) } fn parse_mermaid_info(info: &str) -> Option { @@ -292,6 +229,38 @@ fn parse_mermaid_info(info: &str) -> Option { ) } +/// We deliberately block rendering of some diagram types, even though `merman` +/// supports them, because we have not yet written custom CSS to ensure text is +/// readable. +fn is_supported_diagram_type(source: &str) -> bool { + /// If updating this list, also update the system prompt! + const SUPPORTED_PREFIXES: &[&str] = &[ + "flowchart", + "graph", + "sequenceDiagram", + "classDiagram", + "stateDiagram", + "stateDiagram-v2", + "erDiagram", + "gantt", + "pie", + "gitGraph", + "mindmap", + "timeline", + "quadrantChart", + "xychart-beta", + "journey", + ]; + let first_token = source + .trim_start() + .split(|c: char| c.is_whitespace() || c == '\n') + .next() + .unwrap_or(""); + SUPPORTED_PREFIXES + .iter() + .any(|prefix| first_token.eq_ignore_ascii_case(prefix)) +} + pub(crate) fn extract_mermaid_diagrams( source: &str, events: &[(Range, MarkdownEvent)], @@ -324,6 +293,9 @@ pub(crate) fn extract_mermaid_diagrams( .strip_suffix('\n') .unwrap_or(&source[metadata.content_range.clone()]) .to_string(); + if !is_supported_diagram_type(&contents) { + continue; + } mermaid_diagrams.insert( source_range.start, ParsedMarkdownMermaidDiagram { @@ -585,27 +557,13 @@ mod tests { }; use crate::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions, - MarkdownStyle, + MarkdownStyle, WrapButtonVisibility, }; use collections::HashMap; - use gpui::{Context, Hsla, IntoElement, Render, RenderImage, TestAppContext, Window, size}; + use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size}; use std::sync::Arc; use ui::prelude::*; - #[gpui::property_test] - fn accent_fill_and_text_sufficient_contrast( - #[strategy = Hsla::opaque_strategy()] color: Hsla, - light_mode: bool, - ) { - let (fill, text) = super::accent_fill_and_text(color, light_mode); - let fill_luminance = super::relative_luminance(fill); - let text_luminance = super::relative_luminance(text); - let lighter = fill_luminance.max(text_luminance); - let darker = fill_luminance.min(text_luminance); - let contrast_ratio = (lighter + 0.05) / (darker + 0.05); - assert!(contrast_ratio >= 4.5,); - } - fn ensure_theme_initialized(cx: &mut TestAppContext) { cx.update(|cx| { if !cx.has_global::() { @@ -644,6 +602,7 @@ mod tests { MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }, ) @@ -692,11 +651,27 @@ mod tests { #[test] fn test_mermaid_font_family_resolves_zed_virtual_fonts() { - assert_eq!(super::mermaid_font_family(".ZedSans"), "IBM Plex Sans"); - assert_eq!(super::mermaid_font_family("Zed Plex Sans"), "IBM Plex Sans"); - assert_eq!(super::mermaid_font_family(".ZedMono"), "Lilex"); - assert_eq!(super::mermaid_font_family(".SystemUIFont"), "system-ui"); - assert_eq!(super::mermaid_font_family("Custom Font"), "Custom Font"); + assert_eq!( + super::mermaid_font_family(".ZedSans"), + "IBM Plex Sans, sans-serif" + ); + assert_eq!( + super::mermaid_font_family("Zed Plex Sans"), + "IBM Plex Sans, sans-serif" + ); + assert_eq!(super::mermaid_font_family(".ZedMono"), "Lilex, sans-serif"); + assert_eq!( + super::mermaid_font_family(".SystemUIFont"), + "system-ui, sans-serif" + ); + assert_eq!( + super::mermaid_font_family("Custom Font"), + "Custom Font, sans-serif" + ); + assert_eq!( + super::mermaid_font_family("Custom Font, sans-serif"), + "Custom Font, sans-serif" + ); } #[test] @@ -711,7 +686,8 @@ mod tests { #[test] fn test_extract_mermaid_diagrams_parses_scale() { let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```"; - let events = crate::parser::parse_markdown_with_options(markdown, false, false).events; + let events = + crate::parser::parse_markdown_with_options(markdown, false, false, false).events; let diagrams = extract_mermaid_diagrams(markdown, &events); assert_eq!(diagrams.len(), 1); @@ -720,6 +696,28 @@ mod tests { assert_eq!(diagram.contents.scale, 150); } + #[test] + fn test_unsupported_diagram_types_are_skipped() { + let markdown = concat!( + "```mermaid\nsankey-beta\n```\n\n", + "```mermaid\nblock-beta\n```\n\n", + "```mermaid\nflowchart TD\n A --> B\n```", + ); + let events = + crate::parser::parse_markdown_with_options(markdown, false, false, false).events; + let diagrams = extract_mermaid_diagrams(markdown, &events); + assert_eq!( + diagrams.len(), + 1, + "Only the flowchart should be extracted; sankey and block should be skipped" + ); + let diagram = diagrams.values().next().unwrap(); + assert!( + diagram.contents.contents.contains("flowchart"), + "The extracted diagram should be the flowchart" + ); + } + #[gpui::test] fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) { let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); @@ -924,6 +922,7 @@ mod tests { MarkdownElement::new(markdown.clone(), MarkdownStyle::default()) .code_block_renderer(CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }) }, diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index 4e9d6c29830..6301d759f69 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -37,10 +37,23 @@ pub(crate) struct ParsedMarkdownData { pub language_paths: HashSet>, pub root_block_starts: Vec, pub html_blocks: BTreeMap, + pub metadata_blocks: BTreeMap, pub heading_slugs: HashMap, pub footnote_definitions: HashMap, } +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ParsedMetadataBlock { + pub content_range: Range, + pub rows: Option>, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct MetadataRow { + pub key: Range, + pub value: Range, +} + impl ParseState { fn push_event(&mut self, range: Range, event: MarkdownEvent) { match &event { @@ -149,27 +162,83 @@ fn build_heading_slugs( slugs } +fn parse_metadata_table_rows(source: &str, source_range: Range) -> Option> { + let mut rows = Vec::new(); + let mut line_start = source_range.start; + + for line in source[source_range].split_inclusive('\n') { + let line_end = line_start + line.len(); + let content_end = line_start + line.trim_end_matches(['\r', '\n']).len(); + let content_range = line_start..content_end; + let line_text = &source[content_range.clone()]; + + if line_text.is_empty() + || line_text + .chars() + .next() + .is_some_and(|character| character.is_whitespace()) + { + return None; + } + + let delimiter = line_text.find(':')?; + let key = trim_metadata_range(source, content_range.start..content_range.start + delimiter); + let value = trim_metadata_range( + source, + content_range.start + delimiter + 1..content_range.end, + ); + if key.is_empty() || value.is_empty() { + return None; + } + + rows.push(MetadataRow { key, value }); + line_start = line_end; + } + + if rows.is_empty() { None } else { Some(rows) } +} + +fn trim_metadata_range(source: &str, range: Range) -> Range { + let text = &source[range.clone()]; + let start_offset = text.len() - text.trim_start().len(); + let end_offset = text.trim_end().len(); + let start = range.start + start_offset; + let end = (range.start + end_offset).max(start); + start..end +} + pub(crate) fn parse_markdown_with_options( text: &str, parse_html: bool, parse_heading_slugs: bool, + parse_metadata_blocks: bool, ) -> ParsedMarkdownData { let mut state = ParseState::default(); let mut language_names = HashSet::default(); let mut language_paths = HashSet::default(); let mut html_blocks = BTreeMap::default(); + let mut metadata_blocks = BTreeMap::default(); let mut within_link = false; let mut within_code_block = false; let mut within_metadata = false; - let mut parser = Parser::new_ext(text, PARSE_OPTIONS) + let mut current_metadata_block_start = None; + let mut metadata_block_content_range: Option> = None; + let parse_options = if parse_metadata_blocks { + PARSE_OPTIONS.union(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS) + } else { + PARSE_OPTIONS + }; + let mut parser = Parser::new_ext(text, parse_options) .into_offset_iter() .peekable(); while let Some((pulldown_event, range)) = parser.next() { - if within_metadata { - if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock { .. }) = + if within_metadata && !parse_metadata_blocks { + if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock(_)) = pulldown_event { within_metadata = false; + current_metadata_block_start = None; + metadata_block_content_range = None; } continue; } @@ -216,9 +285,14 @@ pub(crate) fn parse_markdown_with_options( id: SharedString::from(id.into_string()), } } - pulldown_cmark::Tag::MetadataBlock(_kind) => { + pulldown_cmark::Tag::MetadataBlock(kind) => { within_metadata = true; - continue; + current_metadata_block_start = Some(range.start); + metadata_block_content_range = None; + if !parse_metadata_blocks { + continue; + } + MarkdownTag::MetadataBlock(kind) } pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => { within_code_block = true; @@ -347,6 +421,25 @@ pub(crate) fn parse_markdown_with_options( within_link = false; } else if let pulldown_cmark::TagEnd::CodeBlock = tag { within_code_block = false; + } else if let pulldown_cmark::TagEnd::MetadataBlock(_) = tag { + within_metadata = false; + let block_start = current_metadata_block_start.take(); + let content_range = metadata_block_content_range.take(); + if parse_metadata_blocks + && let (Some(block_start), Some(content_range)) = + (block_start, content_range) + { + metadata_blocks.insert( + block_start, + ParsedMetadataBlock { + rows: parse_metadata_table_rows(text, content_range.clone()), + content_range, + }, + ); + } + if !parse_metadata_blocks { + continue; + } } state.push_event(range, MarkdownEvent::End(tag)); } @@ -363,6 +456,18 @@ pub(crate) fn parse_markdown_with_options( } } + if within_metadata { + match &mut metadata_block_content_range { + Some(content_range) => { + content_range.start = content_range.start.min(range.start); + content_range.end = content_range.end.max(range.end); + } + None => metadata_block_content_range = Some(range.clone()), + } + state.push_event(range, MarkdownEvent::Text); + continue; + } + if within_code_block { let (range, event) = event_for(text, range, &parsed); state.push_event(range, event); @@ -541,6 +646,7 @@ pub(crate) fn parse_markdown_with_options( language_paths, root_block_starts: state.root_block_starts, html_blocks, + metadata_blocks, heading_slugs, footnote_definitions, } @@ -798,8 +904,8 @@ mod tests { use super::MarkdownTag::*; use super::*; - const UNWANTED_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS - .union(Options::ENABLE_MATH) + const CONDITIONAL_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS; + const UNWANTED_OPTIONS: Options = Options::ENABLE_MATH .union(Options::ENABLE_DEFINITION_LIST) .union(Options::ENABLE_WIKILINKS); @@ -807,21 +913,174 @@ mod tests { fn all_options_considered() { // The purpose of this is to fail when new options are added to pulldown_cmark, so that they // can be evaluated for inclusion. - assert_eq!(PARSE_OPTIONS.union(UNWANTED_OPTIONS), Options::all()); + assert_eq!( + PARSE_OPTIONS + .union(CONDITIONAL_OPTIONS) + .union(UNWANTED_OPTIONS), + Options::all() + ); } #[test] fn wanted_and_unwanted_options_disjoint() { assert_eq!( - PARSE_OPTIONS.intersection(UNWANTED_OPTIONS), + PARSE_OPTIONS + .union(CONDITIONAL_OPTIONS) + .intersection(UNWANTED_OPTIONS), Options::empty() ); } + #[test] + fn test_yaml_style_metadata_block() { + assert_eq!( + parse_markdown_with_options("---\ntitle: Post\n---\n# Heading", false, false, true), + ParsedMarkdownData { + events: vec![ + (0..19, RootStart), + (0..19, Start(MetadataBlock(MetadataBlockKind::YamlStyle))), + (4..16, Text), + ( + 0..19, + End(MarkdownTagEnd::MetadataBlock(MetadataBlockKind::YamlStyle)) + ), + (0..19, RootEnd(0)), + (20..29, RootStart), + ( + 20..29, + Start(Heading { + level: HeadingLevel::H1, + id: None, + classes: Vec::new(), + attrs: Vec::new(), + }) + ), + (22..29, Text), + (20..29, End(MarkdownTagEnd::Heading(HeadingLevel::H1))), + (20..29, RootEnd(1)), + ], + root_block_starts: vec![0, 20], + metadata_blocks: BTreeMap::from_iter([( + 0, + ParsedMetadataBlock { + content_range: 4..16, + rows: Some(vec![MetadataRow { + key: 4..9, + value: 11..15, + }]), + }, + )]), + ..Default::default() + } + ) + } + + #[test] + fn test_metadata_block_text_is_verbatim() { + let parsed = + parse_markdown_with_options("---\nurl: https://zed.dev\n---\nBody", false, false, true); + assert!( + parsed + .events + .iter() + .all(|(_, event)| !matches!(event, Start(Link { .. }))) + ); + } + + #[test] + fn test_metadata_blocks_store_table_rows() { + let parsed = parse_markdown_with_options( + "---\ntitle: Post\nauthor: Zed\n---\nBody", + false, + false, + true, + ); + + assert_eq!( + parsed.metadata_blocks, + BTreeMap::from_iter([( + 0, + ParsedMetadataBlock { + content_range: 4..28, + rows: Some(vec![ + MetadataRow { + key: 4..9, + value: 11..15, + }, + MetadataRow { + key: 16..22, + value: 24..27, + }, + ]), + }, + )]) + ); + } + + #[test] + fn test_metadata_blocks_store_fallback_for_nested_yaml() { + let parsed = + parse_markdown_with_options("---\ntags:\n - zed\n---\nBody", false, false, true); + + assert_eq!( + parsed.metadata_blocks, + BTreeMap::from_iter([( + 0, + ParsedMetadataBlock { + content_range: 4..18, + rows: None, + }, + )]) + ); + } + + #[test] + fn test_metadata_table_rows_parse_simple_colon_pairs() { + let source = "title: Post\nauthor: Zed\n"; + let Some(rows) = parse_metadata_table_rows(source, 0..source.len()) else { + panic!("expected metadata rows"); + }; + let pairs = rows + .into_iter() + .map(|row| (&source[row.key], &source[row.value])) + .collect::>(); + + assert_eq!(pairs, vec![("title", "Post"), ("author", "Zed")]); + } + + #[test] + fn test_metadata_table_rows_reject_non_simple_colon_pairs() { + for source in [ + "tags:\n - zed\n", + "title = Post\n", + "title:\n", + "title: \n", + ": Post\n", + " title: Post\n", + "\n", + ] { + assert!(parse_metadata_table_rows(source, 0..source.len()).is_none()); + } + } + + #[test] + fn test_trim_metadata_range_returns_valid_empty_range() { + let source = "key: \n"; + let trimmed = trim_metadata_range(source, 4..7); + + assert_eq!(trimmed, 7..7); + assert!(source[trimmed].is_empty()); + } + #[test] fn test_html_comments() { assert_eq!( - parse_markdown_with_options(" \nReturns", false, false), + parse_markdown_with_options( + " \nReturns", + false, + false, + false + ), ParsedMarkdownData { events: vec![ (2..30, RootStart), @@ -851,6 +1110,7 @@ mod tests { "   https://some.url some \\`►\\` text", false, false, + false, ), ParsedMarkdownData { events: vec![ @@ -891,6 +1151,7 @@ mod tests { "You can use the [GitHub Search API](https://docs.github.com/en", false, false, + false, ) .events, vec![ @@ -925,6 +1186,7 @@ mod tests { "-- --- ... \"double quoted\" 'single quoted' ----------", false, false, + false, ), ParsedMarkdownData { events: vec![ @@ -957,7 +1219,12 @@ mod tests { #[test] fn test_code_block_metadata() { assert_eq!( - parse_markdown_with_options("```rust\nfn main() {\n let a = 1;\n}\n```", false, false), + parse_markdown_with_options( + "```rust\nfn main() {\n let a = 1;\n}\n```", + false, + false, + false + ), ParsedMarkdownData { events: vec![ (0..37, RootStart), @@ -986,7 +1253,7 @@ mod tests { } ); assert_eq!( - parse_markdown_with_options(" fn main() {}", false, false), + parse_markdown_with_options(" fn main() {}", false, false, false), ParsedMarkdownData { events: vec![ (4..16, RootStart), @@ -1012,7 +1279,7 @@ mod tests { } fn assert_code_block_does_not_emit_links(markdown: &str) { - let parsed = parse_markdown_with_options(markdown, false, false); + let parsed = parse_markdown_with_options(markdown, false, false, false); let mut code_block_depth = 0; let mut code_block_count = 0; let mut saw_text_inside_code_block = false; @@ -1064,9 +1331,54 @@ mod tests { } #[test] - fn test_metadata_blocks_do_not_affect_root_blocks() { + fn test_metadata_blocks_are_root_blocks() { assert_eq!( - parse_markdown_with_options("+++\ntitle = \"Example\"\n+++\n\nParagraph", false, false), + parse_markdown_with_options( + "+++\ntitle = \"Example\"\n+++\n\nParagraph", + false, + false, + true + ), + ParsedMarkdownData { + events: vec![ + (0..25, RootStart), + (0..25, Start(MetadataBlock(MetadataBlockKind::PlusesStyle))), + (4..22, Text), + ( + 0..25, + End(MarkdownTagEnd::MetadataBlock( + MetadataBlockKind::PlusesStyle + )) + ), + (0..25, RootEnd(0)), + (27..36, RootStart), + (27..36, Start(Paragraph)), + (27..36, Text), + (27..36, End(MarkdownTagEnd::Paragraph)), + (27..36, RootEnd(1)), + ], + root_block_starts: vec![0, 27], + metadata_blocks: BTreeMap::from_iter([( + 0, + ParsedMetadataBlock { + content_range: 4..22, + rows: None, + }, + )]), + ..Default::default() + } + ); + } + + #[test] + fn test_metadata_blocks_are_omitted_by_default() { + assert_eq!( + parse_markdown_with_options( + "+++\ntitle = \"Example\"\n+++\n\nParagraph", + false, + false, + false + ), ParsedMarkdownData { events: vec![ (27..36, RootStart), @@ -1088,7 +1400,7 @@ mod tests { |------|---------| | [x] | Fix bug | | [ ] | Add feature |"; - let parsed = parse_markdown_with_options(markdown, false, false); + let parsed = parse_markdown_with_options(markdown, false, false, false); let mut in_table = false; let mut saw_task_list_marker = false; @@ -1164,6 +1476,7 @@ mod tests { "Text with a footnote[^1] and some more text.\n\n[^1]: This is the footnote content.", false, false, + false, ); assert_eq!( parsed.events, @@ -1194,6 +1507,7 @@ mod tests { "Text[^a] and[^b].\n\n[^a]: First.\n\n[^b]: Second.", false, false, + false, ); assert_eq!(parsed.footnote_definitions.len(), 2); assert!(parsed.footnote_definitions.contains_key("a")); @@ -1211,6 +1525,7 @@ mod tests { "https:/\\/example.com is equivalent to https://example.com!", false, false, + false, ) .events, vec![ @@ -1253,6 +1568,7 @@ mod tests { "Visit https://example.com/cat\\/é‍☕ for coffee!", false, false, + false, ) .events, [ @@ -1286,6 +1602,7 @@ mod tests { "# Hello World\n\n## Code `block`\n\n### Third Level\n\n#### Fourth Level\n\n## Hello World", false, true, + false, ); assert_eq!(parsed.heading_slugs.len(), 5); assert!(parsed.heading_slugs.contains_key("hello-world")); @@ -1301,6 +1618,7 @@ mod tests { "# Duplicate\n\nText\n\n## Duplicate\n\nMore text", false, true, + false, ); let first = parsed.heading_slugs.get("duplicate").copied(); let second = parsed.heading_slugs.get("duplicate-1").copied(); @@ -1311,7 +1629,7 @@ mod tests { #[test] fn test_heading_slug_collision_with_dedup_suffix() { - let parsed = parse_markdown_with_options("# Foo\n\n## Foo\n\n## Foo 1", false, true); + let parsed = parse_markdown_with_options("# Foo\n\n## Foo\n\n## Foo 1", false, true, false); assert_eq!(parsed.heading_slugs.len(), 3); assert!(parsed.heading_slugs.contains_key("foo")); assert!(parsed.heading_slugs.contains_key("foo-1")); @@ -1323,7 +1641,7 @@ mod tests { use pulldown_cmark::BlockQuoteKind; let markdown = "\n> [!NOTE]\n> A note.\n\n> [!TIP]\n> A tip.\n\n> [!IMPORTANT]\n> Important.\n\n> [!WARNING]\n> A warning.\n\n> [!CAUTION]\n> A caution.\n\n> Plain quote.\n"; - let parsed = parse_markdown_with_options(markdown, false, false); + let parsed = parse_markdown_with_options(markdown, false, false, false); let block_quote_kinds: Vec<_> = parsed .events diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 4413e6b0f12..2db1e9b0a24 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -223,6 +223,7 @@ impl MarkdownPreviewView { parse_html: true, render_mermaid_diagrams: true, parse_heading_slugs: true, + render_metadata_blocks: true, ..Default::default() }, cx, @@ -623,6 +624,7 @@ impl MarkdownPreviewView { let mut markdown_element = MarkdownElement::new(self.markdown.clone(), markdown_style) .code_block_renderer(CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::VisibleOnHover, + wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }) .scroll_handle(self.scroll_handle.clone()) diff --git a/crates/mermaid_render/Cargo.toml b/crates/mermaid_render/Cargo.toml new file mode 100644 index 00000000000..73a32ba81fa --- /dev/null +++ b/crates/mermaid_render/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "mermaid_render" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/mermaid_render.rs" +doctest = false + +[features] +test-support = [] + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +merman = { version = "0.4", features = ["render"] } +quick-xml.workspace = true +serde_json.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +mermaid_render = { path = ".", features = ["test-support"] } diff --git a/crates/rules_library/LICENSE-GPL b/crates/mermaid_render/LICENSE-GPL similarity index 100% rename from crates/rules_library/LICENSE-GPL rename to crates/mermaid_render/LICENSE-GPL diff --git a/crates/mermaid_render/src/mermaid_render.rs b/crates/mermaid_render/src/mermaid_render.rs new file mode 100644 index 00000000000..1e17d8d780b --- /dev/null +++ b/crates/mermaid_render/src/mermaid_render.rs @@ -0,0 +1,181 @@ +// for a very big json! macro +#![recursion_limit = "256"] + +//! Crate for rendering Mermaid diagram strings to SVG strings. +//! +//! The entrypoint to this crate is [`render_to_svg`]. +//! +//! It takes a `&str` and a [`MermaidTheme`]. The output is an SVG with the +//! following properties: +//! - The style matches the provided theme +//! - Nodes are given accent colors, even if none are provided in the mermaid +//! source. +//! - The SVG has been tweaked based on the assumption that it will be rasterized +//! using `usvg`/`resvg`. Some bugs/quirks of `usvg`/`resvg` are accounted for +//! in this crate. +//! +//! This module uses the [`merman`] crate for rendering, rather than +//! `mermaid-rs`, which was used in the previous implementation of mermaid +//! rendering in Zed. Merman provides significantly more accurate rendering, and +//! seems to be somewhat faster, but by default has poor CSS, making diagrams +//! look weird without significant cleanup. This is made worse by the fact that +//! `usvg`/`resvg` doesn't support some features that [`merman`] relies on. +//! +//! As such, this crate is quite large. But the code is very self-contained, and +//! has few dependencies. In fact, the [`gpui`] dependency is only needed for +//! the [`Hsla`] and [`Rgba`] color types. +//! +//! The [`render_to_svg`] function operates in two stages: +//! - [`render`] the mermaid text to SVG using [`merman`]. +//! - [`postprocess`] the SVG to clean incorrect output and add styling. +//! +//! The postprocessing is also split up into stages. We parse the generated SVG +//! using [`quick_xml`], which produces an iterator of +//! [`Event<'_>`](quick_xml::events::Event)s. This iterator is then repeatedly +//! transformed, and finally collected back into an SVG string. +//! +//! This approach: +//! - Avoids doing multiple expensive string insertions. +//! - Avoids parsing the SVG multiple times (without needing to put all the +//! logic in one huge function). +//! - But is quite a bit more complex. +//! +//! I think this complexity is justified because of the drastic performance +//! impact, as well as the low-risk nature; this code cannot panic, and errors +//! in the output just produce weird-looking diagrams. +//! +//! ## Color handling +//! +//! We try to match the users theme, and also apply accent colors to diagrams to +//! make them more visually interesting. Accent colors are derived from the +//! `player_colors` in the Zed theme. +//! +//! There are three parts to color handling: +//! +//! 1. A [`merman::MermaidConfig`] is passed when initially rendering the +//! diagram. This sets most "normal" colors (background, text, etc.). However, +//! it's not possible to color nodes individually, and not all parts of the +//! diagrams are correctly themed. +//! 2. `postprocess::accent_colors` injects custom CSS classes (e.g. +//! `zed-accent-0`) to specific elements, based on the diagram type and +//! node. +//! 3. `postprocess::inject_css` injects CSS rules for the classes applied by +//! `accent_colors` + +mod postprocess; +mod render; + +use anyhow::Result; +use gpui::{Hsla, Rgba}; + +#[derive(Debug, Clone, Copy)] +pub struct AccentColor { + pub foreground: Hsla, + pub background: Hsla, +} + +#[derive(Debug, Clone)] +pub struct MermaidTheme { + pub dark_mode: bool, + pub font_family: String, + pub background: Hsla, + pub primary_color: Hsla, + pub primary_text_color: Hsla, + pub primary_border_color: Hsla, + pub secondary_color: Hsla, + pub tertiary_color: Hsla, + pub line_color: Hsla, + pub text_color: Hsla, + pub edge_label_background: Hsla, + pub cluster_background: Hsla, + pub cluster_border: Hsla, + pub note_background: Hsla, + pub note_border: Hsla, + pub actor_background: Hsla, + pub actor_border: Hsla, + pub activation_background: Hsla, + pub activation_border: Hsla, + pub git_branch_colors: [Hsla; 8], + pub git_branch_label_colors: [Hsla; 8], + pub er_attr_bg_odd: Hsla, + pub er_attr_bg_even: Hsla, + pub error_color: Hsla, + pub warning_color: Hsla, + pub accent_colors: Vec, +} + +/// Default theme for testing. +#[cfg(any(test, feature = "test-support"))] +impl Default for MermaidTheme { + fn default() -> Self { + use gpui::{hsla, rgb}; + let git_branch_colors: [Hsla; 8] = [ + hsla(240.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(60.0 / 360.0, 1.0, 0.435_294_12, 1.0), + hsla(80.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(210.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(180.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(150.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(300.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(0.0, 1.0, 0.462_745_1, 1.0), + ]; + let git_branch_label_colors: [Hsla; 8] = + git_branch_colors.map(crate::text_color_for_background); + + Self { + dark_mode: false, + font_family: "Inter, ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", \"DejaVu Sans\", \"Liberation Sans\", sans-serif, \"Noto Color Emoji\", \"Apple Color Emoji\", \"Segoe UI Emoji\"".to_string(), + background: rgb(0xFFFFFF).into(), + primary_color: rgb(0xF8FAFC).into(), + primary_text_color: rgb(0x0F172A).into(), + primary_border_color: rgb(0x94A3B8).into(), + secondary_color: rgb(0xE2E8F0).into(), + tertiary_color: rgb(0xFFFFFF).into(), + line_color: rgb(0x64748B).into(), + text_color: rgb(0x0F172A).into(), + edge_label_background: rgb(0xFFFFFF).into(), + cluster_background: rgb(0xF1F5F9).into(), + cluster_border: rgb(0xCBD5E1).into(), + note_background: rgb(0xFFF7ED).into(), + note_border: rgb(0xFDBA74).into(), + actor_background: rgb(0xF8FAFC).into(), + actor_border: rgb(0x94A3B8).into(), + activation_background: rgb(0xE2E8F0).into(), + activation_border: rgb(0x94A3B8).into(), + git_branch_colors, + git_branch_label_colors, + er_attr_bg_odd: rgb(0x94A3B8).into(), + er_attr_bg_even: rgb(0x0F172A).into(), + error_color: rgb(0xDC2626).into(), + warning_color: rgb(0xD97706).into(), + accent_colors: Vec::new(), + } + } +} + +/// Formats a color as a CSS hex color for embedding in SVG/CSS. +/// +/// Emits `#rrggbb` for fully opaque colors and `#rrggbbaa` when the input +/// has any transparency, so translucent theme colors (e.g. `ghost_element_hover` +/// from Zed's UI palette) round-trip without silently losing their alpha. +pub(crate) fn css_color(color: Hsla) -> String { + let rgba = Rgba::from(color); + let r = (rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8; + let g = (rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8; + let b = (rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8; + let a = (rgba.a.clamp(0.0, 1.0) * 255.0).round() as u8; + if a == 0xff { + format!("#{r:02x}{g:02x}{b:02x}") + } else { + format!("#{r:02x}{g:02x}{b:02x}{a:02x}") + } +} + +pub use postprocess::util::text_color_for_background; + +/// See the [module-level docs][crate] for more info. +pub fn render_to_svg(source: &str, theme: &MermaidTheme) -> Result { + let svg = render::render_mermaid(source, theme)?; + let svg = postprocess::postprocess(&svg, theme)?; + Ok(svg) +} diff --git a/crates/mermaid_render/src/postprocess.rs b/crates/mermaid_render/src/postprocess.rs new file mode 100644 index 00000000000..af1e61f3367 --- /dev/null +++ b/crates/mermaid_render/src/postprocess.rs @@ -0,0 +1,136 @@ +//! Post-processing of [`merman`]-produced SVGs for rasterization with `usvg`/`resvg`. +//! +//! Each submodule is a specific pass that tweaks the SVG event iterator in a particular way. +//! +//! We always produce and consume [`Event`]s with a short lifetime. +//! [`Event<'a>`] is backed internally by a [`Cow<'a, [u8]>`](std::borrow::Cow), +//! so we don't have lifetime issues when we need to mutate the text in an +//! [`Event`], but also don't force allocating a new [`String`] each time. +//! +//! Many modules contain internal structs that implement [`Iterator`] to make +//! reasoning about lifetimes simpler, but these are private implementation +//! details. + +mod accent_colors; +mod element_fixup; +mod fallback_fixup; +mod foreignobject_wrap; +mod inject_css; +mod strip_foreignobject; +mod strip_invalid_css; +pub(crate) mod util; + +use anyhow::{Context as _, Result}; +use quick_xml::Reader; +use quick_xml::events::Event; + +use crate::MermaidTheme; + +pub(super) fn postprocess(svg: &str, theme: &MermaidTheme) -> Result { + // Pass 1: foreignObject preparation (\n fix + word wrapping) + let svg = foreignobject_wrap::process(svg)?; + + // Add fallbacks alongside elements + let svg = merman::render::foreign_object_label_fallback_svg_text(&svg); + + // Extract SVG id for CSS scoping (quick scan of the first element) + let svg_id = extract_svg_id(&svg); + + // Pass 2: themed post-processing pipeline. + // Each adapter takes an iterator of events and returns an iterator of events. + // Events borrow from the `svg` string — no .into_owned() per event. + let mut reader = Reader::from_str(&svg); + reader.config_mut().check_end_names = false; + let events = ReaderIter::new(reader); + let events = strip_foreignobject::process(events); + let events = fallback_fixup::process(events, theme); + let events = element_fixup::process(events, theme); + + let events = accent_colors::process(events, theme); + let events = strip_invalid_css::process(events); + let events = inject_css::process(events, theme, &svg_id); + + let mut writer = quick_xml::Writer::new(Vec::with_capacity(svg.len())); + for event in events { + writer.write_event(event?)?; + } + String::from_utf8(writer.into_inner()).context("SVG output is not valid UTF-8") +} + +fn extract_svg_id(svg: &str) -> String { + let mut reader = Reader::from_str(svg); + reader.config_mut().check_end_names = false; + for event in ReaderIter::new(reader) { + let Ok(Event::Start(e) | Event::Empty(e)) = event else { + continue; + }; + if e.name().as_ref() == b"svg" { + return e + .try_get_attribute("id") + .ok() + .flatten() + .and_then(|a| a.unescape_value().ok()) + .map(|v| v.into_owned()) + .unwrap_or_default(); + } + } + String::new() +} + +struct ReaderIter<'a> { + reader: Reader<&'a [u8]>, + done: bool, +} + +impl<'a> ReaderIter<'a> { + fn new(reader: Reader<&'a [u8]>) -> Self { + Self { + reader, + done: false, + } + } +} + +impl<'a> Iterator for ReaderIter<'a> { + type Item = Result>; + + fn next(&mut self) -> Option { + if self.done { + return None; + } + match self.reader.read_event() { + Ok(Event::Eof) => { + self.done = true; + None + } + Ok(event) => Some(Ok(event)), + Err(e) => { + self.done = true; + Some(Err(e.into())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_theme() -> MermaidTheme { + MermaidTheme::default() + } + + #[test] + fn strip_css_handles_style_element_with_attributes() { + let svg = r#""#; + let result = postprocess(svg, &default_theme()).unwrap(); + assert!( + !result.contains("@keyframes"), + "Unsupported @keyframes should be stripped from +//! +//! +//! +//! ``` + +use std::collections::VecDeque; +use std::fmt::Write; + +use anyhow::Result; +use quick_xml::events::{BytesText, Event}; + +use crate::MermaidTheme; + +/// Morally equivalent to `format!(".section-{i}")`, but without allocating +const MINDMAP_SECTION_SELECTORS: [&str; 11] = [ + ".section-0", + ".section-1", + ".section-2", + ".section-3", + ".section-4", + ".section-5", + ".section-6", + ".section-7", + ".section-8", + ".section-9", + ".section-10", +]; + +struct InjectCss<'a, I> { + inner: I, + injected_css: String, + in_style: bool, + injected: bool, + pending: VecDeque>, +} + +impl<'a, I: Iterator>>> Iterator for InjectCss<'a, I> { + type Item = Result>; + + fn next(&mut self) -> Option { + if let Some(event) = self.pending.pop_front() { + return Some(Ok(event)); + } + + let event = match self.inner.next()? { + Ok(ev) => ev, + Err(e) => return Some(Err(e)), + }; + + match &event { + Event::Start(e) if e.name().as_ref() == b"style" => { + self.in_style = true; + return Some(Ok(event)); + } + Event::End(e) if e.name().as_ref() == b"style" => { + self.in_style = false; + if !self.injected { + self.injected = true; + self.pending + .push_back(Event::Text(BytesText::from_escaped(std::mem::take( + &mut self.injected_css, + )))); + self.pending.push_back(event); + return self.pending.pop_front().map(Ok); + } + return Some(Ok(event)); + } + Event::Text(text) if self.in_style => { + self.injected = true; + let existing = match std::str::from_utf8(text.as_ref()) { + Ok(s) => s, + Err(e) => return Some(Err(e.into())), + }; + let mut combined = String::with_capacity(existing.len() + self.injected_css.len()); + combined.push_str(existing); + combined.push_str(&self.injected_css); + return Some(Ok(Event::Text(BytesText::from_escaped(combined)))); + } + _ => {} + } + + Some(Ok(event)) + } +} + +pub(super) fn process<'a>( + events: impl Iterator>>, + theme: &MermaidTheme, + svg_id: &str, +) -> impl Iterator>> { + let injected_css = build_injected_css(theme, svg_id); + InjectCss { + inner: events, + injected_css, + in_style: false, + injected: false, + pending: VecDeque::new(), + } +} + +fn mindmap_section_css(theme: &MermaidTheme) -> String { + let colors: Vec = theme + .git_branch_colors + .iter() + .map(|c| crate::css_color(*c)) + .collect(); + let fills: Vec = theme + .git_branch_colors + .iter() + .map(|c| { + crate::css_color(blend_over_background( + *c, + theme.background, + ACCENT_FILL_OPACITY, + )) + }) + .collect(); + let text = crate::css_color(theme.text_color); + let mut css = String::with_capacity(5_400); + + let emit = |css: &mut String, selector: &str, color: &str, fill: &str, txt: &str| { + let section_index = selector + .trim_start_matches(".section-root.section-") + .trim_start_matches(".section-"); + write!( + css, + "{selector} rect, {selector} path, {selector} circle, {selector} polygon \ + {{ fill: {fill} !important; stroke: {color} !important; }}\n\ + {selector} text, {selector} span, \ + text{selector}, tspan{selector} \ + {{ fill: {txt} !important; color: {txt} !important; }}\n\ + {selector} foreignObject div, {selector} foreignObject span, {selector} foreignObject p \ + {{ color: {txt} !important; }}\n\ + .section-edge{section_index} {{ stroke: {color} !important; }}\n", + ) + .expect("write to String cannot fail"); + }; + + emit( + &mut css, + ".section-root.section--1", + &colors[0], + &fills[0], + &text, + ); + emit(&mut css, ".section--1", &colors[1], &fills[1], &text); + for (i, selector) in MINDMAP_SECTION_SELECTORS.iter().enumerate() { + let ci = 2 + (i % 6); + emit(&mut css, selector, &colors[ci], &fills[ci], &text); + } + css +} + +fn git_branch_css(theme: &MermaidTheme) -> String { + let text = crate::css_color(theme.text_color); + let mut css = String::with_capacity(8 * 200); + for i in 0..8 { + let c = crate::css_color(theme.git_branch_colors[i]); + let label_fill = crate::css_color(blend_over_background( + theme.git_branch_colors[i], + theme.background, + ACCENT_FILL_OPACITY, + )); + write!( + css, + ".commit{i} {{ stroke: {c}; fill: {c}; }}\n\ + .arrow{i} {{ stroke: {c}; }}\n\ + .label{i} {{ fill: {label_fill}; stroke: {c}; }}\n\ + .branch-label{i} {{ fill: {text}; }}\n" + ) + .expect("write to String cannot fail"); + } + css +} + +fn adjust_lightness(color: &mut gpui::Hsla, dark_mode: bool) { + if dark_mode { + color.l = (color.l * 0.7).max(0.0); + } else { + color.l = (color.l * 1.3).min(1.0); + } +} + +const ACCENT_FILL_OPACITY: f32 = 0.15; + +fn blend_over_background( + foreground: gpui::Hsla, + background: gpui::Hsla, + opacity: f32, +) -> gpui::Hsla { + let fg = gpui::Rgba::from(foreground); + let bg = gpui::Rgba::from(background); + let blended = gpui::Rgba { + r: fg.r * opacity + bg.r * (1.0 - opacity), + g: fg.g * opacity + bg.g * (1.0 - opacity), + b: fg.b * opacity + bg.b * (1.0 - opacity), + a: 1.0, + }; + gpui::Hsla::from(blended) +} + +fn accent_css(theme: &MermaidTheme) -> String { + let mut css = String::with_capacity(theme.accent_colors.len() * 420); + let text = crate::css_color(theme.text_color); + + for (i, accent) in theme.accent_colors.iter().enumerate() { + let stroke = crate::css_color(accent.foreground); + let fill = crate::css_color(blend_over_background( + accent.background, + theme.background, + ACCENT_FILL_OPACITY, + )); + let class = format!(".zed-accent-{i}"); + write!( + css, + "{class} rect, {class} path, {class} circle, {class} polygon, {class} ellipse, \ + rect{class}, path{class}, circle{class}, polygon{class}, ellipse{class} \ + {{ fill: {fill} !important; stroke: {stroke} !important; }}\n\ + {class} text, {class} tspan, text{class}, tspan{class} \ + {{ fill: {text} !important; }}\n", + ) + .expect("write to String cannot fail"); + } + css +} + +fn chart_color_css(theme: &MermaidTheme) -> String { + // Each block is around 230 bytes, add some headroom + let mut css = String::with_capacity(8 * 250); + for i in 0..8 { + let color = crate::css_color(theme.git_branch_colors[i]); + let class = format!(".zed-chart-{i}"); + write!( + css, + "path.pieCircle{class} {{ fill: {color} !important; }}\n\ + .plot rect{class}, .legend rect{class} {{ fill: {color} !important; stroke: {color} !important; }}\n\ + .plot path{class} {{ stroke: {color} !important; }}\n" + ) + .expect("write to String cannot fail"); + } + css +} + +fn timeline_css(theme: &MermaidTheme) -> String { + let mut css = String::with_capacity(8 * 300); + let text = crate::css_color(theme.text_color); + for i in 0..8 { + let c = crate::css_color(theme.git_branch_colors[i]); + let fill = crate::css_color(blend_over_background( + theme.git_branch_colors[i], + theme.background, + ACCENT_FILL_OPACITY, + )); + write!( + css, + "rect.task-type-{i}, rect.section-type-{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n" + ).expect("write to String cannot fail"); + } + for i in 0..4 { + let c = crate::css_color(theme.git_branch_colors[i % 8]); + let fill = crate::css_color(blend_over_background( + theme.git_branch_colors[i % 8], + theme.background, + ACCENT_FILL_OPACITY, + )); + write!( + css, + ".section{i} {{ fill: {fill} !important; }}\n\ + .task{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n\ + .taskText{i} {{ fill: {text} !important; }}\n\ + .taskTextOutside{i} {{ fill: {text} !important; }}\n" + ) + .expect("write to String cannot fail"); + } + css +} + +fn should_scope_css_line(trimmed: &str) -> bool { + !trimmed.is_empty() + && (trimmed.starts_with('.') + || trimmed.starts_with("foreignObject") + || trimmed.starts_with("g.") + || trimmed.starts_with("text") + || trimmed.starts_with("tspan") + || trimmed.starts_with("rect.") + || trimmed.starts_with("path.") + || trimmed.starts_with("defs") + || trimmed.starts_with('#')) +} + +fn scoped_selector_count(raw_css: &str) -> usize { + raw_css.lines().fold(0, |count, line| { + let trimmed = line.trim(); + if !should_scope_css_line(trimmed) { + return count; + } + let Some((selectors, _)) = trimmed.split_once('{') else { + return count; + }; + count.saturating_add(selectors.split(',').count()) + }) +} + +fn scope_css(raw_css: &str, svg_id: &str) -> String { + let scoped_selector_prefix_len = svg_id.len().saturating_add(2); + let result_capacity = raw_css + .len() + .saturating_add(scoped_selector_count(raw_css).saturating_mul(scoped_selector_prefix_len)); + let mut result = String::with_capacity(result_capacity); + for line in raw_css.lines() { + let trimmed = line.trim(); + + if should_scope_css_line(trimmed) { + if let Some(brace) = trimmed.find('{') { + let (selectors, rest) = trimmed.split_at(brace); + let mut first = true; + for selector in selectors.split(',') { + if !first { + result.push_str(", "); + } + first = false; + write!(result, "#{svg_id} {}", selector.trim()) + .expect("write to String cannot fail"); + } + writeln!(result, "{rest}").expect("write to String cannot fail"); + continue; + } + } + writeln!(result, "{line}").expect("write to String cannot fail"); + } + result +} + +fn build_injected_css(theme: &MermaidTheme, svg_id: &str) -> String { + let font = &theme.font_family; + let text = crate::css_color(theme.text_color); + let line = crate::css_color(theme.line_color); + let primary = crate::css_color(theme.primary_color); + let border = crate::css_color(theme.primary_border_color); + let secondary = crate::css_color(theme.secondary_color); + let tertiary = crate::css_color(theme.tertiary_color); + let background = crate::css_color(theme.background); + let edge_label_bg = crate::css_color(theme.edge_label_background); + let actor_bg = crate::css_color(theme.actor_background); + let actor_border = crate::css_color(theme.actor_border); + let error_bg = { + let mut c = theme.error_color; + adjust_lightness(&mut c, theme.dark_mode); + c + }; + let error = crate::css_color(error_bg); + let error_text = crate::css_color(crate::postprocess::util::text_color_for_background( + error_bg, + )); + let warning_bg = { + let mut c = theme.warning_color; + adjust_lightness(&mut c, theme.dark_mode); + c + }; + let warning = crate::css_color(warning_bg); + let warning_text = crate::css_color(crate::postprocess::util::text_color_for_background( + warning_bg, + )); + let note_bg = crate::css_color(theme.note_background); + let note_border = crate::css_color(theme.note_border); + let er_odd = crate::css_color(theme.er_attr_bg_odd); + let er_even = crate::css_color(theme.er_attr_bg_even); + + let actor_text = &text; + let note_text = &text; + + let raw_css = format!( + r#" + text, tspan, foreignObject div, foreignObject span, foreignObject p {{ font-family: {font} !important; }} + foreignObject div, foreignObject span, foreignObject p {{ font-size: 16px; color: {text}; }} + foreignObject p {{ margin: 0; }} + foreignObject {{ overflow: visible; }} + foreignObject div {{ max-width: none !important; }} + .label-group foreignObject {{ font-weight: bold; }} + .node rect, .node path {{ fill: {primary}; stroke: {border}; }} + .node polygon {{ fill: {primary}; stroke: {border}; }} + .label-container path {{ fill: {primary}; stroke: {border}; }} + {mindmap_css} + .mindmap-node line, .timeline-node line {{ stroke: transparent !important; }} + g.stateGroup rect {{ fill: {primary} !important; stroke: {border} !important; }} + g.stateGroup text {{ fill: {text} !important; }} + g.stateGroup .state-title {{ fill: {text} !important; }} + .stateGroup .composit {{ fill: {background} !important; }} + .stateGroup .alt-composit {{ fill: {tertiary} !important; }} + .state-note {{ stroke: {note_border} !important; fill: {note_bg} !important; }} + .state-note text {{ fill: {note_text} !important; }} + .stateLabel .box {{ fill: {primary} !important; }} + .stateLabel text {{ fill: {text} !important; }} + .node circle.state-start {{ fill: {line} !important; stroke: {line} !important; }} + .node .fork-join {{ fill: {line} !important; stroke: {line} !important; }} + .node circle.state-end {{ fill: {border} !important; stroke: {background} !important; }} + .end-state-inner {{ fill: {background} !important; }} + .statediagram-cluster rect {{ fill: {primary} !important; stroke: {border} !important; }} + .statediagram-cluster.statediagram-cluster .inner {{ fill: {background} !important; }} + .statediagram-cluster.statediagram-cluster-alt .inner {{ fill: {tertiary} !important; }} + .statediagram-state rect.divider {{ fill: {tertiary} !important; }} + .statediagram-note rect {{ fill: {note_bg} !important; stroke: {note_border} !important; }} + .statediagram-note text {{ fill: {note_text} !important; }} + .statediagramTitleText {{ fill: {text} !important; }} + .transition {{ stroke: {line} !important; }} + .cluster-label, .nodeLabel {{ color: {text} !important; }} + defs #statediagram-barbEnd {{ fill: {line} !important; stroke: {line} !important; }} + #statediagram-barbEnd {{ fill: {line} !important; }} + .edgeLabel .label rect {{ fill: {primary} !important; }} + .edgeLabel rect {{ fill: {primary} !important; background-color: {primary} !important; }} + .edgeLabel .label text {{ fill: {text} !important; }} + .edgeLabel p {{ background-color: {primary} !important; }} + .edgeLabel {{ background-color: {primary} !important; }} + .actor {{ stroke: {actor_border}; fill: {actor_bg}; }} + text.actor {{ text-anchor: middle; }} + text.actor>tspan {{ fill: {actor_text} !important; stroke: none; }} + .labelText, .labelText>tspan {{ fill: {actor_text} !important; }} + .actor-line {{ stroke: {actor_border} !important; }} + .messageLine0 {{ stroke: {text} !important; }} + .messageLine1 {{ stroke: {text} !important; }} + #arrowhead path {{ fill: {text} !important; stroke: {text} !important; }} + #crosshead path {{ fill: {text} !important; stroke: {text} !important; }} + .messageText {{ fill: {text} !important; }} + .loopText, .loopText>tspan {{ fill: {text} !important; }} + .loopLine {{ stroke: {actor_border} !important; fill: {actor_border} !important; }} + .note {{ stroke: {note_border} !important; fill: {note_bg} !important; }} + .noteText, .noteText>tspan {{ fill: {note_text} !important; }} + .activation0, .activation1, .activation2 {{ fill: {secondary} !important; stroke: {border} !important; }} + .labelBox {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }} + .actor-man line {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }} + .actor-man circle {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }} + .pieTitleText {{ fill: {text} !important; }} + .slice {{ fill: {text} !important; }} + .legend text {{ fill: {text} !important; }} + .pieOuterCircle {{ stroke: {border} !important; }} + .pieCircle {{ stroke: {border} !important; }} + {timeline_css} + text.journey-section, text.task {{ fill: {text} !important; }} + .relationshipLabelBox {{ fill: {tertiary} !important; opacity: 0.7; background-color: {tertiary} !important; }} + .labelBkg {{ background-color: {tertiary} !important; }} + .edgeLabel .label {{ fill: {border} !important; }} + .label {{ color: {text} !important; }} + .relationshipLine {{ stroke: {line} !important; fill: none !important; }} + .entityBox {{ fill: {primary}; stroke: {border}; }} + .node .row-rect-odd path {{ fill: {er_odd} !important; }} + .node .row-rect-even path {{ fill: {er_even} !important; }} + .edge-thickness-normal {{ stroke-width: 1px; }} + .relation {{ stroke: {line}; stroke-width: 1; fill: none; }} + .edgePaths path {{ fill: none; }} + .marker {{ fill: {line} !important; stroke: {line} !important; }} + .marker.er {{ fill: none !important; stroke: {line} !important; }} + .composition {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }} + .extension {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }} + .aggregation {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }} + .dependency {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }} + .lollipop {{ fill: {primary} !important; stroke: {line} !important; stroke-width: 1; }} + .sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3 {{ fill: {text} !important; }} + .sectionTitle {{ font-family: {font} !important; }} + .taskTextOutsideRight {{ fill: {text} !important; font-family: {font} !important; }} + .taskTextOutsideLeft {{ fill: {text} !important; }} + .active0, .active1, .active2, .active3 {{ fill: {secondary} !important; stroke: {border} !important; }} + .activeText0, .activeText1, .activeText2, .activeText3 {{ fill: {text} !important; }} + .done0, .done1, .done2, .done3 {{ stroke: {border} !important; fill: {secondary} !important; stroke-width: 2; }} + .doneText0, .doneText1, .doneText2, .doneText3 {{ fill: {text} !important; }} + .crit0, .crit1, .crit2, .crit3 {{ fill: {error} !important; stroke: {error} !important; }} + .critText0, .critText1, .critText2, .critText3 {{ fill: {error_text} !important; }} + .activeCrit0, .activeCrit1, .activeCrit2, .activeCrit3 {{ fill: {warning} !important; stroke: {warning} !important; }} + .activeCritText0, .activeCritText1, .activeCritText2, .activeCritText3 {{ fill: {warning_text} !important; }} + .doneCrit0, .doneCrit1, .doneCrit2, .doneCrit3 {{ fill: {error} !important; stroke: {border} !important; stroke-width: 2; }} + .doneCritText0, .doneCritText1, .doneCritText2, .doneCritText3 {{ fill: {error_text} !important; }} + .titleText {{ fill: {text} !important; font-family: {font} !important; }} + .grid .tick text {{ fill: {text} !important; font-family: {font} !important; }} + .grid .tick {{ stroke: {border} !important; }} + {git_branch_css} + .commit-merge {{ stroke: {primary}; fill: {primary}; }} + .commit-reverse {{ stroke: {primary}; fill: {primary}; stroke-width: 3; }} + .commit-highlight-inner {{ stroke: {primary}; fill: {primary}; }} + .tag-label {{ font-size: 10px; }} + .tag-label-bkg {{ fill: {primary}; stroke: {border}; }} + .tag-hole {{ fill: {line}; }} + .commit-label {{ fill: {text}; }} + .commit-label-bkg {{ fill: {edge_label_bg}; }} + .commit-id, .commit-msg, .branch-label {{ fill: {text}; color: {text}; font-family: {font}; }} + {accent_css} + .data-point text {{ fill: {text} !important; }} + {chart_color_css} + "#, + mindmap_css = mindmap_section_css(theme), + git_branch_css = git_branch_css(theme), + accent_css = accent_css(theme), + chart_color_css = chart_color_css(theme), + timeline_css = timeline_css(theme), + ); + + scope_css(&raw_css, svg_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_css_prefixes_selectors() { + let input = " .foo { color: red; }\n"; + let result = scope_css(input, "my-svg"); + assert!(result.contains("#my-svg .foo"), "got: {result}"); + } +} diff --git a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs new file mode 100644 index 00000000000..8935c73e392 --- /dev/null +++ b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs @@ -0,0 +1,114 @@ +//! Strips `` elements and their contents from the SVG, since +//! `usvg`/`resvg` does not support them. +//! +//! ```xml +//! +//!
Hello
+//! Hello +//! +//! +//! Hello +//! ``` + +use anyhow::Result; +use quick_xml::events::Event; + +struct StripForeignObject { + inner: I, + /// Depth inside a `` element being stripped. + foreign_depth: usize, + /// Depth inside a `` being stripped. + fallback_depth: usize, + /// Set to true once we see a `` element outside of foreignObjects + /// and fallback groups. When true, fallback groups are redundant and + /// should be stripped. + has_native_text: bool, +} + +impl<'a, I: Iterator>>> Iterator for StripForeignObject { + type Item = Result>; + + fn next(&mut self) -> Option { + loop { + let event = self.inner.next()?; + let event = match event { + Ok(event) => event, + Err(e) => return Some(Err(e)), + }; + + // Strip foreignObject elements and their contents. + match &event { + Event::Start(e) if e.name().as_ref() == b"foreignObject" => { + self.foreign_depth += 1; + continue; + } + Event::Start(_) if self.foreign_depth > 0 => { + self.foreign_depth += 1; + continue; + } + Event::End(_) if self.foreign_depth > 0 => { + self.foreign_depth -= 1; + continue; + } + Event::Empty(e) if e.name().as_ref() == b"foreignObject" => { + continue; + } + _ if self.foreign_depth > 0 => { + continue; + } + _ => {} + } + + // Strip fallback groups when native text exists. + match &event { + Event::Start(e) if e.name().as_ref() == b"g" && self.fallback_depth == 0 => { + if self.has_native_text { + if let Ok(Some(attr)) = e.try_get_attribute("data-merman-foreignobject") { + if attr.value.as_ref() == b"fallback" { + self.fallback_depth = 1; + continue; + } + } + } + } + Event::Start(_) if self.fallback_depth > 0 => { + self.fallback_depth += 1; + continue; + } + Event::End(_) if self.fallback_depth > 0 => { + self.fallback_depth -= 1; + continue; + } + _ if self.fallback_depth > 0 => { + continue; + } + _ => {} + } + + // Track whether the diagram has native elements. + if !self.has_native_text { + match &event { + Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"text" => { + if e.try_get_attribute("class").ok().flatten().is_some() { + self.has_native_text = true; + } + } + _ => {} + } + } + + return Some(Ok(event)); + } + } +} + +pub(super) fn process<'a>( + inner: impl Iterator>>, +) -> impl Iterator>> { + StripForeignObject { + inner, + foreign_depth: 0, + fallback_depth: 0, + has_native_text: false, + } +} diff --git a/crates/mermaid_render/src/postprocess/strip_invalid_css.rs b/crates/mermaid_render/src/postprocess/strip_invalid_css.rs new file mode 100644 index 00000000000..12efb99be2e --- /dev/null +++ b/crates/mermaid_render/src/postprocess/strip_invalid_css.rs @@ -0,0 +1,161 @@ +//! Removes CSS constructs that `usvg`/`resvg` cannot handle. +//! +//! - `@keyframes` and `@-webkit-keyframes` blocks +//! - `:root { ... }` blocks (CSS custom properties) +//! - `:not(...)` pseudo-selectors +//! - `deg` angle units (e.g. `rotate(45deg)` → `rotate(45)`) +//! +//! Also removes `!important` declarations (so that our injected theme CSS +//! always wins). + +use std::borrow::Cow; + +use anyhow::Result; +use quick_xml::events::{BytesText, Event}; + +struct StripInvalidCss { + inner: I, + in_style: bool, +} + +impl<'a, I: Iterator>>> Iterator for StripInvalidCss { + type Item = Result>; + + fn next(&mut self) -> Option { + let event = match self.inner.next()? { + Ok(ev) => ev, + Err(e) => return Some(Err(e)), + }; + + match &event { + Event::Start(e) if e.name().as_ref() == b"style" => { + self.in_style = true; + } + Event::End(e) if e.name().as_ref() == b"style" => { + self.in_style = false; + } + Event::Text(text) if self.in_style => { + let css_text = match std::str::from_utf8(text.as_ref()) { + Ok(s) => s, + Err(e) => return Some(Err(e.into())), + }; + return Some(match strip_unsupported_css(css_text) { + Cow::Borrowed(_) => Ok(event), + Cow::Owned(processed) => Ok(Event::Text(BytesText::from_escaped(processed))), + }); + } + _ => {} + } + + Some(Ok(event)) + } +} + +pub(super) fn process<'a>( + events: impl Iterator>>, +) -> impl Iterator>> { + StripInvalidCss { + inner: events, + in_style: false, + } +} + +fn strip_unsupported_css(css: &str) -> Cow<'_, str> { + let mut chars = css.char_indices().peekable(); + let mut result = None; + let mut copied_until = 0; + + while let Some((i, _)) = chars.next() { + let remaining = &css[i..]; + + if remaining.starts_with("@keyframes") + || remaining.starts_with("@-webkit-keyframes") + || remaining.starts_with(":root") + { + let result = result.get_or_insert_with(|| String::with_capacity(css.len())); + result.push_str(&css[copied_until..i]); + skip_css_block(&mut chars); + copied_until = chars.peek().map_or(css.len(), |&(i, _)| i); + } + } + + let mut result = if let Some(mut result) = result { + result.push_str(&css[copied_until..]); + Cow::Owned(result) + } else { + Cow::Borrowed(css) + }; + + strip_css_angle_units(&mut result); + strip_css_important(&mut result); + result +} + +fn skip_css_block(chars: &mut std::iter::Peekable) { + for (_, c) in chars.by_ref() { + if c == '{' { + break; + } + } + let mut depth = 1u32; + for (_, c) in chars.by_ref() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return; + } + } + _ => {} + } + } +} + +fn replace_all_in_place(css: &mut Cow<'_, str>, needle: &str, replacement: &str) { + while let Some(pos) = css.as_ref().find(needle) { + css.to_mut() + .replace_range(pos..pos + needle.len(), replacement); + } +} + +fn strip_css_angle_units(css: &mut Cow<'_, str>) { + replace_all_in_place(css, "deg)", ")"); +} + +/// Strip `!important` from mermaid's generated CSS so that our injected +/// theme CSS (which uses `!important`) always takes priority. This works +/// around a usvg cascade bug where competing `!important` rules are +/// resolved by first-wins rather than the CSS spec's last-wins. +fn strip_css_important(css: &mut Cow<'_, str>) { + replace_all_in_place(css, "!important", ""); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_keyframes() { + let input = "@keyframes bounce { 0% { transform: scale(1); } 100% { transform: scale(1.1); } } .node rect { fill: red; }"; + let result = strip_unsupported_css(input); + assert!(!result.contains("@keyframes"), "got: {result}"); + assert!(result.contains(".node rect"), "got: {result}"); + } + + #[test] + fn strips_root_blocks() { + let input = ":root { --bg: white; } .foo { color: red; }"; + let result = strip_unsupported_css(input); + assert!(!result.contains(":root"), "got: {result}"); + assert!(result.contains(".foo"), "got: {result}"); + } + + #[test] + fn strips_deg_units() { + let input = ".foo { transform: rotate(45deg); }"; + let result = strip_unsupported_css(input); + assert!(result.contains("rotate(45)"), "got: {result}"); + assert!(!result.contains("deg"), "got: {result}"); + } +} diff --git a/crates/mermaid_render/src/postprocess/util.rs b/crates/mermaid_render/src/postprocess/util.rs new file mode 100644 index 00000000000..70df1af058e --- /dev/null +++ b/crates/mermaid_render/src/postprocess/util.rs @@ -0,0 +1,148 @@ +use gpui::{Hsla, Rgba}; + +/// Produces a readable text color for a given background, subtly tinted by the +/// background's own hue using the OKLCH color space. +/// +/// The result keeps ~15% of the background's chroma so the text feels +/// harmonious with its surroundings rather than a flat black or white. +/// Lightness is set to ensure readable contrast against the background. +pub fn text_color_for_background(background: Hsla) -> Hsla { + let rgba = Rgba::from(background); + let r_lin = srgb_to_linear(rgba.r); + let g_lin = srgb_to_linear(rgba.g); + let b_lin = srgb_to_linear(rgba.b); + + let (_, ok_a, ok_b) = linear_rgb_to_oklab(r_lin, g_lin, b_lin); + let chroma = (ok_a * ok_a + ok_b * ok_b).sqrt(); + let hue = ok_b.atan2(ok_a); + + let bg_luminance = relative_luminance(rgba); + let text_l = if bg_luminance > 0.18 { 0.18 } else { 0.96 }; + let text_c = chroma * 0.15; + + let build = |c: f32| -> Rgba { + let (tr, tg, tb) = oklab_to_linear_rgb(text_l, c * hue.cos(), c * hue.sin()); + Rgba { + r: linear_to_srgb(tr.clamp(0.0, 1.0)), + g: linear_to_srgb(tg.clamp(0.0, 1.0)), + b: linear_to_srgb(tb.clamp(0.0, 1.0)), + a: 1.0, + } + }; + + let meets_contrast = + |fg: Rgba| contrast_ratio_between(bg_luminance, relative_luminance(fg)) >= 4.5; + + let candidate = build(text_c); + let result = if meets_contrast(candidate) { + candidate + } else { + // Binary search for the maximum chroma that still meets 4.5:1. + let mut lo = 0.0_f32; + let mut hi = text_c; + for _ in 0..16 { + let mid = (lo + hi) * 0.5; + if meets_contrast(build(mid)) { + lo = mid; + } else { + hi = mid; + } + } + let best = build(lo); + // Floating-point precision can leave the binary search result just + // below the 4.5:1 threshold. Fall back to pure black or white. + if meets_contrast(best) { + best + } else if bg_luminance > 0.18 { + Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + } + } else { + Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + } + } + }; + Hsla::from(result) +} + +fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(c: f32) -> f32 { + if c <= 0.0031308 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + } +} + +fn linear_rgb_to_oklab(r: f32, g: f32, b: f32) -> (f32, f32, f32) { + let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt(); + let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt(); + let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt(); + ( + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ) +} + +fn oklab_to_linear_rgb(l: f32, a: f32, b: f32) -> (f32, f32, f32) { + let l_ = l + 0.3963377774 * a + 0.2158037573 * b; + let m_ = l - 0.1055613458 * a - 0.0638541728 * b; + let s_ = l - 0.0894841775 * a - 1.2914855480 * b; + ( + 4.0767416621 * l_ * l_ * l_ - 3.3077115913 * m_ * m_ * m_ + 0.2309699292 * s_ * s_ * s_, + -1.2684380046 * l_ * l_ * l_ + 2.6097574011 * m_ * m_ * m_ - 0.3413193965 * s_ * s_ * s_, + -0.0041960863 * l_ * l_ * l_ - 0.7034186147 * m_ * m_ * m_ + 1.7076147010 * s_ * s_ * s_, + ) +} + +fn relative_luminance(c: Rgba) -> f32 { + 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b) +} + +fn contrast_ratio_between(luminance_a: f32, luminance_b: f32) -> f32 { + let (lighter, darker) = if luminance_a > luminance_b { + (luminance_a, luminance_b) + } else { + (luminance_b, luminance_a) + }; + (lighter + 0.05) / (darker + 0.05) +} + +#[cfg(test)] +fn wcag_contrast_ratio(a: Rgba, b: Rgba) -> f32 { + contrast_ratio_between(relative_luminance(a), relative_luminance(b)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::proptest::prelude::*; + + #[gpui::property_test] + fn sufficient_contrast_for_any_opaque_background( + #[strategy = Hsla::opaque_strategy()] bg: Hsla, + ) -> Result<(), TestCaseError> { + let text = text_color_for_background(bg); + let ratio = wcag_contrast_ratio(Rgba::from(bg), Rgba::from(text)); + prop_assert!( + ratio >= 4.5, + "WCAG AA contrast ratio {ratio:.2} < 4.5 for bg {bg:?} -> text {text:?}", + ); + Ok(()) + } +} diff --git a/crates/mermaid_render/src/render.rs b/crates/mermaid_render/src/render.rs new file mode 100644 index 00000000000..22c49d3f35f --- /dev/null +++ b/crates/mermaid_render/src/render.rs @@ -0,0 +1,122 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{Context as _, Result, anyhow}; + +use crate::{MermaidTheme, css_color}; + +pub(super) fn render_mermaid(source: &str, theme: &MermaidTheme) -> Result { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let diagram_id = format!("merman-{id}"); + + let config = to_merman_config(theme); + let renderer = merman::render::HeadlessRenderer::new() + .with_site_config(config) + .with_vendored_text_measurer() + .with_diagram_id(&diagram_id); + + let svg = renderer + .render_svg_sync(source) + .context("merman render failed")? + .ok_or_else(|| anyhow!("merman returned no SVG for the given input"))?; + + Ok(svg) +} + +fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig { + let primary = css_color(theme.primary_color); + let primary_text = css_color(theme.primary_text_color); + let primary_border = css_color(theme.primary_border_color); + let line = css_color(theme.line_color); + let secondary = css_color(theme.secondary_color); + let tertiary = css_color(theme.tertiary_color); + let background = css_color(theme.background); + let cluster_bg = css_color(theme.cluster_background); + let cluster_border = css_color(theme.cluster_border); + let edge_label_bg = css_color(theme.edge_label_background); + let text = css_color(theme.text_color); + let note_bg = css_color(theme.note_background); + let note_border = css_color(theme.note_border); + let actor_bg = css_color(theme.actor_background); + let actor_border = css_color(theme.actor_border); + let activation_bg = css_color(theme.activation_background); + let activation_border = css_color(theme.activation_border); + let er_odd = css_color(theme.er_attr_bg_odd); + let er_even = css_color(theme.er_attr_bg_even); + let git: [String; 8] = theme.git_branch_colors.map(css_color); + let git_lbl: [String; 8] = theme.git_branch_label_colors.map(css_color); + + let mut theme_vars = serde_json::json!({ + "primaryColor": primary, + "primaryTextColor": primary_text, + "primaryBorderColor": primary_border, + "lineColor": line, + "secondaryColor": secondary, + "secondaryTextColor": text, + "tertiaryColor": tertiary, + "tertiaryTextColor": text, + "background": background, + "mainBkg": primary, + "nodeBorder": primary_border, + "nodeTextColor": primary_text, + "clusterBkg": cluster_bg, + "clusterBorder": cluster_border, + "titleColor": text, + "edgeLabelBackground": edge_label_bg, + "textColor": text, + "fontFamily": theme.font_family, + "noteBkgColor": note_bg, + "noteBorderColor": note_border, + "noteTextColor": text, + "actorBkg": actor_bg, + "actorBorder": actor_border, + "actorTextColor": primary_text, + "labelTextColor": text, + "loopTextColor": text, + "signalColor": text, + "signalTextColor": text, + "activationBkgColor": activation_bg, + "activationBorderColor": activation_border, + "classText": text, + "labelColor": primary_text, + "attributeBackgroundColorOdd": er_odd, + "attributeBackgroundColorEven": er_even, + "pieTitleTextColor": text, + "pieSectionTextColor": text, + "pieLegendTextColor": text, + "pieStrokeColor": primary_border, + "pieOuterStrokeColor": primary_border, + "quadrant1Fill": primary, + "quadrant2Fill": primary, + "quadrant3Fill": primary, + "quadrant4Fill": primary, + "quadrant1TextFill": text, + "quadrant2TextFill": text, + "quadrant3TextFill": text, + "quadrant4TextFill": text, + "quadrantPointFill": line, + "quadrantPointTextFill": text, + "quadrantTitleFill": text, + "quadrantXAxisTextFill": text, + "quadrantYAxisTextFill": text, + "quadrantExternalBorderStrokeFill": primary_border, + "quadrantInternalBorderStrokeFill": primary_border, + }); + + let map = theme_vars.as_object_mut().expect("just created as object"); + for i in 0..8 { + map.insert(format!("cScale{i}"), git[i].clone().into()); + map.insert(format!("cScaleLabel{i}"), git_lbl[i].clone().into()); + map.insert(format!("pie{}", i + 1), git[i].clone().into()); + } + + merman::MermaidConfig::from_value(serde_json::json!({ + "theme": "base", + "darkMode": theme.dark_mode, + "fontFamily": theme.font_family, + "flowchart": { + "padding": 16, + }, + "themeVariables": theme_vars, + })) +} diff --git a/crates/mermaid_render/tests/check_invalid_attrs.rs b/crates/mermaid_render/tests/check_invalid_attrs.rs new file mode 100644 index 00000000000..e4d49384bf2 --- /dev/null +++ b/crates/mermaid_render/tests/check_invalid_attrs.rs @@ -0,0 +1,394 @@ +use gpui::Hsla; +use mermaid_render::MermaidTheme; + +fn rgb(r: u8, g: u8, b: u8) -> Hsla { + gpui::Rgba { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: 1.0, + } + .into() +} + +const DIAGRAMS: &[(&str, &str)] = &[ + ( + "flowchart", + "flowchart TD\n A[Hello] --> B[World]\n B --> C{Decision}\n C -->|Yes| D[OK]\n C -->|No| E[Fail]", + ), + ( + "sequence", + "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi\n Note over Alice,Bob: A note", + ), + ( + "state", + "stateDiagram-v2\n [*] --> Active\n Active --> [*]", + ), + ( + "er", + "erDiagram\n A { int id PK }\n B { int id PK }\n A ||--o{ B : has", + ), + ( + "class", + "classDiagram\n class Foo {\n +bar() void\n }", + ), + ("pie", "pie title Test\n \"A\" : 42\n \"B\" : 58"), + ( + "gantt", + "gantt\n title Test\n dateFormat YYYY-MM-DD\n section S\n Task :a1, 2025-01-01, 7d", + ), + ("mindmap", "mindmap\n root((Root))\n Child1\n Child2"), + ( + "journey", + "journey\n title Test\n section S\n Task: 5: Actor", + ), + ( + "gitgraph", + "gitGraph\n commit id: \"init\"\n branch dev\n commit id: \"feat\"\n checkout main\n merge dev", + ), + ( + "quadrant", + "quadrantChart\n title Test\n x-axis Low --> High\n y-axis Low --> High\n A: [0.3, 0.8]\n B: [0.7, 0.4]", + ), + ( + "timeline", + "timeline\n title Test\n section 2020s\n 2020 : Event A\n 2022 : Event B", + ), + ( + "xychart", + "xychart-beta\n title Test\n x-axis [\"A\", \"B\", \"C\"]\n y-axis \"Val\" 0 --> 10\n bar [3, 7, 5]", + ), +]; + +fn rgb_theme() -> MermaidTheme { + MermaidTheme { + dark_mode: true, + font_family: "system-ui".to_string(), + background: rgb(40, 44, 51), + primary_color: rgb(47, 52, 62), + primary_text_color: rgb(220, 224, 229), + primary_border_color: rgb(70, 75, 87), + secondary_color: rgb(46, 52, 62), + tertiary_color: rgb(54, 60, 70), + line_color: rgb(70, 75, 87), + text_color: rgb(220, 224, 229), + edge_label_background: rgb(40, 44, 51), + cluster_background: rgb(47, 52, 62), + cluster_border: rgb(54, 60, 70), + note_background: rgb(47, 52, 62), + note_border: rgb(54, 60, 70), + actor_background: rgb(46, 52, 62), + actor_border: rgb(70, 75, 87), + activation_background: rgb(54, 60, 70), + activation_border: rgb(70, 75, 87), + git_branch_colors: [ + rgb(116, 173, 232), + rgb(190, 80, 70), + rgb(191, 149, 106), + rgb(180, 119, 207), + rgb(110, 180, 191), + rgb(208, 114, 119), + rgb(222, 193, 132), + rgb(161, 193, 129), + ], + git_branch_label_colors: [ + rgb(116, 173, 232), + rgb(190, 80, 70), + rgb(191, 149, 106), + rgb(180, 119, 207), + rgb(110, 180, 191), + rgb(208, 114, 119), + rgb(222, 193, 132), + rgb(161, 193, 129), + ] + .map(mermaid_render::text_color_for_background), + er_attr_bg_odd: rgb(47, 52, 62), + er_attr_bg_even: rgb(46, 52, 62), + error_color: rgb(220, 38, 38), + warning_color: rgb(217, 119, 6), + accent_colors: vec![ + mermaid_render::AccentColor { + foreground: rgb(116, 173, 232), + background: rgb(116, 173, 232), + }, + mermaid_render::AccentColor { + foreground: rgb(190, 80, 70), + background: rgb(190, 80, 70), + }, + mermaid_render::AccentColor { + foreground: rgb(191, 149, 106), + background: rgb(191, 149, 106), + }, + mermaid_render::AccentColor { + foreground: rgb(180, 119, 207), + background: rgb(180, 119, 207), + }, + mermaid_render::AccentColor { + foreground: rgb(110, 180, 191), + background: rgb(110, 180, 191), + }, + mermaid_render::AccentColor { + foreground: rgb(208, 114, 119), + background: rgb(208, 114, 119), + }, + mermaid_render::AccentColor { + foreground: rgb(222, 193, 132), + background: rgb(222, 193, 132), + }, + mermaid_render::AccentColor { + foreground: rgb(161, 193, 129), + background: rgb(161, 193, 129), + }, + ], + } +} + +fn check_svg_issues(name: &str, svg: &str) -> Vec { + let bad_patterns = [ + "fill=\"\"", + "stroke=\"\"", + "width=\"\"", + "height=\"\"", + "NaN", + // Also check for empty values in style attributes + "fill: ;", + "fill:;", + "stroke: ;", + "stroke:;", + // Check for attributes with just whitespace + "fill=\" \"", + ]; + let mut issues = Vec::new(); + for pattern in &bad_patterns { + let mut start = 0; + while let Some(pos) = svg[start..].find(pattern) { + let abs = start + pos; + let ctx_start = abs.saturating_sub(100); + let ctx_end = (abs + pattern.len() + 60).min(svg.len()); + issues.push(format!( + "{name}: found `{pattern}` at byte {abs}:\n ...{}...\n", + &svg[ctx_start..ctx_end] + )); + start = abs + pattern.len(); + } + } + + // Parse with quick-xml to find ANY empty attribute values on visual elements + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(svg); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let tag = String::from_utf8_lossy(e.name().local_name().as_ref()).to_string(); + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy(attr.key.local_name().as_ref()).to_string(); + let val = attr.unescape_value().unwrap_or_default(); + let visual_attr = matches!( + key.as_str(), + "fill" + | "stroke" + | "width" + | "height" + | "x" + | "y" + | "r" + | "cx" + | "cy" + | "rx" + | "ry" + | "stroke-width" + ); + if visual_attr && val.is_empty() { + issues.push(format!("{name}: <{tag}> has empty {key}=\"\"\n")); + } + // Check for CSS length units that usvg can't parse + if visual_attr + && matches!(key.as_str(), "width" | "height") + && val.ends_with("px") + { + issues.push(format!("{name}: <{tag}> has {key}=\"{val}\" (px suffix)\n")); + } + } + } + Err(e) => { + issues.push(format!("{name}: XML parse error: {e}\n")); + break; + } + _ => {} + } + } + + issues +} + +#[test] +fn accent_colors_auto_applied_to_nodes() { + let theme = rgb_theme(); + + // A plain state diagram with no :::accent syntax should get + // automatic accent colors applied to its node groups. + let source = "stateDiagram-v2\n [*] --> Idle\n Idle --> Processing\n Processing --> Done\n Done --> [*]"; + + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + + // accent_fill_and_text darkens the background color for dark mode. + // The stroke colors are direct hex conversions of the accent rgb values. + // With 3 states (Idle, Processing, Done), we expect at least accent0 and + // accent1 stroke colors to appear. + let accent0_stroke = "#74ade8"; // rgb(116, 173, 232) -> hex + let accent1_stroke = "#be5046"; // rgb(190, 80, 70) -> hex + + assert!( + svg.contains(accent0_stroke), + "Expected accent0 stroke color ({accent0_stroke}) in auto-colored state diagram SVG.\n\ + This means auto-coloring did not apply accent colors to node groups.\n\ + SVG snippet: {}...", + &svg[..svg.len().min(2000)] + ); + assert!( + svg.contains(accent1_stroke), + "Expected accent1 stroke color ({accent1_stroke}) in auto-colored state diagram SVG." + ); +} + +#[test] +fn generics_not_double_escaped() { + let theme = rgb_theme(); + let source = "classDiagram\n class Shelter {\n -List~Animal~ animals\n +adopt(Animal a) bool\n }"; + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + assert!( + !svg.contains("&lt;"), + "Double-escaped &lt; found in SVG" + ); + assert!( + !svg.contains("&gt;"), + "Double-escaped &gt; found in SVG" + ); +} + +#[test] +fn backslash_n_converted_to_line_break() { + let theme = rgb_theme(); + let source = r#"graph TD + L7["Layer 7\nHTTP, FTP"] + L6["Layer 6\nEncryption"] + L7 --> L6"#; + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + assert!( + !svg.contains(r"\n"), + "Literal \\n should not appear in SVG output" + ); + assert!( + svg.contains(">Layer 7<") && svg.contains(">HTTP, FTP<"), + "Label lines should be split into separate elements" + ); +} + +#[test] +fn class_diagram_fallback_text_uses_accent_classes() { + let theme = rgb_theme(); + let source = r#"classDiagram + class Animal { + +String name + +makeSound() void + } + class Dog { + +String breed + +bark() void + } + Dog --|> Animal"#; + + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(&svg); + let mut in_fallback = false; + let mut accent_classes: Vec = Vec::new(); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(Event::Start(e)) => { + if e.name().as_ref() == b"g" { + if let Ok(Some(attr)) = e.try_get_attribute("data-merman-foreignobject") { + if attr.value.as_ref() == b"fallback" { + in_fallback = true; + } + } + } + if in_fallback && e.name().as_ref() == b"text" { + if let Ok(Some(class_attr)) = e.try_get_attribute("class") { + let class = class_attr.unescape_value().unwrap_or_default().to_string(); + for token in class.split_whitespace() { + if token.starts_with("zed-accent-") { + accent_classes.push(token.to_string()); + } + } + } + } + } + Ok(Event::End(e)) if e.name().as_ref() == b"g" => { + in_fallback = false; + } + _ => {} + } + } + + assert!( + !accent_classes.is_empty(), + "expected zed-accent-N classes on text elements in fallback groups", + ); +} + +#[test] +fn sequence_diagram_tspan_uses_accent_classes() { + let theme = rgb_theme(); + let source = "sequenceDiagram\n participant Database"; + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(&svg); + let mut accent_classes: Vec = Vec::new(); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(Event::Start(e)) if e.name().as_ref() == b"tspan" => { + if let Ok(Some(class_attr)) = e.try_get_attribute("class") { + let class = class_attr.unescape_value().unwrap_or_default().to_string(); + for token in class.split_whitespace() { + if token.starts_with("zed-accent-") { + accent_classes.push(token.to_string()); + } + } + } + } + _ => {} + } + } + + assert!( + !accent_classes.is_empty(), + "expected zed-accent-N classes on tspan elements in sequence diagram", + ); +} + +#[test] +fn no_empty_attributes_or_nan_with_rgb_theme() { + let theme = rgb_theme(); + let mut all_issues = Vec::new(); + + for (name, source) in DIAGRAMS { + match mermaid_render::render_to_svg(source, &theme) { + Ok(svg) => all_issues.extend(check_svg_issues(name, &svg)), + Err(e) => eprintln!("{name}: render failed (skipped): {e}"), + } + } + + if !all_issues.is_empty() { + panic!( + "Found {} issues in merman SVG output (rgb theme):\n\n{}", + all_issues.len(), + all_issues.join("\n") + ); + } +} diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 74eaeef53eb..809f23bc394 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -20,11 +20,11 @@ use futures_lite::future::yield_now; use gpui::{App, Context, Entity, EventEmitter}; use itertools::Itertools; use language::{ - AutoindentMode, Buffer, BufferChunks, BufferRow, BufferSnapshot, Capability, CharClassifier, - CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, File, IndentGuideSettings, - IndentSize, Language, LanguageAwareStyling, LanguageScope, OffsetRangeExt, OffsetUtf16, - Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _, - ToPoint as _, TransactionId, TreeSitterOptions, Unclipped, + AutoindentMode, Buffer, BufferChunks, BufferEditSource, BufferRow, BufferSnapshot, Capability, + CharClassifier, CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, File, + IndentGuideSettings, IndentSize, Language, LanguageAwareStyling, LanguageScope, OffsetRangeExt, + OffsetUtf16, Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, + ToOffset as _, ToPoint as _, TransactionId, TreeSitterOptions, Unclipped, language_settings::{AllLanguageSettings, LanguageSettings}, }; @@ -110,7 +110,7 @@ pub enum Event { DiffHunksToggled, Edited { edited_buffer: Option>, - is_local: bool, + source: BufferEditSource, }, TransactionUndone { transaction_id: TransactionId, @@ -1828,7 +1828,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); cx.emit(Event::BuffersRemoved { removed_buffer_ids }); cx.notify(); @@ -1952,9 +1952,9 @@ impl MultiBuffer { use language::BufferEvent; let buffer_id = buffer.read(cx).remote_id(); cx.emit(match event { - &BufferEvent::Edited { is_local } => Event::Edited { + &BufferEvent::Edited { source } => Event::Edited { edited_buffer: Some(buffer), - is_local, + source, }, BufferEvent::DirtyChanged => Event::DirtyChanged, BufferEvent::Saved => Event::Saved, @@ -2044,7 +2044,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } @@ -2090,7 +2090,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } @@ -2313,7 +2313,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } @@ -2449,7 +2449,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } @@ -3102,7 +3102,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); } } @@ -7934,7 +7934,11 @@ impl<'a> Iterator for MultiBufferChunks<'a> { if self.range.start >= self.range.end { return None; } - if self.range.start == self.diff_transforms.end().0 { + while self + .diff_transforms + .item() + .is_some_and(|_| self.range.start >= self.diff_transforms.end().0) + { self.diff_transforms.next(); } @@ -7961,10 +7965,17 @@ impl<'a> Iterator for MultiBufferChunks<'a> { let chunk_end = self.range.start + chunk.text.len(); let diff_transform_end = diff_transform_end.min(self.range.end); - if diff_transform_end < chunk_end { - let split_idx = diff_transform_end - self.range.start; + let split_idx = if diff_transform_end < chunk_end { + chunk + .text + .ceil_char_boundary(diff_transform_end - self.range.start) + } else { + chunk.text.len() + }; + + if split_idx < chunk.text.len() { let (before, after) = chunk.text.split_at(split_idx); - self.range.start = diff_transform_end; + self.range.start += split_idx; let mask = 1u128.unbounded_shl(split_idx as u32).wrapping_sub(1); let chars = chunk.chars & mask; let tabs = chunk.tabs & mask; diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 1538c325267..3e71deb8f85 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -12,7 +12,7 @@ use util::RandomCharIter; use util::rel_path::rel_path; use util::test::sample_text; -#[ctor::ctor] +#[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } @@ -192,15 +192,15 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) { &[ Event::Edited { edited_buffer: None, - is_local: true, + source: language::BufferEditSource::User, }, Event::Edited { edited_buffer: None, - is_local: true, + source: language::BufferEditSource::User, }, Event::Edited { edited_buffer: None, - is_local: true, + source: language::BufferEditSource::User, } ] ); @@ -1527,6 +1527,42 @@ async fn test_basic_diff_hunks(cx: &mut TestAppContext) { ); } +#[gpui::test] +fn test_text_for_range_with_diff_transform_boundary_inside_multibyte_character(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("タx", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let mut snapshot = multibuffer.read(cx).snapshot(cx); + + fn ascii_summary_with_byte_len(byte_len: usize) -> MBTextSummary { + let text = "x".repeat(byte_len); + MBTextSummary::from(TextSummary::from(text.as_str())) + } + + // FR-16 shown a diff transform boundary two bytes into the leading 'タ'. + // Build that transform tree directly so this test stays focused on chunk iteration. + let mut diff_transforms = SumTree::default(); + diff_transforms.push( + DiffTransform::BufferContent { + summary: ascii_summary_with_byte_len(2), + inserted_hunk_info: None, + }, + (), + ); + diff_transforms.push( + DiffTransform::BufferContent { + summary: ascii_summary_with_byte_len("タx".len() - 2), + inserted_hunk_info: None, + }, + (), + ); + snapshot.diff_transforms = diff_transforms; + + let text = snapshot + .text_for_range(MultiBufferOffset(0)..snapshot.len()) + .collect::(); + assert_eq!(text, "タx"); +} + #[gpui::test] async fn test_repeatedly_expand_a_diff_hunk(cx: &mut TestAppContext) { let text = indoc!( diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index a2fd1ae2646..8545827ef52 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -2,7 +2,7 @@ use std::{ops::Range, rc::Rc, sync::Arc}; use gpui::{App, AppContext, Context, Entity}; use itertools::Itertools; -use language::{Buffer, BufferSnapshot}; +use language::{Buffer, BufferEditSource, BufferSnapshot}; use rope::Point; use sum_tree::{Dimensions, SumTree}; use text::{Bias, BufferId, Edit, OffsetRangeExt, Patch}; @@ -603,7 +603,7 @@ impl MultiBuffer { cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); cx.emit(Event::BufferRangesUpdated { buffer, @@ -687,7 +687,7 @@ impl MultiBuffer { cx.emit(Event::Edited { edited_buffer: None, - is_local: true, + source: BufferEditSource::User, }); cx.notify(); } diff --git a/crates/multi_buffer/src/transaction.rs b/crates/multi_buffer/src/transaction.rs index a3afe55cd69..47ab39f9363 100644 --- a/crates/multi_buffer/src/transaction.rs +++ b/crates/multi_buffer/src/transaction.rs @@ -1,517 +1,546 @@ -use gpui::{App, Context, Entity}; -use language::{self, Buffer, TransactionId}; -use std::{ - collections::HashMap, - ops::Range, - time::{Duration, Instant}, -}; -use sum_tree::Bias; -use text::BufferId; - -use crate::{Anchor, BufferState, MultiBufferOffset}; - -use super::{Event, MultiBuffer}; - -#[derive(Clone)] -pub(super) struct History { - next_transaction_id: TransactionId, - undo_stack: Vec, - redo_stack: Vec, - transaction_depth: usize, - group_interval: Duration, -} - -impl Default for History { - fn default() -> Self { - History { - next_transaction_id: clock::Lamport::MIN, - undo_stack: Vec::new(), - redo_stack: Vec::new(), - transaction_depth: 0, - group_interval: Duration::from_millis(300), - } - } -} - -#[derive(Clone)] -struct Transaction { - id: TransactionId, - buffer_transactions: HashMap, - first_edit_at: Instant, - last_edit_at: Instant, - suppress_grouping: bool, -} - -impl History { - fn start_transaction(&mut self, now: Instant) -> Option { - self.transaction_depth += 1; - if self.transaction_depth == 1 { - let id = self.next_transaction_id.tick(); - self.undo_stack.push(Transaction { - id, - buffer_transactions: Default::default(), - first_edit_at: now, - last_edit_at: now, - suppress_grouping: false, - }); - Some(id) - } else { - None - } - } - - fn end_transaction( - &mut self, - now: Instant, - buffer_transactions: HashMap, - ) -> bool { - assert_ne!(self.transaction_depth, 0); - self.transaction_depth -= 1; - if self.transaction_depth == 0 { - if buffer_transactions.is_empty() { - self.undo_stack.pop(); - false - } else { - self.redo_stack.clear(); - let transaction = self.undo_stack.last_mut().unwrap(); - transaction.last_edit_at = now; - for (buffer_id, transaction_id) in buffer_transactions { - transaction - .buffer_transactions - .entry(buffer_id) - .or_insert(transaction_id); - } - true - } - } else { - false - } - } - - fn push_transaction<'a, T>( - &mut self, - buffer_transactions: T, - now: Instant, - cx: &Context, - ) where - T: IntoIterator, &'a language::Transaction)>, - { - assert_eq!(self.transaction_depth, 0); - let transaction = Transaction { - id: self.next_transaction_id.tick(), - buffer_transactions: buffer_transactions - .into_iter() - .map(|(buffer, transaction)| (buffer.read(cx).remote_id(), transaction.id)) - .collect(), - first_edit_at: now, - last_edit_at: now, - suppress_grouping: false, - }; - if !transaction.buffer_transactions.is_empty() { - self.undo_stack.push(transaction); - self.redo_stack.clear(); - } - } - - fn finalize_last_transaction(&mut self) { - if let Some(transaction) = self.undo_stack.last_mut() { - transaction.suppress_grouping = true; - } - } - - fn forget(&mut self, transaction_id: TransactionId) -> Option { - if let Some(ix) = self - .undo_stack - .iter() - .rposition(|transaction| transaction.id == transaction_id) - { - Some(self.undo_stack.remove(ix)) - } else if let Some(ix) = self - .redo_stack - .iter() - .rposition(|transaction| transaction.id == transaction_id) - { - Some(self.redo_stack.remove(ix)) - } else { - None - } - } - - fn transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> { - self.undo_stack - .iter() - .find(|transaction| transaction.id == transaction_id) - .or_else(|| { - self.redo_stack - .iter() - .find(|transaction| transaction.id == transaction_id) - }) - } - - fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> { - self.undo_stack - .iter_mut() - .find(|transaction| transaction.id == transaction_id) - .or_else(|| { - self.redo_stack - .iter_mut() - .find(|transaction| transaction.id == transaction_id) - }) - } - - fn pop_undo(&mut self) -> Option<&mut Transaction> { - assert_eq!(self.transaction_depth, 0); - if let Some(transaction) = self.undo_stack.pop() { - self.redo_stack.push(transaction); - self.redo_stack.last_mut() - } else { - None - } - } - - fn pop_redo(&mut self) -> Option<&mut Transaction> { - assert_eq!(self.transaction_depth, 0); - if let Some(transaction) = self.redo_stack.pop() { - self.undo_stack.push(transaction); - self.undo_stack.last_mut() - } else { - None - } - } - - fn remove_from_undo(&mut self, transaction_id: TransactionId) -> Option<&Transaction> { - let ix = self - .undo_stack - .iter() - .rposition(|transaction| transaction.id == transaction_id)?; - let transaction = self.undo_stack.remove(ix); - self.redo_stack.push(transaction); - self.redo_stack.last() - } - - fn group(&mut self) -> Option { - let mut count = 0; - let mut transactions = self.undo_stack.iter(); - if let Some(mut transaction) = transactions.next_back() { - while let Some(prev_transaction) = transactions.next_back() { - if !prev_transaction.suppress_grouping - && transaction.first_edit_at - prev_transaction.last_edit_at - <= self.group_interval - { - transaction = prev_transaction; - count += 1; - } else { - break; - } - } - } - self.group_trailing(count) - } - - fn group_until(&mut self, transaction_id: TransactionId) { - let mut count = 0; - for transaction in self.undo_stack.iter().rev() { - if transaction.id == transaction_id { - self.group_trailing(count); - break; - } else if transaction.suppress_grouping { - break; - } else { - count += 1; - } - } - } - - fn group_trailing(&mut self, n: usize) -> Option { - let new_len = self.undo_stack.len() - n; - let (transactions_to_keep, transactions_to_merge) = self.undo_stack.split_at_mut(new_len); - if let Some(last_transaction) = transactions_to_keep.last_mut() { - if let Some(transaction) = transactions_to_merge.last() { - last_transaction.last_edit_at = transaction.last_edit_at; - } - for to_merge in transactions_to_merge { - for (buffer_id, transaction_id) in &to_merge.buffer_transactions { - last_transaction - .buffer_transactions - .entry(*buffer_id) - .or_insert(*transaction_id); - } - } - } - - self.undo_stack.truncate(new_len); - self.undo_stack.last().map(|t| t.id) - } - - pub(super) fn transaction_depth(&self) -> usize { - self.transaction_depth - } - - pub fn set_group_interval(&mut self, group_interval: Duration) { - self.group_interval = group_interval; - } -} - -impl MultiBuffer { - pub fn start_transaction(&mut self, cx: &mut Context) -> Option { - self.start_transaction_at(Instant::now(), cx) - } - - pub fn start_transaction_at( - &mut self, - now: Instant, - cx: &mut Context, - ) -> Option { - if let Some(buffer) = self.as_singleton() { - return buffer.update(cx, |buffer, _| buffer.start_transaction_at(now)); - } - - for BufferState { buffer, .. } in self.buffers.values() { - buffer.update(cx, |buffer, _| buffer.start_transaction_at(now)); - } - self.history.start_transaction(now) - } - - pub fn last_transaction_id(&self, cx: &App) -> Option { - if let Some(buffer) = self.as_singleton() { - buffer - .read(cx) - .peek_undo_stack() - .map(|history_entry| history_entry.transaction_id()) - } else { - let last_transaction = self.history.undo_stack.last()?; - Some(last_transaction.id) - } - } - - pub fn end_transaction(&mut self, cx: &mut Context) -> Option { - self.end_transaction_at(Instant::now(), cx) - } - - pub fn end_transaction_at( - &mut self, - now: Instant, - cx: &mut Context, - ) -> Option { - if let Some(buffer) = self.as_singleton() { - return buffer.update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)); - } - - let mut buffer_transactions = HashMap::default(); - for BufferState { buffer, .. } in self.buffers.values() { - if let Some(transaction_id) = - buffer.update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)) - { - buffer_transactions.insert(buffer.read(cx).remote_id(), transaction_id); - } - } - - if self.history.end_transaction(now, buffer_transactions) { - let transaction_id = self.history.group().unwrap(); - Some(transaction_id) - } else { - None - } - } - - pub fn edited_ranges_for_transaction( - &self, - transaction_id: TransactionId, - cx: &App, - ) -> Vec> { - let Some(transaction) = self.history.transaction(transaction_id) else { - return Vec::new(); - }; - - let snapshot = self.read(cx); - let mut buffer_anchors = Vec::new(); - - for (buffer_id, buffer_transaction) in &transaction.buffer_transactions { - let Some(buffer) = self.buffer(*buffer_id) else { - continue; - }; - let Some(excerpt) = snapshot.first_excerpt_for_buffer(*buffer_id) else { - continue; - }; - let buffer_snapshot = buffer.read(cx).snapshot(); - - for range in buffer - .read(cx) - .edited_ranges_for_transaction_id::(*buffer_transaction) - { - buffer_anchors.push(Anchor::in_buffer( - excerpt.path_key_index, - buffer_snapshot.anchor_at(range.start, Bias::Left), - )); - buffer_anchors.push(Anchor::in_buffer( - excerpt.path_key_index, - buffer_snapshot.anchor_at(range.end, Bias::Right), - )); - } - } - buffer_anchors.sort_unstable_by(|a, b| a.cmp(b, &snapshot)); - - snapshot - .summaries_for_anchors(buffer_anchors.iter()) - .as_chunks::<2>() - .0 - .iter() - .map(|&[s, e]| s..e) - .collect::>() - } - - pub fn merge_transactions( - &mut self, - transaction: TransactionId, - destination: TransactionId, - cx: &mut Context, - ) { - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, _| { - buffer.merge_transactions(transaction, destination) - }); - } else if let Some(transaction) = self.history.forget(transaction) - && let Some(destination) = self.history.transaction_mut(destination) - { - for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { - if let Some(destination_buffer_transaction_id) = - destination.buffer_transactions.get(&buffer_id) - { - if let Some(state) = self.buffers.get(&buffer_id) { - state.buffer.update(cx, |buffer, _| { - buffer.merge_transactions( - buffer_transaction_id, - *destination_buffer_transaction_id, - ) - }); - } - } else { - destination - .buffer_transactions - .insert(buffer_id, buffer_transaction_id); - } - } - } - } - - pub fn finalize_last_transaction(&mut self, cx: &mut Context) { - self.history.finalize_last_transaction(); - for BufferState { buffer, .. } in self.buffers.values() { - buffer.update(cx, |buffer, _| { - buffer.finalize_last_transaction(); - }); - } - } - - pub fn push_transaction<'a, T>(&mut self, buffer_transactions: T, cx: &Context) - where - T: IntoIterator, &'a language::Transaction)>, - { - self.history - .push_transaction(buffer_transactions, Instant::now(), cx); - self.history.finalize_last_transaction(); - } - - pub fn group_until_transaction( - &mut self, - transaction_id: TransactionId, - cx: &mut Context, - ) { - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, _| { - buffer.group_until_transaction(transaction_id) - }); - } else { - self.history.group_until(transaction_id); - } - } - pub fn undo(&mut self, cx: &mut Context) -> Option { - let mut transaction_id = None; - if let Some(buffer) = self.as_singleton() { - transaction_id = buffer.update(cx, |buffer, cx| buffer.undo(cx)); - } else { - while let Some(transaction) = self.history.pop_undo() { - let mut undone = false; - for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions { - if let Some(BufferState { buffer, .. }) = self.buffers.get(buffer_id) { - undone |= buffer.update(cx, |buffer, cx| { - let undo_to = *buffer_transaction_id; - if let Some(entry) = buffer.peek_undo_stack() { - *buffer_transaction_id = entry.transaction_id(); - } - buffer.undo_to_transaction(undo_to, cx) - }); - } - } - - if undone { - transaction_id = Some(transaction.id); - break; - } - } - } - - if let Some(transaction_id) = transaction_id { - cx.emit(Event::TransactionUndone { transaction_id }); - } - - transaction_id - } - - pub fn redo(&mut self, cx: &mut Context) -> Option { - if let Some(buffer) = self.as_singleton() { - return buffer.update(cx, |buffer, cx| buffer.redo(cx)); - } - - while let Some(transaction) = self.history.pop_redo() { - let mut redone = false; - for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions.iter_mut() { - if let Some(BufferState { buffer, .. }) = self.buffers.get(buffer_id) { - redone |= buffer.update(cx, |buffer, cx| { - let redo_to = *buffer_transaction_id; - if let Some(entry) = buffer.peek_redo_stack() { - *buffer_transaction_id = entry.transaction_id(); - } - buffer.redo_to_transaction(redo_to, cx) - }); - } - } - - if redone { - return Some(transaction.id); - } - } - - None - } - - pub fn undo_transaction(&mut self, transaction_id: TransactionId, cx: &mut Context) { - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); - } else if let Some(transaction) = self.history.remove_from_undo(transaction_id) { - for (buffer_id, transaction_id) in &transaction.buffer_transactions { - if let Some(BufferState { buffer, .. }) = self.buffers.get(buffer_id) { - buffer.update(cx, |buffer, cx| { - buffer.undo_transaction(*transaction_id, cx) - }); - } - } - } - } - - pub fn forget_transaction(&mut self, transaction_id: TransactionId, cx: &mut Context) { - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, _| { - buffer.forget_transaction(transaction_id); - }); - } else if let Some(transaction) = self.history.forget(transaction_id) { - for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { - if let Some(state) = self.buffers.get_mut(&buffer_id) { - state.buffer.update(cx, |buffer, _| { - buffer.forget_transaction(buffer_transaction_id); - }); - } - } - } - } -} +use gpui::{App, Context, Entity}; +use language::{self, Buffer, BufferEditSource, TransactionId}; +use std::{ + collections::HashMap, + ops::Range, + time::{Duration, Instant}, +}; +use sum_tree::Bias; +use text::BufferId; + +use crate::{Anchor, BufferState, MultiBufferOffset}; + +use super::{Event, MultiBuffer}; + +#[derive(Clone)] +pub(super) struct History { + next_transaction_id: TransactionId, + undo_stack: Vec, + redo_stack: Vec, + transaction_depth: usize, + group_interval: Duration, +} + +impl Default for History { + fn default() -> Self { + History { + next_transaction_id: clock::Lamport::MIN, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + transaction_depth: 0, + group_interval: Duration::from_millis(300), + } + } +} + +#[derive(Clone)] +struct Transaction { + id: TransactionId, + buffer_transactions: HashMap, + first_edit_at: Instant, + last_edit_at: Instant, + suppress_grouping: bool, +} + +impl History { + fn start_transaction(&mut self, now: Instant) -> Option { + self.transaction_depth += 1; + if self.transaction_depth == 1 { + let id = self.next_transaction_id.tick(); + self.undo_stack.push(Transaction { + id, + buffer_transactions: Default::default(), + first_edit_at: now, + last_edit_at: now, + suppress_grouping: false, + }); + Some(id) + } else { + None + } + } + + fn end_transaction( + &mut self, + now: Instant, + buffer_transactions: HashMap, + ) -> bool { + assert_ne!(self.transaction_depth, 0); + self.transaction_depth -= 1; + if self.transaction_depth == 0 { + if buffer_transactions.is_empty() { + self.undo_stack.pop(); + false + } else { + self.redo_stack.clear(); + let transaction = self.undo_stack.last_mut().unwrap(); + transaction.last_edit_at = now; + for (buffer_id, transaction_id) in buffer_transactions { + transaction + .buffer_transactions + .entry(buffer_id) + .or_insert(transaction_id); + } + true + } + } else { + false + } + } + + fn push_transaction<'a, T>( + &mut self, + buffer_transactions: T, + now: Instant, + cx: &Context, + ) where + T: IntoIterator, &'a language::Transaction)>, + { + assert_eq!(self.transaction_depth, 0); + let transaction = Transaction { + id: self.next_transaction_id.tick(), + buffer_transactions: buffer_transactions + .into_iter() + .map(|(buffer, transaction)| (buffer.read(cx).remote_id(), transaction.id)) + .collect(), + first_edit_at: now, + last_edit_at: now, + suppress_grouping: false, + }; + if !transaction.buffer_transactions.is_empty() { + self.undo_stack.push(transaction); + self.redo_stack.clear(); + } + } + + fn finalize_last_transaction(&mut self) { + if let Some(transaction) = self.undo_stack.last_mut() { + transaction.suppress_grouping = true; + } + } + + fn forget(&mut self, transaction_id: TransactionId) -> Option { + if let Some(ix) = self + .undo_stack + .iter() + .rposition(|transaction| transaction.id == transaction_id) + { + Some(self.undo_stack.remove(ix)) + } else if let Some(ix) = self + .redo_stack + .iter() + .rposition(|transaction| transaction.id == transaction_id) + { + Some(self.redo_stack.remove(ix)) + } else { + None + } + } + + fn transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> { + self.undo_stack + .iter() + .find(|transaction| transaction.id == transaction_id) + .or_else(|| { + self.redo_stack + .iter() + .find(|transaction| transaction.id == transaction_id) + }) + } + + fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> { + self.undo_stack + .iter_mut() + .find(|transaction| transaction.id == transaction_id) + .or_else(|| { + self.redo_stack + .iter_mut() + .find(|transaction| transaction.id == transaction_id) + }) + } + + fn pop_undo(&mut self) -> Option<&mut Transaction> { + assert_eq!(self.transaction_depth, 0); + if let Some(transaction) = self.undo_stack.pop() { + self.redo_stack.push(transaction); + self.redo_stack.last_mut() + } else { + None + } + } + + fn pop_redo(&mut self) -> Option<&mut Transaction> { + assert_eq!(self.transaction_depth, 0); + if let Some(transaction) = self.redo_stack.pop() { + self.undo_stack.push(transaction); + self.undo_stack.last_mut() + } else { + None + } + } + + fn remove_from_undo(&mut self, transaction_id: TransactionId) -> Option<&Transaction> { + let ix = self + .undo_stack + .iter() + .rposition(|transaction| transaction.id == transaction_id)?; + let transaction = self.undo_stack.remove(ix); + self.redo_stack.push(transaction); + self.redo_stack.last() + } + + fn group(&mut self) -> Option { + let mut count = 0; + let mut transactions = self.undo_stack.iter(); + if let Some(mut transaction) = transactions.next_back() { + while let Some(prev_transaction) = transactions.next_back() { + if !prev_transaction.suppress_grouping + && transaction.first_edit_at - prev_transaction.last_edit_at + <= self.group_interval + { + transaction = prev_transaction; + count += 1; + } else { + break; + } + } + } + self.group_trailing(count) + } + + fn group_until(&mut self, transaction_id: TransactionId) { + let mut count = 0; + for transaction in self.undo_stack.iter().rev() { + if transaction.id == transaction_id { + self.group_trailing(count); + break; + } else if transaction.suppress_grouping { + break; + } else { + count += 1; + } + } + } + + fn group_trailing(&mut self, n: usize) -> Option { + let new_len = self.undo_stack.len() - n; + let (transactions_to_keep, transactions_to_merge) = self.undo_stack.split_at_mut(new_len); + if let Some(last_transaction) = transactions_to_keep.last_mut() { + if let Some(transaction) = transactions_to_merge.last() { + last_transaction.last_edit_at = transaction.last_edit_at; + } + for to_merge in transactions_to_merge { + for (buffer_id, transaction_id) in &to_merge.buffer_transactions { + last_transaction + .buffer_transactions + .entry(*buffer_id) + .or_insert(*transaction_id); + } + } + } + + self.undo_stack.truncate(new_len); + self.undo_stack.last().map(|t| t.id) + } + + pub(super) fn transaction_depth(&self) -> usize { + self.transaction_depth + } + + pub fn set_group_interval(&mut self, group_interval: Duration) { + self.group_interval = group_interval; + } +} + +impl MultiBuffer { + pub fn start_transaction(&mut self, cx: &mut Context) -> Option { + self.start_transaction_at(Instant::now(), cx) + } + + pub fn start_transaction_at( + &mut self, + now: Instant, + cx: &mut Context, + ) -> Option { + if let Some(buffer) = self.as_singleton() { + return buffer.update(cx, |buffer, _| buffer.start_transaction_at(now)); + } + + for BufferState { buffer, .. } in self.buffers.values() { + buffer.update(cx, |buffer, _| buffer.start_transaction_at(now)); + } + self.history.start_transaction(now) + } + + pub fn last_transaction_id(&self, cx: &App) -> Option { + if let Some(buffer) = self.as_singleton() { + buffer + .read(cx) + .peek_undo_stack() + .map(|history_entry| history_entry.transaction_id()) + } else { + let last_transaction = self.history.undo_stack.last()?; + Some(last_transaction.id) + } + } + + pub fn end_transaction(&mut self, cx: &mut Context) -> Option { + self.end_transaction_at(Instant::now(), cx) + } + + pub fn end_transaction_with_source( + &mut self, + source: BufferEditSource, + cx: &mut Context, + ) -> Option { + let now = Instant::now(); + if let Some(buffer) = self.as_singleton() { + return buffer.update(cx, |buffer, cx| { + buffer.end_transaction_with_source(source, cx) + }); + } + + let mut buffer_transactions = HashMap::default(); + for BufferState { buffer, .. } in self.buffers.values() { + if let Some(transaction_id) = buffer.update(cx, |buffer, cx| { + buffer.end_transaction_with_source(source, cx) + }) { + buffer_transactions.insert(buffer.read(cx).remote_id(), transaction_id); + } + } + + if self.history.end_transaction(now, buffer_transactions) { + let transaction_id = self.history.group().unwrap(); + Some(transaction_id) + } else { + None + } + } + + pub fn end_transaction_at( + &mut self, + now: Instant, + cx: &mut Context, + ) -> Option { + if let Some(buffer) = self.as_singleton() { + return buffer.update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)); + } + + let mut buffer_transactions = HashMap::default(); + for BufferState { buffer, .. } in self.buffers.values() { + if let Some(transaction_id) = + buffer.update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)) + { + buffer_transactions.insert(buffer.read(cx).remote_id(), transaction_id); + } + } + + if self.history.end_transaction(now, buffer_transactions) { + let transaction_id = self.history.group().unwrap(); + Some(transaction_id) + } else { + None + } + } + + pub fn edited_ranges_for_transaction( + &self, + transaction_id: TransactionId, + cx: &App, + ) -> Vec> { + let Some(transaction) = self.history.transaction(transaction_id) else { + return Vec::new(); + }; + + let snapshot = self.read(cx); + let mut buffer_anchors = Vec::new(); + + for (buffer_id, buffer_transaction) in &transaction.buffer_transactions { + let Some(buffer) = self.buffer(*buffer_id) else { + continue; + }; + let Some(excerpt) = snapshot.first_excerpt_for_buffer(*buffer_id) else { + continue; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + + for range in buffer + .read(cx) + .edited_ranges_for_transaction_id::(*buffer_transaction) + { + buffer_anchors.push(Anchor::in_buffer( + excerpt.path_key_index, + buffer_snapshot.anchor_at(range.start, Bias::Left), + )); + buffer_anchors.push(Anchor::in_buffer( + excerpt.path_key_index, + buffer_snapshot.anchor_at(range.end, Bias::Right), + )); + } + } + buffer_anchors.sort_unstable_by(|a, b| a.cmp(b, &snapshot)); + + snapshot + .summaries_for_anchors(buffer_anchors.iter()) + .as_chunks::<2>() + .0 + .iter() + .map(|&[s, e]| s..e) + .collect::>() + } + + pub fn merge_transactions( + &mut self, + transaction: TransactionId, + destination: TransactionId, + cx: &mut Context, + ) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, _| { + buffer.merge_transactions(transaction, destination) + }); + } else if let Some(transaction) = self.history.forget(transaction) + && let Some(destination) = self.history.transaction_mut(destination) + { + for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { + if let Some(destination_buffer_transaction_id) = + destination.buffer_transactions.get(&buffer_id) + { + if let Some(state) = self.buffers.get(&buffer_id) { + state.buffer.update(cx, |buffer, _| { + buffer.merge_transactions( + buffer_transaction_id, + *destination_buffer_transaction_id, + ) + }); + } + } else { + destination + .buffer_transactions + .insert(buffer_id, buffer_transaction_id); + } + } + } + } + + pub fn finalize_last_transaction(&mut self, cx: &mut Context) { + self.history.finalize_last_transaction(); + for BufferState { buffer, .. } in self.buffers.values() { + buffer.update(cx, |buffer, _| { + buffer.finalize_last_transaction(); + }); + } + } + + pub fn push_transaction<'a, T>(&mut self, buffer_transactions: T, cx: &Context) + where + T: IntoIterator, &'a language::Transaction)>, + { + self.history + .push_transaction(buffer_transactions, Instant::now(), cx); + self.history.finalize_last_transaction(); + } + + pub fn group_until_transaction( + &mut self, + transaction_id: TransactionId, + cx: &mut Context, + ) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, _| { + buffer.group_until_transaction(transaction_id) + }); + } else { + self.history.group_until(transaction_id); + } + } + pub fn undo(&mut self, cx: &mut Context) -> Option { + let mut transaction_id = None; + if let Some(buffer) = self.as_singleton() { + transaction_id = buffer.update(cx, |buffer, cx| buffer.undo(cx)); + } else { + while let Some(transaction) = self.history.pop_undo() { + let mut undone = false; + for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions { + if let Some(BufferState { buffer, .. }) = self.buffers.get(buffer_id) { + undone |= buffer.update(cx, |buffer, cx| { + let undo_to = *buffer_transaction_id; + if let Some(entry) = buffer.peek_undo_stack() { + *buffer_transaction_id = entry.transaction_id(); + } + buffer.undo_to_transaction(undo_to, cx) + }); + } + } + + if undone { + transaction_id = Some(transaction.id); + break; + } + } + } + + if let Some(transaction_id) = transaction_id { + cx.emit(Event::TransactionUndone { transaction_id }); + } + + transaction_id + } + + pub fn redo(&mut self, cx: &mut Context) -> Option { + if let Some(buffer) = self.as_singleton() { + return buffer.update(cx, |buffer, cx| buffer.redo(cx)); + } + + while let Some(transaction) = self.history.pop_redo() { + let mut redone = false; + for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions.iter_mut() { + if let Some(BufferState { buffer, .. }) = self.buffers.get(buffer_id) { + redone |= buffer.update(cx, |buffer, cx| { + let redo_to = *buffer_transaction_id; + if let Some(entry) = buffer.peek_redo_stack() { + *buffer_transaction_id = entry.transaction_id(); + } + buffer.redo_to_transaction(redo_to, cx) + }); + } + } + + if redone { + return Some(transaction.id); + } + } + + None + } + + pub fn undo_transaction(&mut self, transaction_id: TransactionId, cx: &mut Context) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); + } else if let Some(transaction) = self.history.remove_from_undo(transaction_id) { + for (buffer_id, transaction_id) in &transaction.buffer_transactions { + if let Some(BufferState { buffer, .. }) = self.buffers.get(buffer_id) { + buffer.update(cx, |buffer, cx| { + buffer.undo_transaction(*transaction_id, cx) + }); + } + } + } + } + + pub fn forget_transaction(&mut self, transaction_id: TransactionId, cx: &mut Context) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, _| { + buffer.forget_transaction(transaction_id); + }); + } else if let Some(transaction) = self.history.forget(transaction_id) { + for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { + if let Some(state) = self.buffers.get_mut(&buffer_id) { + state.buffer.update(cx, |buffer, _| { + buffer.forget_transaction(buffer_transaction_id); + }); + } + } + } + } +} diff --git a/crates/nc/src/nc.rs b/crates/nc/src/nc.rs deleted file mode 100644 index d1849e23610..00000000000 --- a/crates/nc/src/nc.rs +++ /dev/null @@ -1,51 +0,0 @@ -use anyhow::Result; - -#[cfg(windows)] -pub fn main(_socket: &str) -> Result<()> { - // It looks like we can't get an async stdio stream on Windows from smol. - panic!("--nc isn't yet supported on Windows"); -} - -/// The main function for when Zed is running in netcat mode -#[cfg(not(windows))] -pub fn main(socket: &str) -> Result<()> { - use futures::{AsyncReadExt as _, AsyncWriteExt as _, FutureExt as _, io::BufReader, select}; - use net::async_net::UnixStream; - use smol::{Unblock, io::AsyncBufReadExt}; - - smol::block_on(async { - let socket_stream = UnixStream::connect(socket).await?; - let (socket_read, mut socket_write) = socket_stream.split(); - let mut socket_reader = BufReader::new(socket_read); - - let mut stdout = Unblock::new(std::io::stdout()); - let stdin = Unblock::new(std::io::stdin()); - let mut stdin_reader = BufReader::new(stdin); - - let mut socket_line = Vec::new(); - let mut stdin_line = Vec::new(); - - loop { - select! { - bytes_read = socket_reader.read_until(b'\n', &mut socket_line).fuse() => { - if bytes_read? == 0 { - break - } - stdout.write_all(&socket_line).await?; - stdout.flush().await?; - socket_line.clear(); - } - bytes_read = stdin_reader.read_until(b'\n', &mut stdin_line).fuse() => { - if bytes_read? == 0 { - break - } - socket_write.write_all(&stdin_line).await?; - socket_write.flush().await?; - stdin_line.clear(); - } - } - } - - anyhow::Ok(()) - }) -} diff --git a/crates/node_runtime/Cargo.toml b/crates/node_runtime/Cargo.toml index dfa40ad666e..25f7b2997e5 100644 --- a/crates/node_runtime/Cargo.toml +++ b/crates/node_runtime/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true async-compression.workspace = true async-tar.workspace = true async-trait.workspace = true +chrono.workspace = true futures.workspace = true http_client.workspace = true log.workspace = true diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index 9d4bfe9cffb..e960634a842 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, Result, anyhow, bail}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; +use chrono::{DateTime, Utc}; use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared}; use http_client::{Host, HttpClient, Url}; use log::Level; @@ -253,9 +254,8 @@ impl NodeRuntime { pub async fn npm_package_latest_version(&self, name: &str) -> Result { let http = self.0.lock().await.http.clone(); - let output = self - .instance() - .await + let instance = self.instance().await; + let output = instance .run_npm_subcommand( None, http.proxy(), @@ -273,11 +273,18 @@ impl NodeRuntime { ) .await?; - let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?; - info.dist_tags - .latest - .or_else(|| info.versions.pop()) - .with_context(|| format!("no version found for npm package {name}")) + let info: NpmInfo = serde_json::from_slice(&output.stdout)?; + let before = npm_config_before(instance.as_ref(), http.proxy()) + .await + .context("getting npm before config") + .log_err() + .flatten(); + let latest_dist_tag = info.dist_tags.latest.clone(); + let selected_version = select_npm_package_version(name, info, before.as_deref())?; + log::debug!( + "selected latest npm package version package={name:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}" + ); + Ok(selected_version) } pub async fn npm_install_packages( @@ -289,6 +296,11 @@ impl NodeRuntime { return Ok(()); } + log::debug!( + "installing npm packages directory={} packages={packages:?}", + directory.display() + ); + let packages: Vec<_> = packages .iter() .map(|(name, version)| format!("{name}@{version}")) @@ -314,6 +326,23 @@ impl NodeRuntime { Ok(()) } + pub async fn npm_install_latest_packages( + &self, + directory: &Path, + package_names: &[&str], + ) -> Result<()> { + // Let npm apply user config such as `before` and `min-release-age` during resolution. + log::debug!( + "installing latest npm packages directory={} packages={package_names:?}", + directory.display() + ); + let packages = package_names + .iter() + .map(|package_name| (*package_name, "latest")) + .collect::>(); + self.npm_install_packages(directory, &packages).await + } + pub async fn should_install_npm_package( &self, package_name: &str, @@ -325,6 +354,10 @@ impl NodeRuntime { // or in the instances where we fail to parse package.json data, // we attempt to install the package. if fs::metadata(local_executable_path).await.is_err() { + log::debug!( + "npm package cache miss package={package_name:?} reason=missing-executable executable={}", + local_executable_path.display() + ); return true; } @@ -334,13 +367,33 @@ impl NodeRuntime { .log_err() .flatten() else { + log::debug!( + "npm package cache miss package={package_name:?} reason=missing-installed-version package_dir={}", + local_package_directory.display() + ); return true; }; - match version_strategy { - VersionStrategy::Pin(pinned_version) => &installed_version != pinned_version, - VersionStrategy::Latest(latest_version) => &installed_version < latest_version, - } + let version_strategy_label = match &version_strategy { + VersionStrategy::Pin(version) => format!("pin:{version}"), + VersionStrategy::Latest(version) => format!("latest:{version}"), + }; + let should_install = + should_install_npm_package_version(&installed_version, version_strategy); + log::debug!( + "npm package cache check package={package_name:?} installed={installed_version} strategy={version_strategy_label} should_install={should_install}" + ); + should_install + } +} + +fn should_install_npm_package_version( + installed_version: &Version, + version_strategy: VersionStrategy<'_>, +) -> bool { + match version_strategy { + VersionStrategy::Pin(pinned_version) => installed_version != pinned_version, + VersionStrategy::Latest(latest_version) => installed_version < latest_version, } } @@ -355,6 +408,8 @@ pub struct NpmInfo { #[serde(default)] dist_tags: NpmInfoDistTags, versions: Vec, + #[serde(default, deserialize_with = "deserialize_npm_info_time")] + time: HashMap, } #[derive(Debug, Deserialize, Default)] @@ -362,6 +417,114 @@ pub struct NpmInfoDistTags { latest: Option, } +// Some registries put non-string values in the `time` map: JFrog Artifactory emits +// `"unpublished": null`, and npm itself reports `unpublished` as an object when a +// package has had versions unpublished. Only version keys map to the RFC 3339 strings +// we read, so keep the string entries and drop the rest rather than failing to parse +// the entire `npm info` response (which would block language server installation). +fn deserialize_npm_info_time<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let entries = HashMap::::deserialize(deserializer)?; + Ok(entries + .into_iter() + .filter_map(|(key, value)| match value { + serde_json::Value::String(value) => Some((key, value)), + _ => None, + }) + .collect()) +} + +#[derive(Debug, Deserialize)] +struct NpmConfig { + #[serde(default)] + before: Option, +} + +async fn npm_config_before( + node_runtime: &dyn NodeRuntimeTrait, + proxy: Option<&Url>, +) -> Result> { + // `npm config get before` renders Date values for display. The JSON config output keeps the + // computed cutoff in the same ISO format used by `npm info --json` release times. + let output = node_runtime + .run_npm_subcommand(None, proxy, "config", &["list", "--json"]) + .await?; + let config: NpmConfig = serde_json::from_slice(&output.stdout)?; + Ok(config + .before + .filter(|before| !before.trim().is_empty() && before != "null")) +} + +fn select_npm_package_version( + package_name: &str, + mut info: NpmInfo, + before: Option<&str>, +) -> Result { + if let Some(before) = before + && !info.time.is_empty() + { + let before_timestamp = DateTime::parse_from_rfc3339(before) + .with_context(|| format!("parsing npm before config timestamp {before:?}"))? + .with_timezone(&Utc); + let latest_version = info.dist_tags.latest.as_ref(); + + if let Some(version) = latest_version + && npm_version_was_published_before(version, &info.time, &before_timestamp)? + { + return Ok(version.clone()); + } + + for version in info.versions.iter().rev() { + if is_allowed_npm_version_before( + version, + latest_version, + &info.time, + &before_timestamp, + )? { + return Ok(version.clone()); + } + } + + bail!("no version found for npm package {package_name} before {before}"); + } + + info.dist_tags + .latest + .or_else(|| info.versions.pop()) + .with_context(|| format!("no version found for npm package {package_name}")) +} + +fn is_allowed_npm_version_before( + version: &Version, + latest_version: Option<&Version>, + published_at_by_version: &HashMap, + before: &DateTime, +) -> Result { + if !version.pre.is_empty() + || latest_version.is_some_and(|latest_version| version > latest_version) + { + return Ok(false); + } + + npm_version_was_published_before(version, published_at_by_version, before) +} + +fn npm_version_was_published_before( + version: &Version, + published_at_by_version: &HashMap, + before: &DateTime, +) -> Result { + let Some(published_at) = published_at_by_version.get(&version.to_string()) else { + return Ok(false); + }; + let published_at = DateTime::parse_from_rfc3339(published_at) + .with_context(|| format!("parsing npm release timestamp for version {version}"))? + .with_timezone(&Utc); + Ok(&published_at <= before) +} + #[async_trait::async_trait] trait NodeRuntimeTrait: Send + Sync { fn boxed_clone(&self) -> Box; @@ -936,9 +1099,14 @@ fn npm_command_env(node_binary: Option<&Path>) -> HashMap { mod tests { use std::path::Path; + use anyhow::{Result, bail}; use http_client::Url; + use semver::Version; - use super::{build_npm_command_args, proxy_argument}; + use super::{ + NpmInfo, VersionStrategy, build_npm_command_args, proxy_argument, + select_npm_package_version, should_install_npm_package_version, + }; // Map localhost to 127.0.0.1 // NodeRuntime without environment information can not parse `localhost` correctly. @@ -1021,4 +1189,199 @@ mod tests { ] ); } + + #[test] + fn test_latest_version_strategy_accepts_newer_installed_versions() -> Result<()> { + let target_version = Version::parse("2.0.0")?; + + assert!(!should_install_npm_package_version( + &Version::parse("2.0.0")?, + VersionStrategy::Latest(&target_version) + )); + assert!(should_install_npm_package_version( + &Version::parse("1.0.0")?, + VersionStrategy::Latest(&target_version) + )); + assert!(!should_install_npm_package_version( + &Version::parse("3.0.0")?, + VersionStrategy::Latest(&target_version) + )); + + Ok(()) + } + + #[test] + fn test_select_npm_package_version_uses_dist_tag_without_before() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "3.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z", + "3.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, None)?, + Version::parse("3.0.0")? + ); + Ok(()) + } + + #[test] + fn test_npm_info_skips_non_string_time_entries() -> Result<()> { + // Registries such as JFrog Artifactory include `"unpublished": null` in `time`; + // parsing must tolerate this rather than rejecting the whole response. + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0"], + "time": { + "unpublished": null, + "created": "2024-01-01T00:00:00.000Z", + "modified": "2024-02-01T00:00:00.000Z", + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_uses_latest_before_npm_before_config() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "3.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z", + "3.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_keeps_allowed_latest_dist_tag() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z", + "3.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_keeps_allowed_prerelease_latest_dist_tag() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0-beta.1" }, + "versions": ["1.0.0", "2.0.0-beta.1"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0-beta.1": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("2.0.0-beta.1")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_ignores_prereleases_before_cutoff() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0-beta.1", "2.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0-beta.1": "2024-02-01T00:00:00.000Z", + "2.0.0": "2024-03-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("1.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_ignores_versions_above_latest_dist_tag() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0", "3.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-03-01T00:00:00.000Z", + "3.0.0": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + assert_eq!( + select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?, + Version::parse("1.0.0")? + ); + Ok(()) + } + + #[test] + fn test_select_npm_package_version_errors_when_no_version_matches_before() -> Result<()> { + let info: NpmInfo = serde_json::from_str( + r#"{ + "dist-tags": { "latest": "2.0.0" }, + "versions": ["1.0.0", "2.0.0"], + "time": { + "1.0.0": "2024-01-01T00:00:00.000Z", + "2.0.0": "2024-02-01T00:00:00.000Z" + } + }"#, + )?; + + let Err(error) = + select_npm_package_version("test-package", info, Some("2023-12-01T00:00:00.000Z")) + else { + bail!("expected cutoff to reject all package versions"); + }; + assert_eq!( + error.to_string(), + "no version found for npm package test-package before 2023-12-01T00:00:00.000Z" + ); + Ok(()) + } } diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs index d9ffcddc6ff..e07cb99df59 100644 --- a/crates/notifications/src/status_toast.rs +++ b/crates/notifications/src/status_toast.rs @@ -150,7 +150,13 @@ impl Component for StatusToast { ComponentScope::Notification } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A compact, transient toast used to surface status updates \ + such as completed operations or pending updates, with optional icon, \ + action, and dismiss affordances." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let text_example = StatusToast::new("Operation completed", cx, |this, _| this); let action_example = StatusToast::new("Update ready to install", cx, |this, _cx| { @@ -214,44 +220,33 @@ impl Component for StatusToast { }) }); - Some( - v_flex() - .gap_6() - .p_4() - .children(vec![ - example_group_with_title( - "Basic Toast", - vec![ - single_example("Text", div().child(text_example).into_any_element()), - single_example( - "Action", - div().child(action_example).into_any_element(), - ), - single_example("Icon", div().child(icon_example).into_any_element()), - single_example( - "Dismiss Button", - div().child(dismiss_button_example).into_any_element(), - ), - ], - ), - example_group_with_title( - "Examples", - vec![ - single_example( - "Success", - div().child(success_example).into_any_element(), - ), - single_example("Error", div().child(error_example).into_any_element()), - single_example( - "Warning", - div().child(warning_example).into_any_element(), - ), - single_example("Create PR", div().child(pr_example).into_any_element()), - ], - ) - .vertical(), - ]) - .into_any_element(), - ) + v_flex() + .gap_6() + .p_4() + .children(vec![ + example_group_with_title( + "Basic Toast", + vec![ + single_example("Text", div().child(text_example).into_any_element()), + single_example("Action", div().child(action_example).into_any_element()), + single_example("Icon", div().child(icon_example).into_any_element()), + single_example( + "Dismiss Button", + div().child(dismiss_button_example).into_any_element(), + ), + ], + ), + example_group_with_title( + "Examples", + vec![ + single_example("Success", div().child(success_example).into_any_element()), + single_example("Error", div().child(error_example).into_any_element()), + single_example("Warning", div().child(warning_example).into_any_element()), + single_example("Create PR", div().child(pr_example).into_any_element()), + ], + ) + .vertical(), + ]) + .into_any_element() } } diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 652febbda49..3f1509b922e 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -54,7 +54,6 @@ pub struct ImportCursorSettings { } pub const FIRST_OPEN: &str = "first_open"; -pub const DOCS_URL: &str = "https://zed.dev/docs/"; actions!( onboarding, diff --git a/crates/onboarding/src/theme_preview.rs b/crates/onboarding/src/theme_preview.rs index 99990d1273f..534272ecda8 100644 --- a/crates/onboarding/src/theme_preview.rs +++ b/crates/onboarding/src/theme_preview.rs @@ -315,11 +315,11 @@ impl Component for ThemePreviewTile { "Theme Preview Tile" } - fn description() -> Option<&'static str> { - Some(Self::DOCS) + fn description() -> &'static str { + Self::DOCS } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let theme_registry = ThemeRegistry::global(cx); let one_dark = theme_registry.get("One Dark"); @@ -337,45 +337,43 @@ impl Component for ThemePreviewTile { .flatten() .collect::>(); - Some( - v_flex() - .gap_6() - .p_4() - .children({ - if let Some(one_dark) = one_dark.ok() { - vec![example_group(vec![single_example( - "Default", - div() - .w(px(240.)) - .h(px(180.)) - .child(ThemePreviewTile::new(one_dark, 0.42)) - .into_any_element(), - )])] - } else { - vec![] - } - }) - .child( - example_group(vec![single_example( - "Default Themes", - h_flex() - .gap_4() - .children( - themes_to_preview - .into_iter() - .map(|theme| { - div() - .w(px(200.)) - .h(px(140.)) - .child(ThemePreviewTile::new(theme, 0.42)) - }) - .collect::>(), - ) + v_flex() + .gap_6() + .p_4() + .children({ + if let Some(one_dark) = one_dark.ok() { + vec![example_group(vec![single_example( + "Default", + div() + .w(px(240.)) + .h(px(180.)) + .child(ThemePreviewTile::new(one_dark, 0.42)) .into_any_element(), - )]) - .grow(), - ) - .into_any_element(), - ) + )])] + } else { + vec![] + } + }) + .child( + example_group(vec![single_example( + "Default Themes", + h_flex() + .gap_4() + .children( + themes_to_preview + .into_iter() + .map(|theme| { + div() + .w(px(200.)) + .h(px(140.)) + .child(ThemePreviewTile::new(theme, 0.42)) + }) + .collect::>(), + ) + .into_any_element(), + )]) + .grow(), + ) + .into_any_element() } } diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index 3b62391678e..b332cd4c2a6 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -21,12 +21,22 @@ use crate::responses::{ }; use crate::{ FunctionContent, FunctionDefinition, ImageUrl, MessagePart, ReasoningEffort, - ResponseStreamEvent, ToolCall, ToolCallContent, + ResponseStreamEvent, ServiceTier, ToolCall, ToolCallContent, }; const RESPONSE_MESSAGE_PHASE_COMMENTARY: &str = "commentary"; const RESPONSE_MESSAGE_PHASE_FINAL_ANSWER: &str = "final_answer"; +/// Translates the request's `Speed` into the corresponding OpenAI service tier. +/// Only `Fast` produces a value; `Standard` leaves the field unset so that the +/// project's default tier applies. +fn service_tier_for(speed: Option) -> Option { + match speed? { + language_model_core::Speed::Fast => Some(ServiceTier::Priority), + language_model_core::Speed::Standard => None, + } +} + pub fn into_open_ai( request: LanguageModelRequest, model_id: &str, @@ -37,6 +47,7 @@ pub fn into_open_ai( interleaved_reasoning: bool, ) -> crate::Request { let stream = !model_id.starts_with("o1-"); + let service_tier = service_tier_for(request.speed); let mut messages = Vec::new(); let mut current_reasoning: Option = None; @@ -173,6 +184,7 @@ pub fn into_open_ai( LanguageModelToolChoice::None => crate::ToolChoice::None, }), reasoning_effort, + service_tier, } } @@ -198,9 +210,11 @@ pub fn into_open_ai_response( temperature, thinking_allowed, thinking_effort, - speed: _, + speed, } = request; + let service_tier = service_tier_for(speed); + let mut input_items = Vec::new(); let mut replayed_reasoning_item_indexes = HashMap::default(); for (index, message) in messages.into_iter().enumerate() { @@ -284,6 +298,7 @@ pub fn into_open_ai_response( None }, reasoning, + service_tier, } } @@ -302,14 +317,14 @@ fn append_message_to_response_items( .. } = message; let phase = if role == Role::Assistant { - response_message_phase_from_details(reasoning_details.as_ref()) + response_message_phase_from_details(reasoning_details.as_deref()) } else { None }; if role == Role::Assistant { append_reasoning_details_to_response_items( - reasoning_details.as_ref(), + reasoning_details.as_deref(), replayed_reasoning_item_indexes, input_items, ); @@ -552,10 +567,13 @@ impl OpenAiEventMapper { event: ResponseStreamEvent, ) -> Vec> { let mut events = Vec::new(); - if let Some(usage) = event.usage { + if let Some(usage) = event.usage + && let Some(prompt_tokens) = usage.prompt_tokens + && let Some(completion_tokens) = usage.completion_tokens + { events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: usage.prompt_tokens, - output_tokens: usage.completion_tokens, + input_tokens: prompt_tokens, + output_tokens: completion_tokens, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }))); @@ -863,8 +881,13 @@ impl OpenAiResponseEventMapper { let message = response_failure_message(&response); vec![Err(LanguageModelCompletionError::Other(anyhow!(message)))] } - ResponsesStreamEvent::Error { error } - | ResponsesStreamEvent::GenericError { error } => { + ResponsesStreamEvent::Error { error } => { + vec![Err(LanguageModelCompletionError::Other(anyhow!( + response_error_message(&error) + )))] + } + ResponsesStreamEvent::GenericError { error } => { + let error = error.into_response_error(); vec![Err(LanguageModelCompletionError::Other(anyhow!( response_error_message(&error) )))] @@ -1167,7 +1190,7 @@ mod tests { use language_model_core::{ LanguageModelImage, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse, - LanguageModelToolUseId, SharedString, + LanguageModelToolUseId, SharedString, Speed, }; use pretty_assertions::assert_eq; use serde_json::json; @@ -1337,7 +1360,6 @@ mod tests { }; let user_image = LanguageModelImage { source: SharedString::from("aGVsbG8="), - size: None, }; let expected_image_url = user_image.to_base64_url(); @@ -1481,7 +1503,7 @@ mod tests { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], cache: false, - reasoning_details: Some(json!({ + reasoning_details: Some(Arc::new(json!({ "reasoning_items": [ { "id": "rs_123", @@ -1501,7 +1523,7 @@ mod tests { "status": "completed", } ] - })), + }))), }], tools: Vec::new(), tool_choice: None, @@ -1569,7 +1591,7 @@ mod tests { role: Role::Assistant, content: vec![MessageContent::Text("Done.".into())], cache: false, - reasoning_details: Some(json!({ + reasoning_details: Some(Arc::new(json!({ "reasoning_items": [ { "id": "rs_123", @@ -1583,7 +1605,7 @@ mod tests { "status": "completed" } ] - })), + }))), }], tools: Vec::new(), tool_choice: None, @@ -1664,6 +1686,90 @@ mod tests { assert_eq!(serialized.get("reasoning"), None); } + /// `Speed::Fast` should translate to `service_tier: "priority"` on the + /// outgoing Responses request, while `Standard` / `None` should leave the + /// field unset so the project's default tier wins. + #[test] + fn into_open_ai_response_sets_service_tier_for_fast_speed() -> Result<()> { + for (speed, expected) in [ + (None, None), + (Some(Speed::Standard), None), + (Some(Speed::Fast), Some("priority")), + ] { + let request = LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: None, + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".into())], + cache: false, + reasoning_details: None, + }], + tools: Vec::new(), + tool_choice: None, + stop: Vec::new(), + temperature: None, + thinking_allowed: false, + thinking_effort: None, + speed, + }; + + let response = into_open_ai_response(request, "gpt-5.4", true, true, None, None, true); + + let serialized = serde_json::to_value(&response)?; + assert_eq!( + serialized + .get("service_tier") + .and_then(|value| value.as_str()), + expected, + "speed = {speed:?} should produce service_tier = {expected:?}", + ); + } + Ok(()) + } + + /// Same as above but for the Chat Completions code path. + #[test] + fn into_open_ai_sets_service_tier_for_fast_speed() -> Result<()> { + for (speed, expected) in [ + (None, None), + (Some(Speed::Standard), None), + (Some(Speed::Fast), Some("priority")), + ] { + let request = LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: None, + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".into())], + cache: false, + reasoning_details: None, + }], + tools: Vec::new(), + tool_choice: None, + stop: Vec::new(), + temperature: None, + thinking_allowed: false, + thinking_effort: None, + speed, + }; + + let chat = into_open_ai(request, "gpt-5.4", true, true, None, None, false); + + let serialized = serde_json::to_value(&chat)?; + assert_eq!( + serialized + .get("service_tier") + .and_then(|value| value.as_str()), + expected, + "speed = {speed:?} should produce service_tier = {expected:?}", + ); + } + Ok(()) + } + #[test] fn into_open_ai_response_sends_none_reasoning_when_thinking_is_disabled() -> Result<()> { let request = LanguageModelRequest { @@ -1752,7 +1858,7 @@ mod tests { role: Role::Assistant, content: vec![MessageContent::Text("Done.".into())], cache: false, - reasoning_details: Some(json!({ + reasoning_details: Some(Arc::new(json!({ "phase": "final_answer", "reasoning_items": [ { @@ -1762,7 +1868,7 @@ mod tests { "status": "completed" } ] - })), + }))), }], tools: Vec::new(), tool_choice: None, @@ -1844,13 +1950,13 @@ mod tests { role: Role::Assistant, content: vec![MessageContent::Text("First.".into())], cache: false, - reasoning_details: Some(first_reasoning_details), + reasoning_details: Some(Arc::new(first_reasoning_details)), }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![MessageContent::Text("Second.".into())], cache: false, - reasoning_details: Some(second_reasoning_details), + reasoning_details: Some(Arc::new(second_reasoning_details)), }, ], tools: Vec::new(), @@ -1924,7 +2030,7 @@ mod tests { MessageContent::Text("This is visible assistant output.".into()), ], cache: false, - reasoning_details: Some(json!({ + reasoning_details: Some(Arc::new(json!({ "reasoning_items": [ { "id": "rs_123", @@ -1938,7 +2044,7 @@ mod tests { "status": "completed" } ] - })), + }))), }], tools: Vec::new(), tool_choice: None, @@ -2113,6 +2219,34 @@ mod tests { assert_eq!(error.to_string(), "ERR_SOMETHING: Something went wrong"); } + #[test] + fn responses_stream_deserializes_nested_error_event() { + // In practice the Responses API often nests error fields under an + // `error` object even though the public spec documents them at the top + // level. Make sure we don't lose the message and code in that case. + let event = serde_json::from_value::(json!({ + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "context_length_exceeded", + "message": "Your input exceeds the context window of this model. Please adjust your input and try again.", + "param": "input" + }, + "sequence_number": 2 + })) + .expect("nested error event"); + + let mut mapper = OpenAiResponseEventMapper::new(); + let mapped = mapper.map_event(event); + + assert_eq!(mapped.len(), 1); + let error = mapped.into_iter().next().unwrap().unwrap_err(); + assert_eq!( + error.to_string(), + "context_length_exceeded: Your input exceeds the context window of this model. Please adjust your input and try again." + ); + } + #[test] fn responses_stream_deserializes_response_error_event() { let event = serde_json::from_value::(json!({ diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index b756e8a6122..0ff1308d52a 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -342,6 +342,30 @@ impl Model { pub fn supports_prompt_cache_key(&self) -> bool { true } + + /// Whether OpenAI's Priority processing tier is available for this model. + /// Sourced from . The `*-pro`, + /// `*-nano`, and legacy `gpt-4` variants are not eligible. + pub fn supports_priority(&self) -> bool { + match self { + Self::FourOmniMini + | Self::O3 + | Self::Five + | Self::FiveMini + | Self::FivePointOne + | Self::FivePointTwo + | Self::FivePointThreeCodex + | Self::FivePointFourMini + | Self::FivePointFour + | Self::FivePointFive => true, + Self::Four + | Self::FiveNano + | Self::FivePointFourNano + | Self::FivePointFourPro + | Self::FivePointFivePro + | Self::Custom { .. } => false, + } + } } #[cfg(test)] @@ -456,6 +480,23 @@ pub struct Request { pub prompt_cache_key: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_tier: Option, +} + +/// Service tier for OpenAI requests. Maps to the top-level `service_tier` +/// field on Responses and Chat Completions. We only ever send `Priority` +/// today (in response to Fast Mode being enabled); the other variants are +/// included for symmetry with the API and so deserialization of echoed +/// values does not fail. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ServiceTier { + Auto, + Default, + Flex, + Scale, + Priority, } #[derive(Debug, Serialize, Deserialize)] @@ -622,9 +663,9 @@ pub struct FunctionChunk { #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Usage { - pub prompt_tokens: u64, - pub completion_tokens: u64, - pub total_tokens: u64, + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub total_tokens: Option, } #[derive(Serialize, Deserialize, Debug)] diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 954f9b5ad56..6cc05699254 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -4,7 +4,7 @@ use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{ReasoningEffort, RequestError, Role, ToolChoice}; +use crate::{ReasoningEffort, RequestError, Role, ServiceTier, ToolChoice}; #[derive(Serialize, Debug)] pub struct Request { @@ -35,6 +35,8 @@ pub struct Request { pub reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] pub store: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -156,6 +158,43 @@ pub struct ResponseError { pub param: Option, } +/// Payload of the top-level `error` SSE event from the Responses API. +/// +/// OpenAI's spec documents the error fields as being at the top level of the +/// event, but in practice the API often nests them under an `error` object. +#[derive(Deserialize, Debug, Clone, Default)] +pub struct GenericStreamErrorPayload { + #[serde(flatten)] + top_level: PartialResponseError, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize, Debug, Clone, Default)] +struct PartialResponseError { + #[serde(default)] + code: Option, + #[serde(default)] + message: Option, + #[serde(default)] + param: Option, +} + +impl GenericStreamErrorPayload { + pub fn into_response_error(self) -> ResponseError { + let nested = self.error.unwrap_or_default(); + ResponseError { + code: self.top_level.code.or(nested.code), + message: self + .top_level + .message + .or(nested.message) + .unwrap_or_default(), + param: self.top_level.param.or(nested.param), + } + } +} + #[derive(Deserialize, Debug)] #[serde(tag = "type")] pub enum StreamEvent { @@ -276,7 +315,7 @@ pub enum StreamEvent { #[serde(rename = "error")] GenericError { #[serde(flatten)] - error: ResponseError, + error: GenericStreamErrorPayload, }, #[serde(other)] Unknown, @@ -296,6 +335,8 @@ pub struct ResponseSummary { pub usage: Option, #[serde(default)] pub output: Vec, + #[serde(default)] + pub service_tier: Option, } #[derive(Deserialize, Debug, Default, Clone)] diff --git a/crates/open_path_prompt/src/open_path_prompt.rs b/crates/open_path_prompt/src/open_path_prompt.rs index 607dfb13e4f..8cfcb115cd8 100644 --- a/crates/open_path_prompt/src/open_path_prompt.rs +++ b/crates/open_path_prompt/src/open_path_prompt.rs @@ -709,26 +709,36 @@ impl PickerDelegate for OpenPathDelegate { ) -> Option { let settings = FileFinderSettings::get_global(cx); let candidate = self.get_entry(ix)?; - let mut match_positions = match &self.directory_state { - DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(), + let string_match = match &self.directory_state { + DirectoryState::List { .. } => self.string_matches.get(ix), DirectoryState::Create { user_input, .. } => { if let Some(user_input) = user_input { if !user_input.exists || !user_input.is_dir { if ix == 0 { - Vec::new() + None } else { - self.string_matches.get(ix - 1)?.positions.clone() + self.string_matches.get(ix - 1) } } else { - self.string_matches.get(ix)?.positions.clone() + self.string_matches.get(ix) } } else { - self.string_matches.get(ix)?.positions.clone() + self.string_matches.get(ix) } } - DirectoryState::None { .. } => Vec::new(), + DirectoryState::None { .. } => None, }; + // Directory entries and string matches can briefly go out of sync during + // async updates. When that happens, render the row without highlights. + let mut match_positions = string_match + .filter(|string_match| { + string_match.candidate_id == candidate.path.id + && string_match.string == candidate.path.string + }) + .map(|string_match| string_match.positions.clone()) + .unwrap_or_default(); + let is_current_dir_candidate = candidate.path.string == self.current_dir(); let file_icon = maybe!({ diff --git a/crates/open_path_prompt/src/open_path_prompt_tests.rs b/crates/open_path_prompt/src/open_path_prompt_tests.rs index 7d359dbf176..d89b657860e 100644 --- a/crates/open_path_prompt/src/open_path_prompt_tests.rs +++ b/crates/open_path_prompt/src/open_path_prompt_tests.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{AppContext, Entity, TestAppContext, VisualTestContext}; use picker::{Picker, PickerDelegate}; use project::Project; @@ -8,7 +9,7 @@ use ui::rems; use util::path; use workspace::{AppState, MultiWorkspace}; -use crate::OpenPathDelegate; +use crate::{CandidateInfo, DirectoryState, OpenPathDelegate}; #[gpui::test] async fn test_open_path_prompt(cx: &mut TestAppContext) { @@ -372,6 +373,39 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) { assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]); } +#[gpui::test] +async fn test_open_path_prompt_panics_with_stale_highlight_positions(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree(path!("/root"), json!({})) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (picker, cx) = build_open_path_prompt(project, false, false, cx); + + picker.update_in(cx, |picker, window, cx| { + picker.delegate.prompt_root = "/".to_string(); + picker.delegate.directory_state = DirectoryState::List { + parent_path: picker.delegate.prompt_root.clone(), + entries: vec![CandidateInfo { + path: StringMatchCandidate::new(0, "éclair"), + is_dir: false, + }], + error: None, + }; + picker.delegate.string_matches = vec![StringMatch { + candidate_id: 0, + score: 0.0, + positions: vec![1], + string: "ab".to_string(), + }]; + + picker.delegate.render_match(0, false, window, cx); + }); +} + #[gpui::test] async fn test_open_path_prompt_with_show_hidden(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index b94631f9a0e..306f2c22cb4 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -212,7 +212,7 @@ pub enum RequestMessage { #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - reasoning_details: Option, + reasoning_details: Option>, }, User { content: MessageContent, diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index 0e235bf7166..4dfc30c62b9 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -56,6 +56,8 @@ impl OpenCodeSubscription { #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] pub enum Model { // -- Anthropic protocol models -- + #[serde(rename = "claude-opus-4-8")] + ClaudeOpus4_8, #[serde(rename = "claude-opus-4-7")] ClaudeOpus4_7, #[serde(rename = "claude-opus-4-6")] @@ -115,6 +117,8 @@ pub enum Model { Gemini3_1Pro, #[serde(rename = "gemini-3-flash")] Gemini3Flash, + #[serde(rename = "gemini-3.5-flash")] + Gemini3_5Flash, // -- OpenAI Chat Completions protocol models -- #[serde(rename = "deepseek-v4-pro")] @@ -123,12 +127,12 @@ pub enum Model { DeepSeekV4Flash, #[serde(rename = "minimax-m2.5")] MiniMaxM2_5, - #[serde(rename = "minimax-m2.5-free")] - MiniMaxM2_5Free, #[serde(rename = "glm-5")] Glm5, #[serde(rename = "glm-5.1")] Glm5_1, + #[serde(rename = "grok-build-0.1")] + GrokBuild0_1, #[serde(rename = "kimi-k2.5")] KimiK2_5, #[serde(rename = "kimi-k2.6")] @@ -141,14 +145,14 @@ pub enum Model { MimoV2_5, #[serde(rename = "big-pickle")] BigPickle, - #[serde(rename = "ring-2.6-1t-free")] - Ring2_6_1TFree, #[serde(rename = "nemotron-3-super-free")] Nemotron3SuperFree, #[serde(rename = "qwen3.5-plus")] Qwen3_5Plus, #[serde(rename = "qwen3.6-plus")] Qwen3_6Plus, + #[serde(rename = "qwen3.7-max")] + Qwen3_7Max, // -- Custom model -- #[serde(rename = "custom")] @@ -182,7 +186,7 @@ impl Model { } pub fn default_free_fast() -> Self { - Self::MiniMaxM2_5Free + Self::Nemotron3SuperFree } pub fn available_subscriptions(&self) -> &'static [OpenCodeSubscription] { @@ -201,13 +205,11 @@ impl Model { | Self::MimoV2_5Pro | Self::MimoV2_5 | Self::DeepSeekV4Pro - | Self::DeepSeekV4Flash => &[OpenCodeSubscription::Go], + | Self::DeepSeekV4Flash + | Self::Qwen3_7Max => &[OpenCodeSubscription::Go], // Free models - Self::MiniMaxM2_5Free - | Self::Nemotron3SuperFree - | Self::BigPickle - | Self::Ring2_6_1TFree => &[OpenCodeSubscription::Free], + Self::Nemotron3SuperFree | Self::BigPickle => &[OpenCodeSubscription::Free], // Custom models get their subscription from settings, not from here Self::Custom { .. } => &[], @@ -219,6 +221,7 @@ impl Model { pub fn id(&self) -> &str { match self { + Self::ClaudeOpus4_8 => "claude-opus-4-8", Self::ClaudeOpus4_7 => "claude-opus-4-7", Self::ClaudeOpus4_6 => "claude-opus-4-6", Self::ClaudeOpus4_5 => "claude-opus-4-5", @@ -248,13 +251,14 @@ impl Model { Self::Gemini3_1Pro => "gemini-3.1-pro", Self::Gemini3Flash => "gemini-3-flash", + Self::Gemini3_5Flash => "gemini-3.5-flash", Self::DeepSeekV4Pro => "deepseek-v4-pro", Self::DeepSeekV4Flash => "deepseek-v4-flash", Self::MiniMaxM2_5 => "minimax-m2.5", - Self::MiniMaxM2_5Free => "minimax-m2.5-free", Self::Glm5 => "glm-5", Self::Glm5_1 => "glm-5.1", + Self::GrokBuild0_1 => "grok-build-0.1", Self::KimiK2_5 => "kimi-k2.5", Self::KimiK2_6 => "kimi-k2.6", Self::MiniMaxM2_7 => "minimax-m2.7", @@ -262,8 +266,8 @@ impl Model { Self::MimoV2_5 => "mimo-v2.5", Self::Qwen3_5Plus => "qwen3.5-plus", Self::Qwen3_6Plus => "qwen3.6-plus", + Self::Qwen3_7Max => "qwen3.7-max", Self::BigPickle => "big-pickle", - Self::Ring2_6_1TFree => "ring-2.6-1t-free", Self::Nemotron3SuperFree => "nemotron-3-super-free", Self::Custom { name, .. } => name, @@ -272,6 +276,7 @@ impl Model { pub fn display_name(&self) -> &str { match self { + Self::ClaudeOpus4_8 => "Claude Opus 4.8", Self::ClaudeOpus4_7 => "Claude Opus 4.7", Self::ClaudeOpus4_6 => "Claude Opus 4.6", Self::ClaudeOpus4_5 => "Claude Opus 4.5", @@ -301,13 +306,14 @@ impl Model { Self::Gemini3_1Pro => "Gemini 3.1 Pro", Self::Gemini3Flash => "Gemini 3 Flash", + Self::Gemini3_5Flash => "Gemini 3.5 Flash", Self::DeepSeekV4Pro => "DeepSeek V4 Pro", Self::DeepSeekV4Flash => "DeepSeek V4 Flash", Self::MiniMaxM2_5 => "MiniMax M2.5", - Self::MiniMaxM2_5Free => "MiniMax M2.5 Free", Self::Glm5 => "GLM 5", Self::Glm5_1 => "GLM 5.1", + Self::GrokBuild0_1 => "Grok Build 0.1", Self::KimiK2_5 => "Kimi K2.5", Self::KimiK2_6 => "Kimi K2.6", Self::MiniMaxM2_7 => "MiniMax M2.7", @@ -315,8 +321,8 @@ impl Model { Self::MimoV2_5 => "MiMo V2.5", Self::Qwen3_5Plus => "Qwen3.5 Plus", Self::Qwen3_6Plus => "Qwen3.6 Plus", + Self::Qwen3_7Max => "Qwen3.7 Max", Self::BigPickle => "Big Pickle", - Self::Ring2_6_1TFree => "Ring 2.6 1T Free", Self::Nemotron3SuperFree => "Nemotron 3 Super Free", Self::Custom { @@ -337,7 +343,8 @@ impl Model { } } - Self::ClaudeOpus4_7 + Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_1 @@ -364,11 +371,13 @@ impl Model { | Self::Gpt5Codex | Self::Gpt5Nano => ApiProtocol::OpenAiResponses, - Self::Gemini3_1Pro | Self::Gemini3Flash => ApiProtocol::Google, + Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => ApiProtocol::Google, - Self::MiniMaxM2_5Free - | Self::Glm5 + Self::Qwen3_7Max => ApiProtocol::Anthropic, + + Self::Glm5 | Self::Glm5_1 + | Self::GrokBuild0_1 | Self::KimiK2_5 | Self::KimiK2_6 | Self::MimoV2_5Pro @@ -378,7 +387,6 @@ impl Model { | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::BigPickle - | Self::Ring2_6_1TFree | Self::Nemotron3SuperFree => ApiProtocol::OpenAiChat, Self::Custom { protocol, .. } => *protocol, @@ -395,8 +403,8 @@ impl Model { | Self::MimoV2_5Pro | Self::Glm5 | Self::Glm5_1 - | Self::BigPickle - | Self::Ring2_6_1TFree => true, + | Self::Nemotron3SuperFree + | Self::BigPickle => true, Self::Custom { interleaved_reasoning, @@ -407,10 +415,10 @@ impl Model { } } - pub fn max_token_count(&self) -> u64 { + pub fn max_token_count(&self, subscription: OpenCodeSubscription) -> u64 { match self { // Anthropic models - Self::ClaudeOpus4_7 => 1_000_000, + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 => 1_000_000, Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => 1_000_000, Self::ClaudeSonnet4_5 => 1_000_000, Self::ClaudeOpus4_5 | Self::ClaudeHaiku4_5 => 200_000, @@ -432,17 +440,25 @@ impl Model { // Google models Self::Gemini3_1Pro => 1_048_576, Self::Gemini3Flash => 1_048_576, + Self::Gemini3_5Flash => 1_048_576, // OpenAI-compatible models Self::MiniMaxM2_7 => 204_800, - Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => 204_800, - Self::Glm5 | Self::Glm5_1 => 202_725, + Self::MiniMaxM2_5 => 204_800, + Self::Glm5 | Self::Glm5_1 => { + if subscription == OpenCodeSubscription::Go { + 202_752 + } else { + 204_800 + } + } Self::KimiK2_6 | Self::KimiK2_5 => 262_144, + Self::GrokBuild0_1 => 256_000, Self::MimoV2_5Pro => 1_048_576, Self::MimoV2_5 => 1_000_000, Self::Qwen3_5Plus | Self::Qwen3_6Plus => 262_144, + Self::Qwen3_7Max => 1_000_000, Self::BigPickle => 200_000, - Self::Ring2_6_1TFree => 262_000, Self::Nemotron3SuperFree => 204_800, Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => 1_000_000, @@ -450,10 +466,10 @@ impl Model { } } - pub fn max_output_tokens(&self) -> Option { + pub fn max_output_tokens(&self, subscription: OpenCodeSubscription) -> Option { match self { // Anthropic models - Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 => Some(128_000), + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 => Some(128_000), Self::ClaudeOpus4_5 | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 @@ -481,16 +497,28 @@ impl Model { | Self::Gpt5Nano => Some(128_000), // Google models - Self::Gemini3_1Pro | Self::Gemini3Flash => Some(65_536), + Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => Some(65_536), // OpenAI-compatible models Self::MiniMaxM2_7 => Some(131_072), - Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => Some(131_072), - Self::Glm5 | Self::Glm5_1 => Some(32_768), - Self::BigPickle => Some(128_000), - Self::Ring2_6_1TFree => Some(66_000), + Self::MiniMaxM2_5 => { + if subscription == OpenCodeSubscription::Go { + Some(65_536) + } else { + Some(131_072) + } + } + Self::Glm5 | Self::Glm5_1 => { + if subscription == OpenCodeSubscription::Go { + Some(32_768) + } else { + Some(131_072) + } + } + Self::BigPickle => Some(32_000), Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536), - Self::Qwen3_5Plus | Self::Qwen3_6Plus => Some(65_536), + Self::GrokBuild0_1 => Some(256_000), + Self::Qwen3_7Max | Self::Qwen3_6Plus | Self::Qwen3_5Plus => Some(65_536), Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(384_000), Self::Nemotron3SuperFree => Some(128_000), Self::MimoV2_5Pro | Self::MimoV2_5 => Some(128_000), @@ -508,7 +536,8 @@ impl Model { pub fn supports_images(&self) -> bool { match self { // Anthropic models support images - Self::ClaudeOpus4_7 + Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_1 @@ -525,7 +554,6 @@ impl Model { | Self::Gpt5_4Mini | Self::Gpt5_4Nano | Self::Gpt5_3Codex - | Self::Gpt5_3Spark | Self::Gpt5_2 | Self::Gpt5_2Codex | Self::Gpt5_1 @@ -536,27 +564,30 @@ impl Model { | Self::Gpt5Codex | Self::Gpt5Nano => true, + // OpenAI models without image support + Self::Gpt5_3Spark => false, + // Google models support images - Self::Gemini3_1Pro | Self::Gemini3Flash => true, + Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => true, // OpenAI-compatible models with image support Self::KimiK2_6 | Self::KimiK2_5 + | Self::GrokBuild0_1 | Self::MimoV2_5 | Self::Qwen3_5Plus | Self::Qwen3_6Plus => true, // OpenAI-compatible models without image support Self::MiniMaxM2_5 - | Self::MiniMaxM2_5Free | Self::Glm5 | Self::Glm5_1 | Self::MiniMaxM2_7 | Self::MimoV2_5Pro | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash + | Self::Qwen3_7Max | Self::BigPickle - | Self::Ring2_6_1TFree | Self::Nemotron3SuperFree => false, Self::Custom { protocol, .. } => matches!( @@ -571,7 +602,14 @@ impl Model { pub fn supported_reasoning_effort_levels(&self) -> Option> { match self { - Self::Ring2_6_1TFree | Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ + Self::ClaudeOpus4_8 => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]), + + Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 7c5bb7bcf62..c9812e5ee38 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -131,7 +131,9 @@ pub struct OutlinePanel { _subscriptions: Vec, new_entries_for_fs_update: HashSet, fs_entries_update_task: Task<()>, + fs_entries_update_pending: bool, cached_entries_update_task: Task<()>, + cached_entries_update_pending: bool, reveal_selection_task: Task>, outline_fetch_tasks: HashMap>, buffers: HashMap, @@ -415,6 +417,12 @@ struct SearchData { highlights_data: HighlightStyleData, } +struct SearchPrecomputed { + multi_buffer_snapshot: MultiBufferSnapshot, + matches_by_buffer: HashMap, Arc>)>>, + folded_buffers: HashSet, +} + impl PartialEq for PanelEntry { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -872,7 +880,9 @@ impl OutlinePanel { preserve_selection_on_buffer_fold_toggles: HashSet::default(), pending_default_expansion_depth: None, fs_entries_update_task: Task::ready(()), + fs_entries_update_pending: false, cached_entries_update_task: Task::ready(()), + cached_entries_update_pending: false, reveal_selection_task: Task::ready(Ok(())), outline_fetch_tasks: HashMap::default(), buffers: HashMap::default(), @@ -2716,12 +2726,11 @@ impl OutlinePanel { return; } - let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; - let active_multi_buffer = active_editor.read(cx).buffer().clone(); - let new_entries = self.new_entries_for_fs_update.clone(); - let repo_snapshots = self.project.update(cx, |project, cx| { - project.git_store().read(cx).repo_snapshots(cx) - }); + if debounce.is_some() && self.fs_entries_update_pending { + return; + } + self.fs_entries_update_pending = true; + self.fs_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| { if let Some(debounce) = debounce { cx.background_executor().timer(debounce).await; @@ -2731,65 +2740,77 @@ impl OutlinePanel { let mut new_unfolded_dirs = HashMap::default(); let mut root_entries = HashSet::default(); let mut new_buffers = HashMap::::default(); - let Ok(buffer_excerpts) = outline_panel.update(cx, |outline_panel, cx| { - let git_store = outline_panel.project.read(cx).git_store().clone(); - new_collapsed_entries = outline_panel.collapsed_entries.clone(); - new_unfolded_dirs = outline_panel.unfolded_dirs.clone(); - let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); + let Ok((buffer_excerpts, auto_fold_dirs, repo_snapshots)) = + outline_panel.update(cx, |outline_panel, cx| { + outline_panel.fs_entries_update_pending = false; + let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; + let active_multi_buffer = active_editor.read(cx).buffer().clone(); + let new_entries = outline_panel.new_entries_for_fs_update.clone(); + let repo_snapshots = outline_panel.project.update(cx, |project, cx| { + project.git_store().read(cx).repo_snapshots(cx) + }); + let git_store = outline_panel.project.read(cx).git_store().clone(); + new_collapsed_entries = outline_panel.collapsed_entries.clone(); + new_unfolded_dirs = outline_panel.unfolded_dirs.clone(); + let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); - multi_buffer_snapshot.excerpts().fold( - HashMap::default(), - |mut buffer_excerpts, excerpt_range| { - let Some(buffer_snapshot) = multi_buffer_snapshot - .buffer_for_id(excerpt_range.context.start.buffer_id) - else { - return buffer_excerpts; - }; - let buffer_id = buffer_snapshot.remote_id(); - let file = File::from_dyn(buffer_snapshot.file()); - let entry_id = file.and_then(|file| file.project_entry_id()); - let worktree = file.map(|file| file.worktree.read(cx).snapshot()); - let is_new = new_entries.contains(&buffer_id) - || !outline_panel.buffers.contains_key(&buffer_id); - let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx); - let status = git_store - .read(cx) - .repository_and_path_for_buffer_id(buffer_id, cx) - .and_then(|(repo, path)| { - Some(repo.read(cx).status_for_path(&path)?.status) - }); - buffer_excerpts - .entry(buffer_id) - .or_insert_with(|| { - (is_new, is_folded, Vec::new(), entry_id, worktree, status) - }) - .2 - .push(excerpt_range.clone()); + let buffer_excerpts = multi_buffer_snapshot.excerpts().fold( + HashMap::default(), + |mut buffer_excerpts, excerpt_range| { + let Some(buffer_snapshot) = multi_buffer_snapshot + .buffer_for_id(excerpt_range.context.start.buffer_id) + else { + return buffer_excerpts; + }; + let buffer_id = buffer_snapshot.remote_id(); + let file = File::from_dyn(buffer_snapshot.file()); + let entry_id = file.and_then(|file| file.project_entry_id()); + let worktree = file.map(|file| file.worktree.read(cx).snapshot()); + let is_new = new_entries.contains(&buffer_id) + || !outline_panel.buffers.contains_key(&buffer_id); + let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx); + let status = git_store + .read(cx) + .repository_and_path_for_buffer_id(buffer_id, cx) + .and_then(|(repo, path)| { + Some(repo.read(cx).status_for_path(&path)?.status) + }); + buffer_excerpts + .entry(buffer_id) + .or_insert_with(|| { + (is_new, is_folded, Vec::new(), entry_id, worktree, status) + }) + .2 + .push(excerpt_range.clone()); - new_buffers - .entry(buffer_id) - .or_insert_with(|| { - let outlines = match outline_panel.buffers.get(&buffer_id) { - Some(old_buffer) => match &old_buffer.outlines { - OutlineState::Outlines(outlines) => { - OutlineState::Outlines(outlines.clone()) - } - OutlineState::Invalidated(_) => OutlineState::NotFetched, - OutlineState::NotFetched => OutlineState::NotFetched, - }, - None => OutlineState::NotFetched, - }; - BufferOutlines { - outlines, - excerpts: Vec::new(), - } - }) - .excerpts - .push(excerpt_range); - buffer_excerpts - }, - ) - }) else { + new_buffers + .entry(buffer_id) + .or_insert_with(|| { + let outlines = match outline_panel.buffers.get(&buffer_id) { + Some(old_buffer) => match &old_buffer.outlines { + OutlineState::Outlines(outlines) => { + OutlineState::Outlines(outlines.clone()) + } + OutlineState::Invalidated(_) => { + OutlineState::NotFetched + } + OutlineState::NotFetched => OutlineState::NotFetched, + }, + None => OutlineState::NotFetched, + }; + BufferOutlines { + outlines, + excerpts: Vec::new(), + } + }) + .excerpts + .push(excerpt_range); + buffer_excerpts + }, + ); + (buffer_excerpts, auto_fold_dirs, repo_snapshots) + }) + else { return; }; @@ -3126,14 +3147,12 @@ impl OutlinePanel { e: &SearchEvent, window: &mut Window, cx: &mut Context| { - if matches!(e, SearchEvent::MatchesInvalidated) { - let update_cached_items = outline_panel.update_search_matches(window, cx); - if update_cached_items { - outline_panel.selected_entry.invalidate(); - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); - } - }; - outline_panel.autoscroll(cx); + if matches!(e, SearchEvent::MatchesInvalidated) + && outline_panel.update_search_matches(window, cx) + { + outline_panel.selected_entry.invalidate(); + outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + } }, ); self.active_item = Some(ActiveItem { @@ -3157,8 +3176,10 @@ impl OutlinePanel { fn clear_previous(&mut self, window: &mut Window, cx: &mut App) { self.fs_entries_update_task = Task::ready(()); + self.fs_entries_update_pending = false; self.outline_fetch_tasks.clear(); self.cached_entries_update_task = Task::ready(()); + self.cached_entries_update_pending = false; self.reveal_selection_task = Task::ready(Ok(())); self.filter_editor .update(cx, |editor, cx| editor.clear(window, cx)); @@ -3585,14 +3606,23 @@ impl OutlinePanel { return; } - let is_singleton = self.is_singleton_active(cx); - let query = self.query(cx); + // A pending debounced update will read the latest state when it fires, + // so we don't need to reschedule. Constantly rescheduling under a steady stream + // of events (e.g. project search streaming results) would starve the task forever. + if debounce.is_some() && self.cached_entries_update_pending { + return; + } + self.cached_entries_update_pending = true; + self.cached_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| { if let Some(debounce) = debounce { cx.background_executor().timer(debounce).await; } let Some(new_cached_entries) = outline_panel .update_in(cx, |outline_panel, window, cx| { + outline_panel.cached_entries_update_pending = false; + let is_singleton = outline_panel.is_singleton_active(cx); + let query = outline_panel.query(cx); outline_panel.generate_cached_entries(is_singleton, query, window, cx) }) .ok() @@ -3618,7 +3648,6 @@ impl OutlinePanel { outline_panel.select_entry(new_selected_entry, false, window, cx); } - outline_panel.autoscroll(cx); cx.notify(); }) .ok(); @@ -3651,6 +3680,60 @@ impl OutlinePanel { expanded: bool, depth: usize, } + + let search_precomputed = + if let ItemsDisplayMode::Search(search_state) = &outline_panel.mode { + let multi_buffer_snapshot = + active_editor.read(cx).buffer().read(cx).snapshot(cx); + let mut folded_buffers = HashSet::default(); + let mut not_folded_buffers = HashSet::default(); + let mut matches_by_buffer = HashMap::default(); + + for (match_range, search_data) in &search_state.matches { + let Some((start_anchor, _)) = + multi_buffer_snapshot.anchor_to_buffer_anchor(match_range.start) + else { + continue; + }; + let start_buffer_id = start_anchor.buffer_id; + let end_buffer_id = multi_buffer_snapshot + .anchor_to_buffer_anchor(match_range.end) + .map(|(anchor, _)| anchor.buffer_id); + + let mut any_folded = false; + for buffer_id in + [Some(start_buffer_id), end_buffer_id].into_iter().flatten() + { + if folded_buffers.contains(&buffer_id) { + any_folded = true; + } else if !not_folded_buffers.contains(&buffer_id) { + if active_editor.read(cx).is_buffer_folded(buffer_id, cx) { + folded_buffers.insert(buffer_id); + any_folded = true; + } else { + not_folded_buffers.insert(buffer_id); + } + } + } + if any_folded { + continue; + } + + matches_by_buffer + .entry(start_buffer_id) + .or_insert_with(Vec::new) + .push((match_range.clone(), Arc::clone(search_data))); + } + + Some(SearchPrecomputed { + multi_buffer_snapshot, + matches_by_buffer, + folded_buffers, + }) + } else { + None + }; + let mut parent_dirs = Vec::::new(); for entry in outline_panel.fs_entries.clone() { let is_expanded = outline_panel.is_expanded(&entry); @@ -3880,13 +3963,15 @@ impl OutlinePanel { match outline_panel.mode { ItemsDisplayMode::Search(_) => { - if is_singleton || query.is_some() || (should_add && is_expanded) { + if (is_singleton || query.is_some() || (should_add && is_expanded)) + && let Some(search) = &search_precomputed + { outline_panel.add_search_entries( &mut generation_state, - &active_editor, - entry.clone(), + search, + &entry, depth, - query.clone(), + query.is_some(), is_singleton, cx, ); @@ -4202,31 +4287,37 @@ impl OutlinePanel { ) }; - let mut previous_matches = HashMap::default(); - update_cached_entries = match &mut self.mode { - ItemsDisplayMode::Search(current_search_state) => { - let update = current_search_state.query != new_search_query - || current_search_state.kind != kind - || current_search_state.matches.is_empty() - || current_search_state.matches.iter().enumerate().any( - |(i, (match_range, _))| new_search_matches.get(i) != Some(match_range), - ); - if current_search_state.kind == kind { - previous_matches.extend(current_search_state.matches.drain(..)); - } - update + let changed = match &self.mode { + ItemsDisplayMode::Search(current) => { + current.query != new_search_query + || current.kind != kind + || current.matches.len() != new_search_matches.len() + || current + .matches + .iter() + .zip(&new_search_matches) + .any(|((existing, _), incoming)| existing != incoming) } ItemsDisplayMode::Outline => true, }; - self.mode = ItemsDisplayMode::Search(SearchState::new( - kind, - new_search_query, - previous_matches, - new_search_matches, - cx.theme().syntax().clone(), - window, - cx, - )); + if changed { + let previous_matches = match &mut self.mode { + ItemsDisplayMode::Search(current) if current.kind == kind => { + current.matches.drain(..).collect() + } + _ => HashMap::default(), + }; + self.mode = ItemsDisplayMode::Search(SearchState::new( + kind, + new_search_query, + previous_matches, + new_search_matches, + cx.theme().syntax().clone(), + window, + cx, + )); + update_cached_entries = true; + } } update_cached_entries } @@ -4350,68 +4441,58 @@ impl OutlinePanel { fn add_search_entries( &mut self, state: &mut GenerationState, - active_editor: &Entity, - parent_entry: FsEntry, + search: &SearchPrecomputed, + parent_entry: &FsEntry, parent_depth: usize, - filter_query: Option, + track_matches: bool, is_singleton: bool, cx: &mut Context, ) { - let ItemsDisplayMode::Search(search_state) = &mut self.mode else { + let ItemsDisplayMode::Search(search_state) = &self.mode else { + return; + }; + let kind = search_state.kind; + + let (buffer_id, excerpts) = match parent_entry { + FsEntry::Directory(_) => return, + FsEntry::ExternalFile(external) => (external.buffer_id, &external.excerpts), + FsEntry::File(file) => (file.buffer_id, &file.excerpts), + }; + + if search.folded_buffers.contains(&buffer_id) { + return; + } + let Some(buffer_matches) = search.matches_by_buffer.get(&buffer_id) else { return; }; - let kind = search_state.kind; - let related_excerpts = match &parent_entry { - FsEntry::Directory(_) => return, - FsEntry::ExternalFile(external) => &external.excerpts, - FsEntry::File(file) => &file.excerpts, - } - .iter() - .cloned() - .collect::>(); - - let depth = if is_singleton { 0 } else { parent_depth + 1 }; - let new_search_matches = search_state.matches.iter().filter(|(match_range, _)| { - let editor = active_editor.read(cx); - let snapshot = editor.buffer().read(cx).snapshot(cx); - if !related_excerpts.iter().any(|excerpt| { - let (Some(start), Some(end)) = ( - snapshot.anchor_in_buffer(excerpt.context.start), - snapshot.anchor_in_buffer(excerpt.context.end), - ) else { - return false; - }; - let excerpt_range = start..end; - excerpt_range.overlaps(match_range, &snapshot) - }) { - return false; - }; - if let Some((buffer_anchor, _)) = snapshot.anchor_to_buffer_anchor(match_range.start) - && editor.is_buffer_folded(buffer_anchor.buffer_id, cx) - { - return false; - } - if let Some((buffer_anchor, _)) = snapshot.anchor_to_buffer_anchor(match_range.end) - && editor.is_buffer_folded(buffer_anchor.buffer_id, cx) - { - return false; - } - true - }); - - let new_search_entries = new_search_matches - .map(|(match_range, search_data)| SearchEntry { - match_range: match_range.clone(), - kind, - render_data: Arc::clone(search_data), + let excerpt_ranges = excerpts + .iter() + .filter_map(|excerpt| { + let start = search + .multi_buffer_snapshot + .anchor_in_buffer(excerpt.context.start)?; + let end = search + .multi_buffer_snapshot + .anchor_in_buffer(excerpt.context.end)?; + Some(start..end) }) .collect::>(); - for new_search_entry in new_search_entries { + + let depth = if is_singleton { 0 } else { parent_depth + 1 }; + for (match_range, search_data) in buffer_matches.iter().filter(|(match_range, _)| { + excerpt_ranges.iter().any(|excerpt_range| { + excerpt_range.overlaps(match_range, &search.multi_buffer_snapshot) + }) + }) { self.push_entry( state, - filter_query.is_some(), - PanelEntry::Search(new_search_entry), + track_matches, + PanelEntry::Search(SearchEntry { + match_range: match_range.clone(), + kind, + render_data: Arc::clone(search_data), + }), depth, cx, ); diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index e0e044e5638..81779906ed6 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -1,26 +1,27 @@ use std::{ any::Any, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, LazyLock}, time::Duration, }; use anyhow::{Context as _, Result, bail}; use collections::HashMap; -use fs::Fs; -use gpui::{AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, TaskExt}; +use fs::{Fs, RemoveOptions}; +use futures::StreamExt; +use gpui::{ + AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, + TaskExt, +}; use http_client::{HttpClient, github::AssetKind}; use node_runtime::NodeRuntime; use percent_encoding::percent_decode_str; use remote::RemoteClient; -use rpc::{ - AnyProtoClient, TypedEnvelope, - proto::{self, ExternalExtensionAgent}, -}; +use rpc::{AnyProtoClient, TypedEnvelope, proto}; use schemars::JsonSchema; use semver::Version; use serde::{Deserialize, Serialize}; -use settings::{RegisterSetting, SettingsStore}; +use settings::{RegisterSetting, SettingsStore, update_settings_file}; use sha2::{Digest, Sha256}; use url::Url; use util::{ResultExt as _, debug_panic}; @@ -110,7 +111,6 @@ impl std::borrow::Borrow for AgentId { pub enum ExternalAgentSource { #[default] Custom, - Extension, Registry, } @@ -136,16 +136,6 @@ pub trait ExternalAgentServer { fn as_any_mut(&mut self) -> &mut dyn Any; } -struct ExtensionAgentEntry { - agent_name: Arc, - extension_id: String, - targets: HashMap, - env: HashMap, - icon_path: Option, - display_name: Option, - version: Option, -} - enum AgentServerStoreState { Local { node_runtime: NodeRuntime, @@ -154,7 +144,6 @@ enum AgentServerStoreState { downstream_client: Option<(u64, AnyProtoClient)>, settings: Option, http_client: Arc, - extension_agents: Vec, _subscriptions: Vec, }, Remote { @@ -197,123 +186,54 @@ pub struct AgentServersUpdated; impl EventEmitter for AgentServerStore {} +static EXTENSION_TO_REGISTRY_IDS: LazyLock> = + LazyLock::new(|| { + HashMap::from_iter([ + ("opencode", "opencode"), + ("mistral-vibe", "mistral-vibe"), + ("auggie", "auggie"), + ("stakpak", "stakpak"), + ("codebuddy", "codebuddy-code"), + ("autohand-acp", "autohand"), + ("corust-agent", "corust-agent"), + ("factory-droid", "factory-droid"), + // Unmaintained + // ("qqcode", ""), + ]) + }); + impl AgentServerStore { - /// Synchronizes extension-provided agent servers with the store. - pub fn sync_extension_agents<'a, I>( + pub fn migrate_agent_server_from_extensions( &mut self, - manifests: I, - extensions_dir: PathBuf, + id: Arc, + fs: Arc, cx: &mut Context, - ) where - I: IntoIterator, - { - // Collect manifests first so we can iterate twice - let manifests: Vec<_> = manifests.into_iter().collect(); + ) { + let Some(registry_id) = EXTENSION_TO_REGISTRY_IDS.get(id.as_ref()) else { + return; + }; - // Remove all extension-provided agents - // (They will be re-added below if they're in the currently installed extensions) - self.external_agents - .retain(|_, entry| entry.source != ExternalAgentSource::Extension); - - // Insert agent servers from extension manifests - match &mut self.state { - AgentServerStoreState::Local { - extension_agents, .. - } => { - extension_agents.clear(); - for (ext_id, manifest) in manifests { - for (agent_name, agent_entry) in &manifest.agent_servers { - let display_name = SharedString::from(agent_entry.name.clone()); - let icon_path = agent_entry.icon.as_ref().and_then(|icon| { - resolve_extension_icon_path(&extensions_dir, ext_id, icon) - }); - - extension_agents.push(ExtensionAgentEntry { - agent_name: agent_name.clone(), - extension_id: ext_id.to_owned(), - targets: agent_entry.targets.clone(), - env: agent_entry.env.clone(), - icon_path, - display_name: Some(display_name), - version: Some(SharedString::from(manifest.version.clone())), - }); - } - } - self.reregister_agents(cx); + update_settings_file(fs, cx, move |settings, _| { + let agent_servers = settings.agent_servers.get_or_insert_default(); + // Take the old settings + let settings = agent_servers.remove(id.as_ref()); + // If they had both installed, just remove the extension settings, leave theirregistry settings alone + if agent_servers.contains_key(*registry_id) { + return; } - AgentServerStoreState::Remote { - project_id, - upstream_client, - worktree_store, - } => { - let mut agents = vec![]; - for (ext_id, manifest) in manifests { - for (agent_name, agent_entry) in &manifest.agent_servers { - let display_name = SharedString::from(agent_entry.name.clone()); - let icon_path = agent_entry.icon.as_ref().and_then(|icon| { - resolve_extension_icon_path(&extensions_dir, ext_id, icon) - }); - let icon_shared = icon_path - .as_ref() - .map(|path| SharedString::from(path.clone())); - let icon = icon_path; - let agent_server_name = AgentId(agent_name.clone().into()); - self.external_agents - .entry(agent_server_name.clone()) - .and_modify(|entry| { - entry.icon = icon_shared.clone(); - entry.display_name = Some(display_name.clone()); - entry.source = ExternalAgentSource::Extension; - }) - .or_insert_with(|| { - ExternalAgentEntry::new( - Box::new(RemoteExternalAgentServer { - project_id: *project_id, - upstream_client: upstream_client.clone(), - worktree_store: worktree_store.clone(), - name: agent_server_name.clone(), - new_version_available_tx: None, - }) - as Box, - ExternalAgentSource::Extension, - icon_shared.clone(), - Some(display_name.clone()), - ) - }); - - agents.push(ExternalExtensionAgent { - name: agent_name.to_string(), - icon_path: icon, - extension_id: ext_id.to_string(), - targets: agent_entry - .targets - .iter() - .map(|(k, v)| (k.clone(), v.to_proto())) - .collect(), - env: agent_entry - .env - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - version: Some(manifest.version.to_string()), - }); - } - } - upstream_client - .read(cx) - .proto_client() - .send(proto::ExternalExtensionAgentsUpdated { - project_id: *project_id, - agents, - }) - .log_err(); - } - AgentServerStoreState::Collab => { - // Do nothing - } - } - - cx.emit(AgentServersUpdated); + // Insert the old settings, or write new ones so it is "installed" via the registry + agent_servers.insert( + registry_id.to_string(), + settings.unwrap_or_else(|| settings::CustomAgentServerSettings::Registry { + default_mode: None, + default_model: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + }), + ); + }); } pub fn agent_icon(&self, id: &AgentId) -> Option { @@ -327,46 +247,6 @@ impl AgentServerStore { } } -/// Safely resolves an extension icon path, ensuring it stays within the extension directory. -/// Returns `None` if the path would escape the extension directory (path traversal attack). -pub fn resolve_extension_icon_path( - extensions_dir: &Path, - extension_id: &str, - icon_relative_path: &str, -) -> Option { - let extension_root = extensions_dir.join(extension_id); - let icon_path = extension_root.join(icon_relative_path); - - // Canonicalize both paths to resolve symlinks and normalize the paths. - // For the extension root, we need to handle the case where it might be a symlink - // (common for dev extensions). - let canonical_extension_root = extension_root.canonicalize().unwrap_or(extension_root); - let canonical_icon_path = match icon_path.canonicalize() { - Ok(path) => path, - Err(err) => { - log::warn!( - "Failed to canonicalize icon path for extension '{}': {} (path: {})", - extension_id, - err, - icon_relative_path - ); - return None; - } - }; - - // Verify the resolved icon path is within the extension directory - if canonical_icon_path.starts_with(&canonical_extension_root) { - Some(canonical_icon_path.to_string_lossy().to_string()) - } else { - log::warn!( - "Icon path '{}' for extension '{}' escapes extension directory, ignoring for security", - icon_relative_path, - extension_id - ); - None - } -} - impl AgentServerStore { pub fn agent_display_name(&self, name: &AgentId) -> Option { self.external_agents @@ -380,7 +260,6 @@ impl AgentServerStore { } pub fn init_headless(session: &AnyProtoClient) { - session.add_entity_message_handler(Self::handle_external_extension_agents_updated); session.add_entity_request_handler(Self::handle_get_agent_server_command); } @@ -415,7 +294,6 @@ impl AgentServerStore { downstream_client, settings: old_settings, http_client, - extension_agents, .. } = &mut self.state else { @@ -466,47 +344,6 @@ impl AgentServerStore { } } - // Insert extension agents before custom/registry so registry entries override extensions. - for entry in extension_agents.iter() { - let name = AgentId(entry.agent_name.clone().into()); - let mut env = entry.env.clone(); - if let Some(settings_env) = - new_settings - .get(entry.agent_name.as_ref()) - .and_then(|settings| match settings { - CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()), - _ => None, - }) - { - env.extend(settings_env); - } - let icon = entry - .icon_path - .as_ref() - .map(|path| SharedString::from(path.clone())); - - self.external_agents.insert( - name.clone(), - ExternalAgentEntry::new( - Box::new(LocalExtensionArchiveAgent { - fs: fs.clone(), - http_client: http_client.clone(), - node_runtime: node_runtime.clone(), - project_environment: project_environment.clone(), - extension_id: Arc::from(&*entry.extension_id), - targets: entry.targets.clone(), - env, - agent_id: entry.agent_name.clone(), - version: entry.version.clone(), - new_version_available_tx: None, - }) as Box, - ExternalAgentSource::Extension, - icon, - entry.display_name.clone(), - ), - ); - } - for (name, settings) in new_settings.iter() { match settings { CustomAgentServerSettings::Custom { command, .. } => { @@ -589,7 +426,6 @@ impl AgentServerStore { } } } - CustomAgentServerSettings::Extension { .. } => {} } } @@ -658,12 +494,10 @@ impl AgentServerStore { http_client, downstream_client: None, settings: None, - extension_agents: vec![], _subscriptions: subscriptions, }, external_agents: HashMap::default(), }; - if let Some(_events) = extension::ExtensionEvents::try_global(cx) {} this.agent_servers_settings_changed(cx); this } @@ -896,52 +730,6 @@ impl AgentServerStore { }) } - async fn handle_external_extension_agents_updated( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result<()> { - this.update(&mut cx, |this, cx| { - let AgentServerStoreState::Local { - extension_agents, .. - } = &mut this.state - else { - panic!( - "handle_external_extension_agents_updated \ - should not be called for a non-remote project" - ); - }; - - extension_agents.clear(); - for ExternalExtensionAgent { - name, - icon_path, - extension_id, - targets, - env, - version, - } in envelope.payload.agents - { - extension_agents.push(ExtensionAgentEntry { - agent_name: Arc::from(&*name), - extension_id, - targets: targets - .into_iter() - .map(|(k, v)| (k, extension::TargetConfig::from_proto(v))) - .collect(), - env: env.into_iter().collect(), - icon_path, - display_name: None, - version: version.map(SharedString::from), - }); - } - - this.reregister_agents(cx); - cx.emit(AgentServersUpdated); - Ok(()) - }) - } - async fn handle_new_version_available( this: Entity, envelope: TypedEnvelope, @@ -957,16 +745,6 @@ impl AgentServerStore { }); Ok(()) } - - pub fn get_extension_id_for_agent(&self, name: &AgentId) -> Option> { - self.external_agents.get(name).and_then(|entry| { - entry - .server - .as_any() - .downcast_ref::() - .map(|ext_agent| ext_agent.extension_id.clone()) - }) - } } struct RemoteExternalAgentServer { @@ -1126,199 +904,70 @@ fn versioned_archive_cache_dir( )) } -pub struct LocalExtensionArchiveAgent { - pub fs: Arc, - pub http_client: Arc, - pub node_runtime: NodeRuntime, - pub project_environment: Entity, - pub extension_id: Arc, - pub agent_id: Arc, - pub targets: HashMap, - pub env: HashMap, - pub version: Option, - pub new_version_available_tx: Option>>, -} +// The `v_` prefix here must stay in sync with `versioned_archive_cache_dir`, +// so we only ever remove directories that we created ourselves. +const VERSIONED_ARCHIVE_CACHE_DIR_PREFIX: &str = "v_"; -impl ExternalAgentServer for LocalExtensionArchiveAgent { - fn version(&self) -> Option<&SharedString> { - self.version.as_ref() +async fn remove_stale_versioned_archive_cache_dirs( + fs: Arc, + base_dir: &Path, + current_version_dir: &Path, +) -> Result<()> { + let Some(current_dir_name) = current_version_dir.file_name() else { + return Ok(()); + }; + + let current_mtime = fs + .metadata(current_version_dir) + .await + .with_context(|| format!("reading metadata for {current_version_dir:?}"))? + .with_context(|| format!("missing metadata for {current_version_dir:?}"))? + .mtime; + + let mut entries = fs + .read_dir(base_dir) + .await + .with_context(|| format!("reading archive cache directory {base_dir:?}"))?; + + while let Some(entry) = entries.next().await { + let entry = entry.with_context(|| format!("reading entry in {base_dir:?}"))?; + let Some(entry_name) = entry.file_name() else { + continue; + }; + + if entry_name == current_dir_name + || !entry_name + .to_string_lossy() + .starts_with(VERSIONED_ARCHIVE_CACHE_DIR_PREFIX) + { + continue; + } + + let Some(entry_metadata) = fs.metadata(&entry).await.log_err().flatten() else { + continue; + }; + if !entry_metadata.is_dir { + continue; + } + // Only remove directories that predate the current version's directory. + // This avoids racing with a concurrent extraction of a different version + // that finished after we cached the current version's mtime. + if !current_mtime.bad_is_greater_than(entry_metadata.mtime) { + continue; + } + + fs.remove_dir( + &entry, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + .with_context(|| format!("removing stale archive cache directory {entry:?}"))?; } - fn take_new_version_available_tx(&mut self) -> Option>> { - self.new_version_available_tx.take() - } - - fn set_new_version_available_tx(&mut self, tx: watch::Sender>) { - self.new_version_available_tx = Some(tx); - } - - fn get_command( - &self, - extra_args: Vec, - extra_env: HashMap, - cx: &mut AsyncApp, - ) -> Task> { - let fs = self.fs.clone(); - let http_client = self.http_client.clone(); - let node_runtime = self.node_runtime.clone(); - let project_environment = self.project_environment.downgrade(); - let extension_id = self.extension_id.clone(); - let agent_id = self.agent_id.clone(); - let targets = self.targets.clone(); - let base_env = self.env.clone(); - let version = self.version.clone(); - - cx.spawn(async move |cx| { - // Get project environment - let mut env = project_environment - .update(cx, |project_environment, cx| { - project_environment.default_environment(cx) - })? - .await - .unwrap_or_default(); - - // Merge manifest env and extra env - env.extend(base_env); - env.extend(extra_env); - - let cache_key = format!("{}/{}", extension_id, agent_id); - let dir = paths::external_agents_dir().join(&cache_key); - fs.create_dir(&dir).await?; - - // Determine platform key - let os = if cfg!(target_os = "macos") { - "darwin" - } else if cfg!(target_os = "linux") { - "linux" - } else if cfg!(target_os = "windows") { - "windows" - } else { - anyhow::bail!("unsupported OS"); - }; - - let arch = if cfg!(target_arch = "aarch64") { - "aarch64" - } else if cfg!(target_arch = "x86_64") { - "x86_64" - } else { - anyhow::bail!("unsupported architecture"); - }; - - let platform_key = format!("{}-{}", os, arch); - let target_config = targets.get(&platform_key).with_context(|| { - format!( - "no target specified for platform '{}'. Available platforms: {}", - platform_key, - targets - .keys() - .map(|k| k.as_str()) - .collect::>() - .join(", ") - ) - })?; - - let archive_url = &target_config.archive; - let version_dir = versioned_archive_cache_dir( - &dir, - version.as_ref().map(|version| version.as_ref()), - archive_url, - ); - - if !fs.is_dir(&version_dir).await { - // Determine SHA256 for verification - let sha256 = if let Some(provided_sha) = &target_config.sha256 { - // Use provided SHA256 - Some(provided_sha.clone()) - } else if let Some(github_archive) = github_release_archive_from_url(archive_url) { - // Try to fetch SHA256 from GitHub API - if let Ok(release) = ::http_client::github::get_release_by_tag_name( - &github_archive.repo_name_with_owner, - &github_archive.tag, - http_client.clone(), - ) - .await - { - // Find matching asset - if let Some(asset) = release - .assets - .iter() - .find(|a| a.name == github_archive.asset_name) - { - // Strip "sha256:" prefix if present - asset.digest.as_ref().map(|d| { - d.strip_prefix("sha256:") - .map(|s| s.to_string()) - .unwrap_or_else(|| d.clone()) - }) - } else { - None - } - } else { - None - } - } else { - None - }; - - let asset_kind = asset_kind_for_archive_url(archive_url)?; - - // Download and extract - ::http_client::github_download::download_server_binary( - &*http_client, - archive_url, - sha256.as_deref(), - &version_dir, - asset_kind, - ) - .await?; - } - - // Validate and resolve cmd path - let cmd = &target_config.cmd; - - let cmd_path = if cmd == "node" { - // Use Zed's managed Node.js runtime - node_runtime.binary_path().await? - } else { - if cmd.contains("..") { - anyhow::bail!("command path cannot contain '..': {}", cmd); - } - - if cmd.starts_with("./") || cmd.starts_with(".\\") { - // Relative to extraction directory - let cmd_path = version_dir.join(&cmd[2..]); - anyhow::ensure!( - fs.is_file(&cmd_path).await, - "Missing command {} after extraction", - cmd_path.to_string_lossy() - ); - cmd_path - } else { - // On PATH - anyhow::bail!("command must be relative (start with './'): {}", cmd); - } - }; - - let mut args = target_config.args.clone(); - args.extend(extra_args); - - let command = AgentServerCommand { - path: cmd_path, - args, - env: Some(env), - }; - - Ok(command) - }) - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } + Ok(()) } struct LocalRegistryArchiveAgent { @@ -1478,6 +1127,18 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { } }; + cx.background_spawn({ + let fs = fs.clone(); + let dir = dir.clone(); + let version_dir = version_dir.clone(); + async move { + remove_stale_versioned_archive_cache_dirs(fs, &dir, &version_dir) + .await + .log_err(); + } + }) + .detach(); + let mut args = target_config.args.clone(); args.extend(extra_args); @@ -1719,40 +1380,6 @@ pub enum CustomAgentServerSettings { /// Default: {} favorite_config_option_values: HashMap>, }, - Extension { - /// Additional environment variables to pass to the agent. - /// - /// Default: {} - env: HashMap, - /// The default mode to use for this agent. - /// - /// Note: Not only all agents support modes. - /// - /// Default: None - default_mode: Option, - /// The default model to use for this agent. - /// - /// This should be the model ID as reported by the agent. - /// - /// Default: None - default_model: Option, - /// The favorite models for this agent. - /// - /// Default: [] - favorite_models: Vec, - /// Default values for session config options. - /// - /// This is a map from config option ID to value ID. - /// - /// Default: {} - default_config_options: HashMap, - /// Favorited values for session config options. - /// - /// This is a map from config option ID to a list of favorited value IDs. - /// - /// Default: {} - favorite_config_option_values: HashMap>, - }, Registry { /// Additional environment variables to pass to the agent. /// @@ -1793,15 +1420,13 @@ impl CustomAgentServerSettings { pub fn command(&self) -> Option<&AgentServerCommand> { match self { CustomAgentServerSettings::Custom { command, .. } => Some(command), - CustomAgentServerSettings::Extension { .. } - | CustomAgentServerSettings::Registry { .. } => None, + CustomAgentServerSettings::Registry { .. } => None, } } pub fn default_mode(&self) -> Option<&str> { match self { CustomAgentServerSettings::Custom { default_mode, .. } - | CustomAgentServerSettings::Extension { default_mode, .. } | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(), } } @@ -1809,7 +1434,6 @@ impl CustomAgentServerSettings { pub fn default_model(&self) -> Option<&str> { match self { CustomAgentServerSettings::Custom { default_model, .. } - | CustomAgentServerSettings::Extension { default_model, .. } | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(), } } @@ -1819,9 +1443,6 @@ impl CustomAgentServerSettings { CustomAgentServerSettings::Custom { favorite_models, .. } - | CustomAgentServerSettings::Extension { - favorite_models, .. - } | CustomAgentServerSettings::Registry { favorite_models, .. } => favorite_models, @@ -1834,10 +1455,6 @@ impl CustomAgentServerSettings { default_config_options, .. } - | CustomAgentServerSettings::Extension { - default_config_options, - .. - } | CustomAgentServerSettings::Registry { default_config_options, .. @@ -1851,10 +1468,6 @@ impl CustomAgentServerSettings { favorite_config_option_values, .. } - | CustomAgentServerSettings::Extension { - favorite_config_option_values, - .. - } | CustomAgentServerSettings::Registry { favorite_config_option_values, .. @@ -1889,21 +1502,6 @@ impl From for CustomAgentServerSettings { default_config_options, favorite_config_option_values, }, - settings::CustomAgentServerSettings::Extension { - env, - default_mode, - default_model, - default_config_options, - favorite_models, - favorite_config_option_values, - } => CustomAgentServerSettings::Extension { - env, - default_mode, - default_model, - default_config_options, - favorite_models, - favorite_config_option_values, - }, settings::CustomAgentServerSettings::Registry { env, default_mode, @@ -1930,7 +1528,15 @@ impl settings::Settings for AllAgentServersSettings { agent_settings .0 .into_iter() - .map(|(k, v)| (k, v.into())) + .map(|(k, v)| { + ( + EXTENSION_TO_REGISTRY_IDS + .get(&k.as_str()) + .map(|v| v.to_string()) + .unwrap_or(k), + v.into(), + ) + }) .collect(), ) } @@ -1943,7 +1549,7 @@ mod tests { AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent, }; use crate::worktree_store::{WorktreeIdCounter, WorktreeStore}; - use gpui::{AppContext as _, TestAppContext}; + use gpui::TestAppContext; use node_runtime::NodeRuntime; use settings::Settings as _; @@ -2137,6 +1743,63 @@ mod tests { assert_ne!(slash_version_dir, colon_version_dir); } + #[gpui::test] + async fn test_remove_stale_versioned_archive_cache_dirs(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let base_dir = Path::new("/cache"); + + // FakeFs increments mtime on every create, so creation order is + // ascending mtime: v_old_1 < v_old_2 < other < v_not_a_dir < v_current < v_newer. + fs.insert_tree( + base_dir, + serde_json::json!({ + "v_old_1": {}, + "v_old_2": {}, + "other": {}, + }), + ) + .await; + fs.insert_file(base_dir.join("v_not_a_dir"), b"keep me".to_vec()) + .await; + let current_version_dir = base_dir.join("v_current"); + fs.create_dir(¤t_version_dir).await.unwrap(); + // Sibling that "finished extracting" after the current dir was cached. + fs.create_dir(&base_dir.join("v_newer")).await.unwrap(); + + remove_stale_versioned_archive_cache_dirs( + fs.clone() as Arc, + base_dir, + ¤t_version_dir, + ) + .await + .unwrap(); + + let mut remaining = fs + .read_dir(base_dir) + .await + .unwrap() + .filter_map(|entry| async move { entry.ok() }) + .map(|path| { + path.file_name() + .expect("entry has a name") + .to_string_lossy() + .into_owned() + }) + .collect::>() + .await; + remaining.sort(); + + assert_eq!( + remaining, + vec![ + "other".to_string(), + "v_current".to_string(), + "v_newer".to_string(), + "v_not_a_dir".to_string(), + ] + ); + } + #[gpui::test] fn test_version_change_sends_notification(cx: &mut TestAppContext) { init_test_settings(cx); diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index de2e1e3ceff..35651a7ff4b 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -27,8 +27,8 @@ use util::{ResultExt as _, rel_path::RelPath}; use crate::{ DisableAiSettings, Project, - project_settings::{ContextServerSettings, ProjectSettings}, - worktree_store::WorktreeStore, + project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings}, + worktree_store::{WorktreeStore, WorktreeStoreEvent}, }; /// Maximum timeout for context server requests @@ -56,6 +56,11 @@ pub enum ContextServerStatus { /// The server returned 401 and OAuth authorization is needed. The UI /// should show an "Authenticate" button. AuthRequired, + /// The server has a pre-registered OAuth client_id, but a client_secret + /// is needed and not available in settings or the keychain. + ClientSecretRequired { + error: Option>, + }, /// The OAuth browser flow is in progress — the user has been redirected /// to the authorization server and we're waiting for the callback. Authenticating, @@ -69,6 +74,11 @@ impl ContextServerStatus { ContextServerState::Stopped { .. } => ContextServerStatus::Stopped, ContextServerState::Error { error, .. } => ContextServerStatus::Error(error.clone()), ContextServerState::AuthRequired { .. } => ContextServerStatus::AuthRequired, + ContextServerState::ClientSecretRequired { error, .. } => { + ContextServerStatus::ClientSecretRequired { + error: error.clone(), + } + } ContextServerState::Authenticating { .. } => ContextServerStatus::Authenticating, } } @@ -100,6 +110,14 @@ enum ContextServerState { configuration: Arc, discovery: Arc, }, + /// A pre-registered client_id is configured but no client_secret was found + /// in settings or the keychain. + ClientSecretRequired { + server: Arc, + configuration: Arc, + discovery: Arc, + error: Option>, + }, /// The OAuth browser flow is in progress. The user has been redirected /// to the authorization server and we're waiting for the callback. Authenticating { @@ -117,6 +135,7 @@ impl ContextServerState { | ContextServerState::Stopped { server, .. } | ContextServerState::Error { server, .. } | ContextServerState::AuthRequired { server, .. } + | ContextServerState::ClientSecretRequired { server, .. } | ContextServerState::Authenticating { server, .. } => server.clone(), } } @@ -128,6 +147,7 @@ impl ContextServerState { | ContextServerState::Stopped { configuration, .. } | ContextServerState::Error { configuration, .. } | ContextServerState::AuthRequired { configuration, .. } + | ContextServerState::ClientSecretRequired { configuration, .. } | ContextServerState::Authenticating { configuration, .. } => configuration.clone(), } } @@ -148,6 +168,7 @@ pub enum ContextServerConfiguration { url: url::Url, headers: HashMap, timeout: Option, + oauth: Option, }, } @@ -228,12 +249,14 @@ impl ContextServerConfiguration { url, headers: auth, timeout, + oauth, } => { let url = url::Url::parse(&url).log_err()?; Some(ContextServerConfiguration::Http { url, headers: auth, timeout, + oauth, }) } } @@ -454,6 +477,16 @@ impl ContextServerStore { this.available_context_servers_changed(cx); } })); + subscriptions.push(cx.subscribe(&worktree_store, |this, _store, event, cx| { + if matches!( + event, + WorktreeStoreEvent::WorktreeAdded(_) + | WorktreeStoreEvent::WorktreeRemoved(_, _) + ) && !DisableAiSettings::get_global(cx).disable_ai + { + this.available_context_servers_changed(cx); + } + })); } let ai_disabled = DisableAiSettings::get_global(cx).disable_ai; @@ -831,6 +864,7 @@ impl ContextServerStore { url, headers, timeout, + oauth: _, } => { let transport = HttpTransport::new_with_token_provider( cx.http_client(), @@ -997,6 +1031,15 @@ impl ContextServerStore { _ => anyhow::bail!("Server is not in AuthRequired state"), }; + let needs_keychain_check = match configuration.as_ref() { + ContextServerConfiguration::Http { + url, + oauth: Some(oauth_settings), + .. + } if oauth_settings.client_secret.is_none() => Some(url.clone()), + _ => None, + }; + let id = id.clone(); let task = cx.spawn({ @@ -1004,6 +1047,33 @@ impl ContextServerStore { let server = server.clone(); let configuration = configuration.clone(); async move |this, cx| { + if let Some(server_url) = needs_keychain_check { + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); + let has_keychain_secret = + Self::load_client_secret(&credentials_provider, &server_url, cx) + .await + .ok() + .flatten() + .is_some(); + + if !has_keychain_secret { + this.update(cx, |this, cx| { + this.update_server_state( + id.clone(), + ContextServerState::ClientSecretRequired { + server, + configuration, + discovery, + error: None, + }, + cx, + ); + }) + .log_err(); + return; + } + } + let result = Self::run_oauth_flow( this.clone(), id.clone(), @@ -1015,15 +1085,13 @@ impl ContextServerStore { if let Err(err) = &result { log::error!("{} OAuth authentication failed: {:?}", id, err); - // Transition back to AuthRequired so the user can retry - // rather than landing in a terminal Error state. this.update(cx, |this, cx| { this.update_server_state( id.clone(), - ContextServerState::AuthRequired { + ContextServerState::Error { server, configuration, - discovery, + error: format!("{err:#}").into(), }, cx, ) @@ -1046,6 +1114,121 @@ impl ContextServerStore { Ok(()) } + /// Store the client secret and proceed with authentication. + pub fn submit_client_secret( + &mut self, + id: &ContextServerId, + secret: String, + cx: &mut Context, + ) -> Result<()> { + let state = self.servers.get(id).context("Context server not found")?; + + let (server, configuration, discovery) = match state { + ContextServerState::ClientSecretRequired { + server, + configuration, + discovery, + .. + } => (server.clone(), configuration.clone(), discovery.clone()), + _ => anyhow::bail!("Server is not in ClientSecretRequired state"), + }; + + let server_url = match configuration.as_ref() { + ContextServerConfiguration::Http { url, .. } => url.clone(), + _ => anyhow::bail!("OAuth only supported for HTTP servers"), + }; + + let id = id.clone(); + + let task = cx.spawn({ + let id = id.clone(); + let server = server.clone(); + let configuration = configuration.clone(); + async move |this, cx| { + // Store the secret if non-empty (empty means public client / skip). + if !secret.is_empty() { + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); + if let Err(err) = + Self::store_client_secret(&credentials_provider, &server_url, &secret, cx) + .await + { + log::error!( + "{} failed to store client secret in keychain: {:?}", + id, + err + ); + } + } + + let result = Self::run_oauth_flow( + this.clone(), + id.clone(), + discovery.clone(), + configuration.clone(), + cx, + ) + .await; + + if let Err(err) = &result { + log::error!("{} OAuth authentication failed: {:?}", id, err); + + let is_bad_client_credentials = err + .downcast_ref::() + .is_some_and(|e| e.error == "unauthorized_client"); + + if is_bad_client_credentials { + // Clear the bad secret from the keychain so the user + // gets a fresh prompt. + let credentials_provider = + cx.update(|cx| zed_credentials_provider::global(cx)); + Self::clear_client_secret(&credentials_provider, &server_url, cx) + .await + .log_err(); + + this.update(cx, |this, cx| { + this.update_server_state( + id.clone(), + ContextServerState::ClientSecretRequired { + server, + configuration, + discovery, + error: Some(format!("{err:#}").into()), + }, + cx, + ); + }) + .log_err(); + } else { + this.update(cx, |this, cx| { + this.update_server_state( + id.clone(), + ContextServerState::Error { + server, + configuration, + error: format!("{err:#}").into(), + }, + cx, + ) + }) + .log_err(); + } + } + } + }); + + self.update_server_state( + id, + ContextServerState::Authenticating { + server, + configuration, + _task: task, + }, + cx, + ); + + Ok(()) + } + async fn run_oauth_flow( this: WeakEntity, id: ContextServerId, @@ -1073,10 +1256,30 @@ impl ContextServerStore { _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"), }; - let client_registration = - oauth::resolve_client_registration(&http_client, &discovery, &redirect_uri) + let client_registration = match configuration.as_ref() { + ContextServerConfiguration::Http { + url, + oauth: Some(oauth_settings), + .. + } => { + // Pre-registered client. Resolve the secret from settings, then keychain. + let client_secret = if oauth_settings.client_secret.is_some() { + oauth_settings.client_secret.clone() + } else { + Self::load_client_secret(&credentials_provider, url, cx) + .await + .ok() + .flatten() + }; + oauth::OAuthClientRegistration { + client_id: oauth_settings.client_id.clone(), + client_secret, + } + } + _ => oauth::resolve_client_registration(&http_client, &discovery, &redirect_uri) .await - .context("Failed to resolve OAuth client registration")?; + .context("Failed to resolve OAuth client registration")?, + }; let auth_url = oauth::build_authorization_url( &discovery.auth_server_metadata, @@ -1106,6 +1309,7 @@ impl ContextServerStore { &redirect_uri, &pkce.verifier, &resource, + client_registration.client_secret.as_deref(), ) .await .context("Failed to exchange authorization code for tokens")?; @@ -1139,6 +1343,7 @@ impl ContextServerStore { url, headers, timeout, + oauth: _, } => { let transport = HttpTransport::new_with_token_provider( http_client.clone(), @@ -1212,6 +1417,46 @@ impl ContextServerStore { format!("mcp-oauth:{}", oauth::canonical_server_uri(server_url)) } + fn client_secret_keychain_key(server_url: &url::Url) -> String { + format!( + "mcp-oauth-client-secret:{}", + oauth::canonical_server_uri(server_url) + ) + } + + async fn load_client_secret( + credentials_provider: &Arc, + server_url: &url::Url, + cx: &AsyncApp, + ) -> Result> { + let key = Self::client_secret_keychain_key(server_url); + match credentials_provider.read_credentials(&key, cx).await? { + Some((_username, secret_bytes)) => Ok(Some(String::from_utf8(secret_bytes)?)), + None => Ok(None), + } + } + + pub async fn store_client_secret( + credentials_provider: &Arc, + server_url: &url::Url, + secret: &str, + cx: &AsyncApp, + ) -> Result<()> { + let key = Self::client_secret_keychain_key(server_url); + credentials_provider + .write_credentials(&key, "mcp-oauth-client-secret", secret.as_bytes(), cx) + .await + } + + async fn clear_client_secret( + credentials_provider: &Arc, + server_url: &url::Url, + cx: &AsyncApp, + ) -> Result<()> { + let key = Self::client_secret_keychain_key(server_url); + credentials_provider.delete_credentials(&key, cx).await + } + /// Log out of an OAuth-authenticated MCP server: clear the stored OAuth /// session from the keychain and stop the server. pub fn logout_server(&mut self, id: &ContextServerId, cx: &mut Context) -> Result<()> { @@ -1231,6 +1476,11 @@ impl ContextServerStore { if let Err(err) = Self::clear_session(&credentials_provider, &server_url, &cx).await { log::error!("{} failed to clear OAuth session: {}", id, err); } + // Also clear any client secret so the user gets a fresh prompt on + // the next authentication attempt. + Self::clear_client_secret(&credentials_provider, &server_url, &cx) + .await + .log_err(); // Trigger server recreation so the next start uses a fresh // transport without the old (now-invalidated) token provider. this.update(cx, |this, cx| { @@ -1477,6 +1727,34 @@ async fn resolve_start_failure( match context_server::oauth::discover(&http_client, &server_url, www_authenticate).await { Ok(discovery) => { + use context_server::oauth::{ + ClientRegistrationStrategy, determine_registration_strategy, + }; + + let has_preregistered_client_id = matches!( + configuration.as_ref(), + ContextServerConfiguration::Http { oauth: Some(_), .. } + ); + + let strategy = determine_registration_strategy(&discovery.auth_server_metadata); + + if matches!(strategy, ClientRegistrationStrategy::Unavailable) + && !has_preregistered_client_id + { + log::error!( + "{id} authorization server supports neither CIMD nor DCR, \ + and no pre-registered client_id is configured" + ); + return ContextServerState::Error { + configuration, + server, + error: "Authorization server supports neither CIMD nor DCR. \ + Configure a pre-registered client_id in your settings \ + under the \"oauth\" key." + .into(), + }; + } + log::info!( "{id} requires OAuth authorization (auth server: {})", discovery.auth_server_metadata.issuer, diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 39578eaf8f0..fc5f56395cc 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -3145,7 +3145,7 @@ async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Resul async fn install_latest_version(dir: PathBuf, node: NodeRuntime) -> Result { let temp_dir = tempfile::tempdir().context("creating temporary directory")?; - node.npm_install_packages(temp_dir.path(), &[(PACKAGE_NAME, "latest")]) + node.npm_install_latest_packages(temp_dir.path(), &[PACKAGE_NAME]) .await .context("installing latest companion package")?; let version = node diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 9f97f829b0c..f5a62baa4c3 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -34,11 +34,11 @@ use git::{ blame::Blame, parse_git_remote_url, repository::{ - Branch, CommitData, CommitDetails, CommitDiff, CommitFile, CommitOptions, - CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate, GitRepository, - GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, - RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, - Worktree as GitWorktree, delete_branch_flag, + Branch, BranchesScanResult, CommitData, CommitDetails, CommitDiff, CommitFile, + CommitOptions, CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate, + GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, + PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, + UpstreamTrackingStatus, Worktree as GitWorktree, delete_branch_flag, }, stash::{GitStash, StashEntry}, status::{ @@ -302,6 +302,7 @@ pub struct RepositorySnapshot { pub id: RepositoryId, pub statuses_by_path: SumTree, pub work_directory_abs_path: Arc, + pub dot_git_abs_path: Arc, /// Absolute path to the directory holding this worktree's Git state. /// /// For a linked worktree this is the worktree-specific directory under the @@ -317,6 +318,7 @@ pub struct RepositorySnapshot { pub path_style: PathStyle, pub branch: Option, pub branch_list: Arc<[Branch]>, + pub branch_list_error: Option, pub head_commit: Option, pub scan_id: u64, pub merge: MergeDetails, @@ -380,6 +382,7 @@ pub struct Repository { // and that should be examined during the next status scan. paths_needing_status_update: Vec>, job_sender: mpsc::UnboundedSender, + _worker_task: Task<()>, active_jobs: HashMap, job_debug_queue: job_debug_queue::GitJobDebugQueue, pending_ops: SumTree, @@ -390,14 +393,6 @@ pub struct Repository { initial_graph_data: HashMap<(LogSource, LogOrder), InitialGitGraphData>, commit_data_handler: CommitDataHandlerState, commit_data: HashMap, - refetch_repo_state: Arc< - dyn Fn( - &mut Context, - ) -> ( - mpsc::UnboundedSender, - Shared>>, - ), - >, } impl std::ops::Deref for Repository { @@ -546,14 +541,36 @@ impl GitStore { let (mut watcher, _) = watcher.await; while let Some(_) = watcher.next().await { let Ok(_) = this.update(cx, |this, cx| { - for repo in this.repositories.values() { - repo.update(cx, |this, cx| { - if this.job_sender.is_closed() { - let (job_sender, state) = (this.refetch_repo_state)(cx); - this.repository_state = state; - this.job_sender = job_sender; - this.schedule_scan(None, cx); - } + let GitStoreState::Local { + project_environment, + fs, + .. + } = &this.state + else { + return; + }; + let project_environment = project_environment.downgrade(); + let fs = fs.clone(); + let repositories_to_respawn = this + .repositories + .iter() + .filter_map(|(repository_id, repo)| { + repo.read(cx) + .job_sender + .is_closed() + .then_some((*repository_id, repo.clone())) + }) + .collect::>(); + for (repository_id, repo) in repositories_to_respawn { + let is_trusted = this.repository_is_trusted(repository_id, cx); + repo.update(cx, |repo, cx| { + repo.respawn_local_worker( + project_environment.clone(), + fs.clone(), + is_trusted, + cx, + ); + repo.schedule_scan(None, cx); }) } cx.emit(GitStoreEvent::GlobalConfigurationUpdated); @@ -1498,6 +1515,7 @@ impl GitStore { else { return; }; + log::debug!("received worktree update for repositories: {changed_repos:?}"); self.update_repositories_from_worktree( *worktree_id, project_environment.clone(), @@ -1597,6 +1615,21 @@ impl GitStore { cx.emit(GitStoreEvent::JobsUpdated) } + fn repository_is_trusted(&self, repository_id: RepositoryId, cx: &mut Context) -> bool { + let Some(worktree_ids) = self.worktree_ids.get(&repository_id) else { + return false; + }; + let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) else { + return false; + }; + + worktree_ids.iter().any(|worktree_id| { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.can_trust(&self.worktree_store, *worktree_id, cx) + }) + }) + } + /// Update our list of repositories and schedule git scans in response to a notification from a worktree, fn update_repositories_from_worktree( &mut self, @@ -1626,10 +1659,44 @@ impl GitStore { .entry(repo_id) .or_insert_with(HashSet::new) .insert(worktree_id); - existing.update(cx, |existing, cx| { - existing.snapshot.work_directory_abs_path = new_work_directory_abs_path; - existing.schedule_scan(updates_tx.clone(), cx); - }); + let path_changed = update.old_work_directory_abs_path.as_ref() + != update.new_work_directory_abs_path.as_ref(); + if path_changed + && let Some(dot_git_abs_path) = update.dot_git_abs_path.clone() + && let Some(repository_dir_abs_path) = + update.repository_dir_abs_path.clone() + && let Some(common_dir_abs_path) = update.common_dir_abs_path.clone() + { + let is_trusted = TrustedWorktrees::try_get_global(cx) + .map(|trusted_worktrees| { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.can_trust( + &self.worktree_store, + worktree_id, + cx, + ) + }) + }) + .unwrap_or(false); + existing.update(cx, |existing, cx| { + existing.reinitialize_local_backend( + new_work_directory_abs_path, + dot_git_abs_path, + repository_dir_abs_path, + common_dir_abs_path, + project_environment.downgrade(), + fs.clone(), + is_trusted, + cx, + ); + existing.schedule_scan(updates_tx.clone(), cx); + }); + } else { + existing.update(cx, |existing, cx| { + existing.snapshot.work_directory_abs_path = new_work_directory_abs_path; + existing.schedule_scan(updates_tx.clone(), cx); + }); + } } else { if let Some(worktree_ids) = self.worktree_ids.get_mut(&repo_id) { worktree_ids.remove(&worktree_id); @@ -2876,15 +2943,17 @@ impl GitStore { let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; - let branches = repository_handle + let branches_scan = repository_handle .update(&mut cx, |repository_handle, _| repository_handle.branches()) .await??; Ok(proto::GitBranchesResponse { - branches: branches + branches: branches_scan + .branches .into_iter() .map(|branch| branch_to_proto(&branch)) .collect::>(), + error: branches_scan.error.map(|error| error.to_string()), }) } async fn handle_get_default_branch( @@ -4103,11 +4172,14 @@ impl RepositorySnapshot { id: RepositoryId, work_directory_abs_path: Arc, repository_dir_abs_path: Option>, + dot_git_abs_path: Option>, common_dir_abs_path: Option>, path_style: PathStyle, ) -> Self { let repository_dir_abs_path = repository_dir_abs_path.unwrap_or_else(|| work_directory_abs_path.join(".git").into()); + let dot_git_abs_path = + dot_git_abs_path.unwrap_or_else(|| work_directory_abs_path.join(".git").into()); let common_dir_abs_path = common_dir_abs_path.unwrap_or_else(|| repository_dir_abs_path.clone()); @@ -4115,10 +4187,12 @@ impl RepositorySnapshot { id, statuses_by_path: Default::default(), repository_dir_abs_path, + dot_git_abs_path, common_dir_abs_path, work_directory_abs_path, branch: None, branch_list: Arc::from([]), + branch_list_error: None, head_commit: None, scan_id: 0, merge: Default::default(), @@ -4134,6 +4208,10 @@ impl RepositorySnapshot { proto::UpdateRepository { branch_summary: self.branch.as_ref().map(branch_to_proto), branch_list: self.branch_list.iter().map(branch_to_proto).collect(), + branch_list_error: self + .branch_list_error + .as_ref() + .map(|error| error.to_string()), head_commit_details: self.head_commit.as_ref().map(commit_details_to_proto), updated_statuses: self .statuses_by_path @@ -4221,6 +4299,10 @@ impl RepositorySnapshot { proto::UpdateRepository { branch_summary: self.branch.as_ref().map(branch_to_proto), branch_list: self.branch_list.iter().map(branch_to_proto).collect(), + branch_list_error: self + .branch_list_error + .as_ref() + .map(|error| error.to_string()), head_commit_details: self.head_commit.as_ref().map(commit_details_to_proto), updated_statuses, removed_statuses, @@ -4393,7 +4475,7 @@ impl MergeDetails { &mut self, backend: &Arc, current_conflicted_paths: Vec, - ) -> Result { + ) -> bool { log::debug!("load merge details"); self.message = backend.merge_message().await.map(SharedString::from); let heads = backend @@ -4434,7 +4516,7 @@ impl MergeDetails { keep }); - Ok(conflicts_changed) + conflicts_changed } } @@ -4464,6 +4546,66 @@ impl Repository { .cloned() } + fn respawn_local_worker( + &mut self, + project_environment: WeakEntity, + fs: Arc, + is_trusted: bool, + cx: &mut Context, + ) { + let work_directory_abs_path = self.snapshot.work_directory_abs_path.clone(); + let dot_git_abs_path = self.snapshot.dot_git_abs_path.clone(); + + let state = cx + .spawn(async move |_, cx| { + LocalRepositoryState::new( + work_directory_abs_path, + dot_git_abs_path, + project_environment, + fs, + is_trusted, + cx, + ) + .await + .map_err(|err| err.to_string()) + }) + .shared(); + self.job_sender.close_channel(); + self._worker_task = Task::ready(()); + self.active_jobs.clear(); + self.job_debug_queue + .mark_unfinished_complete(job_debug_queue::CompletedJobStatus::Skipped); + cx.notify(); + + let (job_sender, worker_task) = Repository::spawn_local_git_worker(state.clone(), cx); + self.job_sender = job_sender; + self._worker_task = worker_task; + self.repository_state = cx + .spawn(async move |_, _| { + let state = state.await?; + Ok(RepositoryState::Local(state)) + }) + .shared(); + } + + fn reinitialize_local_backend( + &mut self, + work_directory_abs_path: Arc, + dot_git_abs_path: Arc, + repository_dir_abs_path: Arc, + common_dir_abs_path: Arc, + project_environment: WeakEntity, + fs: Arc, + is_trusted: bool, + cx: &mut Context, + ) { + self.snapshot.work_directory_abs_path = work_directory_abs_path; + self.snapshot.dot_git_abs_path = dot_git_abs_path; + self.snapshot.repository_dir_abs_path = repository_dir_abs_path; + self.snapshot.common_dir_abs_path = common_dir_abs_path; + self.respawn_local_worker(project_environment, fs, is_trusted, cx); + } + fn local( id: RepositoryId, work_directory_abs_path: Arc, @@ -4478,64 +4620,35 @@ impl Repository { ) -> Self { let snapshot = RepositorySnapshot::empty( id, - work_directory_abs_path.clone(), + work_directory_abs_path, Some(repository_dir_abs_path), + Some(dot_git_abs_path), Some(common_dir_abs_path), PathStyle::local(), ); - let refetch_repo_state = Arc::new(move |cx: &mut Context| { - let work_directory_abs_path = work_directory_abs_path.clone(); - let dot_git_abs_path = dot_git_abs_path.clone(); - let project_environment = project_environment.clone(); - let fs = fs.clone(); - let state = cx - .spawn(async move |_, cx| { - LocalRepositoryState::new( - work_directory_abs_path, - dot_git_abs_path, - project_environment, - fs, - is_trusted, - cx, - ) - .await - .map_err(|err| err.to_string()) - }) - .shared(); - let job_sender = Repository::spawn_local_git_worker(state.clone(), cx); - let state = cx - .spawn(async move |_, _| { - let state = state.await?; - Ok(RepositoryState::Local(state)) - }) - .shared(); - - (job_sender, state) - }); - - let (job_sender, state) = (refetch_repo_state)(cx); - cx.subscribe_self(Self::handle_subscribe_self).detach(); - - Repository { + let mut repo = Repository { this: cx.weak_entity(), git_store, snapshot, pending_ops: Default::default(), - repository_state: state, + repository_state: Task::ready(Err("not yet initialized".into())).shared(), + _worker_task: Task::ready(()), commit_message_buffer: None, askpass_delegates: Default::default(), paths_needing_status_update: Default::default(), latest_askpass_id: 0, - job_sender, + job_sender: mpsc::unbounded().0, job_id: 0, active_jobs: Default::default(), job_debug_queue: job_debug_queue::GitJobDebugQueue::new(), initial_graph_data: Default::default(), commit_data: Default::default(), commit_data_handler: CommitDataHandlerState::Closed, - refetch_repo_state, - } + }; + repo.respawn_local_worker(project_environment, fs, is_trusted, cx); + cx.subscribe_self(Self::handle_subscribe_self).detach(); + repo } fn remote( @@ -4553,21 +4666,14 @@ impl Repository { id, work_directory_abs_path, repository_dir_abs_path, + None, common_dir_abs_path, path_style, ); - let refetch_repo_state = Arc::new(move |cx: &mut Context| { - let repository_state = RemoteRepositoryState { - project_id, - client: client.clone(), - }; - let job_sender = Self::spawn_remote_git_worker(repository_state.clone(), cx); - let repository_state = - Task::ready(Ok(RepositoryState::Remote(repository_state))).shared(); - (job_sender, repository_state) - }); - let (job_sender, repository_state) = (refetch_repo_state)(cx); + let repository_state = RemoteRepositoryState { project_id, client }; + let (job_sender, worker_task) = Self::spawn_remote_git_worker(repository_state.clone(), cx); + let repository_state = Task::ready(Ok(RepositoryState::Remote(repository_state))).shared(); cx.subscribe_self(Self::handle_subscribe_self).detach(); Self { @@ -4578,6 +4684,7 @@ impl Repository { pending_ops: Default::default(), paths_needing_status_update: Default::default(), job_sender, + _worker_task: worker_task, repository_state, askpass_delegates: Default::default(), latest_askpass_id: 0, @@ -4587,7 +4694,6 @@ impl Repository { initial_graph_data: Default::default(), commit_data: Default::default(), commit_data_handler: CommitDataHandlerState::Closed, - refetch_repo_state, } } @@ -6515,12 +6621,23 @@ impl Repository { .await; // TODO would be nice to not have to do this manually if result.is_ok() { - let branches = backend.branches().await?; - let branch = branches.into_iter().find(|branch| branch.is_head); + let branches_scan = backend.branches().await?; + let branch_list_error = branches_scan.error; + let branch_list: Arc<[Branch]> = branches_scan.branches.into(); + let branch = branch_list.iter().find(|branch| branch.is_head).cloned(); log::info!("head branch after scan is {branch:?}"); let snapshot = this.update(&mut cx, |this, cx| { + let branch_list_changed = + *branch_list != *this.snapshot.branch_list; + let branch_list_error_changed = + this.snapshot.branch_list_error != branch_list_error; this.snapshot.branch = branch; + this.snapshot.branch_list = branch_list; + this.snapshot.branch_list_error = branch_list_error; cx.emit(RepositoryEvent::HeadChanged); + if branch_list_changed || branch_list_error_changed { + cx.emit(RepositoryEvent::BranchListChanged); + } this.snapshot.clone() })?; if let Some(updates_tx) = updates_tx { @@ -6819,7 +6936,7 @@ impl Repository { }) } - pub fn branches(&mut self) -> oneshot::Receiver>> { + pub fn branches(&mut self) -> oneshot::Receiver> { let id = self.id; self.send_job("branches", None, move |repo, _| async move { match repo { @@ -6840,7 +6957,10 @@ impl Repository { .map(|branch| proto_to_branch(&branch)) .collect(); - Ok(branches) + Ok(BranchesScanResult { + branches, + error: response.error.map(SharedString::from), + }) } } }) @@ -7607,10 +7727,14 @@ impl Repository { if update.is_last_update { let new_branch_list: Arc<[Branch]> = update.branch_list.iter().map(proto_to_branch).collect(); - if *self.snapshot.branch_list != *new_branch_list { + let new_branch_list_error = update.branch_list_error.map(SharedString::from); + if *self.snapshot.branch_list != *new_branch_list + || self.snapshot.branch_list_error != new_branch_list_error + { cx.emit(RepositoryEvent::BranchListChanged); } self.snapshot.branch_list = new_branch_list; + self.snapshot.branch_list_error = new_branch_list_error; } // We don't store any merge head state for downstream projects; the upstream @@ -7776,7 +7900,7 @@ impl Repository { let RepositoryState::Local(LocalRepositoryState { backend, .. }) = state else { bail!("not a local repository") }; - let snapshot = compute_snapshot(this.clone(), backend.clone(), &mut cx).await?; + let snapshot = compute_snapshot(this.clone(), backend.clone(), &mut cx).await; this.update(&mut cx, |this, cx| { this.clear_pending_ops(cx); }); @@ -7793,11 +7917,13 @@ impl Repository { fn spawn_local_git_worker( state: Shared>>, cx: &mut Context, - ) -> mpsc::UnboundedSender { + ) -> (mpsc::UnboundedSender, Task<()>) { let (job_tx, mut job_rx) = mpsc::unbounded::(); - cx.spawn(async move |this, cx| { - let state = state.await.map_err(|err| anyhow::anyhow!(err))?; + let worker_task = cx.spawn(async move |this, cx| { + let Some(state) = state.await.log_err() else { + return; + }; if let Some(git_hosting_provider_registry) = cx.update(|cx| GitHostingProviderRegistry::try_global(cx)) { @@ -7837,55 +7963,56 @@ impl Repository { break; } } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + }); - job_tx + (job_tx, worker_task) } fn spawn_remote_git_worker( state: RemoteRepositoryState, cx: &mut Context, - ) -> mpsc::UnboundedSender { + ) -> (mpsc::UnboundedSender, Task<()>) { let (job_tx, mut job_rx) = mpsc::unbounded::(); - cx.spawn(async move |this, cx| { - let state = RepositoryState::Remote(state); - let mut jobs = VecDeque::new(); - loop { - while let Ok(next_job) = job_rx.try_recv() { - jobs.push_back(next_job); - } - - if let Some(job) = jobs.pop_front() { - if let Some(current_key) = &job.key - && jobs - .iter() - .any(|other_job| other_job.key.as_ref() == Some(current_key)) - { - let skipped_job_id = job.id; - this.update(cx, |repo, _| { - repo.job_debug_queue.mark_complete( - skipped_job_id, - job_debug_queue::CompletedJobStatus::Skipped, - ); - }) - .ok(); - continue; + let worker_task = cx.spawn(async move |this, cx| { + let result: Result<()> = async { + let state = RepositoryState::Remote(state); + let mut jobs = VecDeque::new(); + loop { + while let Ok(next_job) = job_rx.try_recv() { + jobs.push_back(next_job); } - (job.job)(state.clone(), cx).await; - } else if let Some(job) = job_rx.next().await { - jobs.push_back(job); - } else { - break; - } - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - job_tx + if let Some(job) = jobs.pop_front() { + if let Some(current_key) = &job.key + && jobs + .iter() + .any(|other_job| other_job.key.as_ref() == Some(current_key)) + { + let skipped_job_id = job.id; + this.update(cx, |repo, _| { + repo.job_debug_queue.mark_complete( + skipped_job_id, + job_debug_queue::CompletedJobStatus::Skipped, + ); + }) + .ok(); + continue; + } + (job.job)(state.clone(), cx).await; + } else if let Some(job) = job_rx.next().await { + jobs.push_back(job); + } else { + break; + } + } + anyhow::Ok(()) + } + .await; + result.log_err(); + }); + + (job_tx, worker_task) } fn load_staged_text( @@ -9142,7 +9269,9 @@ async fn compute_snapshot( this: Entity, backend: Arc, cx: &mut AsyncApp, -) -> Result { +) -> RepositorySnapshot { + log::debug!("starting compute snapshot"); + let (id, work_directory_abs_path, prev_snapshot) = this.update(cx, |this, _| { this.paths_needing_status_update.clear(); ( @@ -9152,28 +9281,31 @@ async fn compute_snapshot( ) }); + let branches_future = { + let backend = backend.clone(); + async move { backend.branches().await.log_err().unwrap_or_default() } + }; let head_commit_future = { let backend = backend.clone(); async move { - Ok(match backend.head_sha().await { + match backend.head_sha().await { Some(head_sha) => backend.show(head_sha).await.log_err(), None => None, - }) + } } }; - let (branches, head_commit, all_worktrees) = cx - .background_spawn({ - let backend = backend.clone(); - async move { - futures::future::try_join3( - backend.branches(), - head_commit_future, - backend.worktrees(), - ) - .await - } - }) - .await?; + let worktrees_future = { + let backend = backend.clone(); + async move { backend.worktrees().await.log_err().unwrap_or_default() } + }; + let (branches, head_commit, all_worktrees) = + futures::future::join3(branches_future, head_commit_future, worktrees_future).await; + log::debug!("fetched branches, head commit, worktrees"); + + let BranchesScanResult { + branches, + error: branch_list_error, + } = branches; let branch = branches.iter().find(|branch| branch.is_head).cloned(); let branch_list: Arc<[Branch]> = branches.into(); @@ -9182,25 +9314,16 @@ async fn compute_snapshot( .filter(|wt| wt.path != *work_directory_abs_path) .collect(); - let (remote_origin_url, remote_upstream_url) = cx - .background_spawn({ - let backend = backend.clone(); - async move { - Ok::<_, anyhow::Error>( - futures::future::join( - backend.remote_url("origin"), - backend.remote_url("upstream"), - ) - .await, - ) - } - }) - .await?; + let remote_origin_url = backend.remote_url("origin").await; + let remote_upstream_url = backend.remote_url("upstream").await; + + log::debug!("fetched remotes"); let snapshot = this.update(cx, |this, cx| { let head_changed = branch != this.snapshot.branch || head_commit != this.snapshot.head_commit; let branch_list_changed = *branch_list != *this.snapshot.branch_list; + let branch_list_error_changed = branch_list_error != this.snapshot.branch_list_error; let worktrees_changed = *linked_worktrees != *this.snapshot.linked_worktrees; this.snapshot = RepositorySnapshot { @@ -9208,6 +9331,7 @@ async fn compute_snapshot( work_directory_abs_path, branch, branch_list: branch_list.clone(), + branch_list_error, head_commit, remote_origin_url, remote_upstream_url, @@ -9220,7 +9344,7 @@ async fn compute_snapshot( cx.emit(RepositoryEvent::HeadChanged); } - if branch_list_changed { + if branch_list_changed || branch_list_error_changed { cx.emit(RepositoryEvent::BranchListChanged); } @@ -9231,31 +9355,37 @@ async fn compute_snapshot( this.snapshot.clone() }); - let (statuses, diff_stats, stash_entries) = cx - .background_spawn({ - let backend = backend.clone(); - let snapshot = snapshot.clone(); - async move { - let diff_stat_future: BoxFuture<'_, Result> = - if snapshot.head_commit.is_some() { - backend.diff_stat(&[]) - } else { - future::ready(Ok(status::GitDiffStat { - entries: Arc::default(), - })) - .boxed() - }; - futures::future::try_join3( - backend.status(&[RepoPath::from_rel_path( - &RelPath::new(".".as_ref(), PathStyle::local()).unwrap(), - )]), - diff_stat_future, - backend.stash_entries(), - ) + let statuses_future = { + let backend = backend.clone(); + async move { + backend + .status(&[RepoPath::from_rel_path( + &RelPath::new(".".as_ref(), PathStyle::local()).unwrap(), + )]) .await + .log_err() + .unwrap_or_default() + } + }; + let diff_stat_future = { + let snapshot = snapshot.clone(); + let backend = backend.clone(); + async move { + if snapshot.head_commit.is_some() { + backend.diff_stat(&[]).await.log_err().unwrap_or_default() + } else { + Default::default() } - }) - .await?; + } + }; + let stash_entries_future = { + let backend = backend.clone(); + async move { backend.stash_entries().await.log_err().unwrap_or_default() } + }; + + let (statuses, diff_stats, stash_entries) = + futures::future::join3(statuses_future, diff_stat_future, stash_entries_future).await; + log::debug!("fetched statuses, diff stats, stash entries"); let diff_stat_map: HashMap<&RepoPath, DiffStat> = diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect(); @@ -9274,20 +9404,19 @@ async fn compute_snapshot( (), ); - let merge_details = cx + let (merge_details, conflicts_changed) = cx .background_spawn({ let backend = backend.clone(); let mut merge_details = snapshot.merge.clone(); async move { - let conflicts_changed = merge_details.update(&backend, conflicted_paths).await?; - Ok::<_, anyhow::Error>((merge_details, conflicts_changed)) + let conflicts_changed = merge_details.update(&backend, conflicted_paths).await; + (merge_details, conflicts_changed) } }) - .await?; - let (merge_details, conflicts_changed) = merge_details; + .await; log::debug!("new merge details: {merge_details:?}"); - Ok(this.update(cx, |this, cx| { + this.update(cx, |this, cx| { if conflicts_changed || statuses_by_path != this.snapshot.statuses_by_path { cx.emit(RepositoryEvent::StatusesChanged); } @@ -9301,7 +9430,7 @@ async fn compute_snapshot( this.snapshot.stash_entries = stash_entries; this.snapshot.clone() - })) + }) } fn status_from_proto( diff --git a/crates/project/src/git_store/job_debug_queue.rs b/crates/project/src/git_store/job_debug_queue.rs index c204451d58b..96820611b16 100644 --- a/crates/project/src/git_store/job_debug_queue.rs +++ b/crates/project/src/git_store/job_debug_queue.rs @@ -80,6 +80,18 @@ impl GitJobDebugQueue { }); } + pub fn mark_unfinished_complete(&mut self, status: CompletedJobStatus) { + let ids = self + .pending + .iter() + .map(|job| job.id) + .chain(self.running.iter().map(|job| job.id)) + .collect::>(); + for id in ids { + self.mark_complete(id, status); + } + } + pub fn mark_complete(&mut self, id: JobId, status: CompletedJobStatus) { let (enqueued_at, started_at, description, key) = if let Some(index) = self.running.iter().position(|job| job.id == id) { @@ -113,6 +125,10 @@ impl GitJobDebugQueue { } pub fn to_debug_string(&self) -> String { + serde_json::to_string_pretty(&self.to_debug_value()).unwrap_or_default() + } + + pub fn to_debug_value(&self) -> serde_json::Value { let mut entries = Vec::new(); let mut pending_count = 0u64; @@ -141,7 +157,7 @@ impl GitJobDebugQueue { let json_entries: Vec = entries.into_iter().map(|(_, json)| json).collect(); - let json = serde_json::json!({ + serde_json::json!({ "summary": { "pending": pending_count, "running": running_count, @@ -149,9 +165,7 @@ impl GitJobDebugQueue { "skipped": skipped_count, }, "entries": json_entries, - }); - - serde_json::to_string_pretty(&json).unwrap_or_default() + }) } fn format_pending(&self, job: &PendingJob) -> serde_json::Value { diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index e110176dd20..2f05aba49b5 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -6,7 +6,7 @@ use crate::{ InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location, LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse, ProjectTransaction, PulledDiagnostics, ResolveState, - lsp_store::{LocalLspStore, LspFoldingRange, LspStore}, + lsp_store::{LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore}, }; use anyhow::{Context as _, Result}; use async_trait::async_trait; @@ -189,6 +189,7 @@ pub(crate) struct PerformRename { #[derive(Debug, Clone, Copy)] pub struct GetDefinitions { pub position: PointUtf16, + pub workspace_only: bool, } #[derive(Debug, Clone, Copy)] @@ -199,6 +200,7 @@ pub(crate) struct GetDeclarations { #[derive(Debug, Clone, Copy)] pub(crate) struct GetTypeDefinitions { pub position: PointUtf16, + pub workspace_only: bool, } #[derive(Debug, Clone, Copy)] @@ -302,6 +304,9 @@ pub(crate) struct GetDocumentColor; #[derive(Debug, Copy, Clone)] pub(crate) struct GetFoldingRanges; +#[derive(Debug, Copy, Clone)] +pub(crate) struct GetDocumentLinks; + impl GetCodeLens { pub(crate) fn can_resolve_lens(capabilities: &ServerCapabilities) -> bool { capabilities @@ -689,7 +694,15 @@ impl LspCommand for GetDefinitions { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await + location_links_from_lsp( + message, + lsp_store, + buffer, + server_id, + self.workspace_only, + cx, + ) + .await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDefinition { @@ -700,6 +713,7 @@ impl LspCommand for GetDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), + workspace_only: self.workspace_only, } } @@ -720,6 +734,7 @@ impl LspCommand for GetDefinitions { .await?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), + workspace_only: message.workspace_only, }) } @@ -792,7 +807,7 @@ impl LspCommand for GetDeclarations { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await + location_links_from_lsp(message, lsp_store, buffer, server_id, false, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDeclaration { @@ -894,7 +909,7 @@ impl LspCommand for GetImplementations { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, lsp_store, buffer, server_id, cx).await + location_links_from_lsp(message, lsp_store, buffer, server_id, false, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetImplementation { @@ -993,7 +1008,7 @@ impl LspCommand for GetTypeDefinitions { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - location_links_from_lsp(message, project, buffer, server_id, cx).await + location_links_from_lsp(message, project, buffer, server_id, self.workspace_only, cx).await } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetTypeDefinition { @@ -1004,6 +1019,7 @@ impl LspCommand for GetTypeDefinitions { &buffer.anchor_before(self.position), )), version: serialize_version(&buffer.version()), + workspace_only: self.workspace_only, } } @@ -1024,6 +1040,7 @@ impl LspCommand for GetTypeDefinitions { .await?; Ok(Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), + workspace_only: message.workspace_only, }) } @@ -1148,6 +1165,7 @@ pub async fn location_links_from_lsp( lsp_store: Entity, buffer: Entity, server_id: LanguageServerId, + workspace_only: bool, mut cx: AsyncApp, ) -> Result> { let message = match message { @@ -1179,6 +1197,25 @@ pub async fn location_links_from_lsp( let (_, language_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; let mut definitions = Vec::new(); for (origin_range, target_uri, target_range) in unresolved_links { + if workspace_only + && !lsp_store.update(&mut cx, |this, cx| { + use util::paths::UrlExt as _; + let worktree_store = this.worktree_store().read(cx); + let path_style = worktree_store.path_style(); + let Ok(abs_path) = target_uri.clone().to_file_path_ext(path_style) else { + return false; + }; + worktree_store + .find_worktree(&abs_path, cx) + .is_some_and(|(worktree, _)| { + let worktree = worktree.read(cx); + worktree.is_visible() && !worktree.is_single_file() + }) + }) + { + continue; + } + let target_buffer_handle = lsp_store .update(&mut cx, |this, cx| { this.open_local_buffer_via_lsp(target_uri, language_server.server_id(), cx) @@ -4872,6 +4909,147 @@ impl LspCommand for GetFoldingRanges { } } +#[async_trait(?Send)] +impl LspCommand for GetDocumentLinks { + type Response = Vec; + type LspRequest = lsp::request::DocumentLinkRequest; + type ProtoRequest = proto::GetDocumentLinks; + + fn display_name(&self) -> &str { + "Document links" + } + + fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool { + server_capabilities + .server_capabilities + .document_link_provider + .is_some() + } + + fn to_lsp( + &self, + path: &Path, + _: &Buffer, + _: &Arc, + _: &App, + ) -> Result { + Ok(lsp::DocumentLinkParams { + text_document: make_text_document_identifier(path)?, + work_done_progress_params: lsp::WorkDoneProgressParams::default(), + partial_result_params: lsp::PartialResultParams::default(), + }) + } + + async fn response_from_lsp( + self, + message: Option>, + _: Entity, + buffer: Entity, + _: LanguageServerId, + cx: AsyncApp, + ) -> Result { + let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot()); + Ok(message + .unwrap_or_default() + .into_iter() + .map(|link| { + let start = snapshot.clip_point_utf16( + Unclipped(PointUtf16::new( + link.range.start.line, + link.range.start.character, + )), + Bias::Left, + ); + let end = snapshot.clip_point_utf16( + Unclipped(PointUtf16::new( + link.range.end.line, + link.range.end.character, + )), + Bias::Right, + ); + LspDocumentLink { + range: snapshot.anchor_after(start)..snapshot.anchor_before(end), + target: link.target.map(|url| url.to_string().into()), + tooltip: link.tooltip.map(SharedString::from), + data: link.data, + resolved: false, + } + }) + .collect()) + } + + fn to_proto(&self, project_id: u64, buffer: &Buffer) -> Self::ProtoRequest { + proto::GetDocumentLinks { + project_id, + buffer_id: buffer.remote_id().to_proto(), + version: serialize_version(&buffer.version()), + } + } + + async fn from_proto( + _: Self::ProtoRequest, + _: Entity, + _: Entity, + _: AsyncApp, + ) -> Result { + Ok(Self) + } + + fn response_to_proto( + response: Self::Response, + _: &mut LspStore, + _: PeerId, + buffer_version: &clock::Global, + _: &mut App, + ) -> proto::GetDocumentLinksResponse { + proto::GetDocumentLinksResponse { + links: response + .into_iter() + .map(|link| proto::DocumentLinkProto { + range: Some(serialize_anchor_range(link.range)), + target: link.target.map(String::from), + tooltip: link.tooltip.map(String::from), + data: link + .data + .map(|d| serde_json::to_string(&d).unwrap_or_default()), + }) + .collect(), + version: serialize_version(buffer_version), + } + } + + async fn response_from_proto( + self, + message: proto::GetDocumentLinksResponse, + _: Entity, + buffer: Entity, + mut cx: AsyncApp, + ) -> Result { + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(deserialize_version(&message.version)) + }) + .await?; + message + .links + .into_iter() + .map(|link| { + Ok(LspDocumentLink { + range: deserialize_anchor_range(link.range.context("missing range")?)?, + target: link.target.map(SharedString::from), + tooltip: link.tooltip.map(SharedString::from), + data: link.data.and_then(|d| serde_json::from_str(&d).ok()), + resolved: false, + }) + }) + .collect() + } + + fn buffer_id_from_proto(message: &Self::ProtoRequest) -> Result { + BufferId::new(message.buffer_id) + } +} + fn process_related_documents( diagnostics: &mut HashMap, server_id: LanguageServerId, diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index c943498817d..811b9aebac6 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -12,6 +12,7 @@ pub mod clangd_ext; pub mod code_lens; mod document_colors; +mod document_links; mod document_symbols; mod folding_ranges; mod inlay_hints; @@ -24,6 +25,7 @@ pub mod vue_language_server_ext; use self::code_lens::CodeLensData; use self::document_colors::DocumentColorData; +use self::document_links::DocumentLinksData; use self::document_symbols::DocumentSymbolsData; use self::inlay_hints::BufferInlayHints; use crate::{ @@ -58,6 +60,7 @@ use clock::Global; use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; use futures::{ AsyncWriteExt, Future, FutureExt, StreamExt, + channel::oneshot, future::{Either, Shared, join_all, pending, select}, select, select_biased, stream::FuturesUnordered, @@ -145,6 +148,10 @@ use util::{ }; pub use document_colors::DocumentColors; +pub use document_links::{ + BufferDocumentLinks, DocumentLinkId, DocumentLinkResolveTask, LspDocumentLink, + ResolvedDocumentLink, +}; pub use folding_ranges::LspFoldingRange; pub use fs::*; pub use language::Location; @@ -728,6 +735,48 @@ impl LocalLspStore { }) }); } + + #[cfg(any(test, feature = "test-support"))] + if !adapter.adapter.is_extension() && self.languages.has_fake_lsp_server(&adapter.name) { + let language_server_name = adapter.name.clone(); + let languages = self.languages.clone(); + return cx.spawn(async move |_| { + if let Some(mut wait_until_worktree_trust) = wait_until_worktree_trust { + let already_trusted = *wait_until_worktree_trust.borrow(); + if !already_trusted { + log::info!( + "Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {language_server_name}", + ); + while let Some(worktree_trusted) = wait_until_worktree_trust.recv().await { + if worktree_trusted { + break; + } + } + log::info!( + "Worktree {worktree_abs_path:?} is trusted, starting language server {language_server_name}", + ); + } + languages.update_lsp_binary_status( + language_server_name.clone(), + BinaryStatus::Starting, + ); + } + + Ok(LanguageServerBinary { + path: PathBuf::from(format!("/fake/lsp/{language_server_name}")), + arguments: Vec::new(), + env: None, + }) + }); + } + + if cfg!(any(test, feature = "test-support")) && !adapter.adapter.is_extension() { + return Task::ready(Err(anyhow!( + "language server binary lookup for {:?} is disabled in tests; register a fake language server or configure an explicit binary", + adapter.name + ))); + } + let lsp_binary_options = LanguageServerBinaryOptions { allow_path_lookup: !settings .binary @@ -744,10 +793,11 @@ impl LocalLspStore { cx.spawn(async move |cx| { if let Some(mut wait_until_worktree_trust) = wait_until_worktree_trust { - let already_trusted = *wait_until_worktree_trust.borrow(); + let already_trusted = *wait_until_worktree_trust.borrow(); if !already_trusted { log::info!( - "Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {}", + "Waiting for worktree {worktree_abs_path:?} to be trusted, \ + before starting language server {}", adapter.name(), ); while let Some(worktree_trusted) = wait_until_worktree_trust.recv().await { @@ -757,7 +807,7 @@ impl LocalLspStore { } log::info!( "Worktree {worktree_abs_path:?} is trusted, starting language server {}", - adapter.name(), + adapter.name(), ); } } @@ -3593,8 +3643,10 @@ impl LocalLspStore { } } servers_to_remove.retain(|server_id| !servers_to_preserve.contains(server_id)); - self.language_server_ids - .retain(|_, state| !servers_to_remove.contains(&state.id)); + self.language_server_ids.retain(|seed, state| { + seed.worktree_id != id_to_remove && !servers_to_remove.contains(&state.id) + }); + self.lsp_tree.instances.remove(&id_to_remove); for server_id_to_remove in &servers_to_remove { self.language_server_watched_paths .remove(server_id_to_remove); @@ -4003,6 +4055,7 @@ pub struct BufferLspData { code_lens: Option, semantic_tokens: Option, folding_ranges: Option, + document_links: Option, document_symbols: Option, inlay_hints: BufferInlayHints, lsp_requests: HashMap>>, @@ -4023,6 +4076,7 @@ impl BufferLspData { code_lens: None, semantic_tokens: None, folding_ranges: None, + document_links: None, document_symbols: None, inlay_hints: BufferInlayHints::new(buffer, cx), lsp_requests: HashMap::default(), @@ -4049,6 +4103,10 @@ impl BufferLspData { folding_ranges.ranges.remove(&for_server); } + if let Some(document_links) = &mut self.document_links { + document_links.remove_server_data(for_server); + } + if let Some(document_symbols) = &mut self.document_symbols { document_symbols.remove_server_data(for_server); } @@ -4171,6 +4229,7 @@ impl LspStore { client.add_entity_request_handler(Self::handle_apply_code_action); client.add_entity_request_handler(Self::handle_get_project_symbols); client.add_entity_request_handler(Self::handle_resolve_inlay_hint); + client.add_entity_request_handler(Self::handle_resolve_document_link); client.add_entity_request_handler(Self::handle_get_color_presentation); client.add_entity_request_handler(Self::handle_open_buffer_for_symbol); client.add_entity_request_handler(Self::handle_refresh_inlay_hints); @@ -5600,7 +5659,7 @@ impl LspStore { .unwrap_or_default(); if !available_commands.contains(&command.command) { - log::warn!( + log::debug!( "Skipping executeCommand for {}, not listed in language server capabilities", command.command ); @@ -5937,9 +5996,31 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, + ) -> Task>>> { + self.definitions_with_filter(buffer, position, false, cx) + } + + pub fn workspace_definitions( + &mut self, + buffer: &Entity, + position: PointUtf16, + cx: &mut Context, + ) -> Task>>> { + self.definitions_with_filter(buffer, position, true, cx) + } + + fn definitions_with_filter( + &mut self, + buffer: &Entity, + position: PointUtf16, + workspace_only: bool, + cx: &mut Context, ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { - let request = GetDefinitions { position }; + let request = GetDefinitions { + position, + workspace_only, + }; if !self.is_capable_for_proto_request(buffer, &request, cx) { return Task::ready(Ok(None)); } @@ -5964,7 +6045,11 @@ impl LspStore { return Ok(None); }; let actions = join_all(responses.payload.into_iter().map(|response| { - GetDefinitions { position }.response_from_proto( + GetDefinitions { + position, + workspace_only, + } + .response_from_proto( response.response, lsp_store.clone(), buffer.clone(), @@ -5987,7 +6072,10 @@ impl LspStore { let definitions_task = self.request_multiple_lsp_locally( buffer, Some(position), - GetDefinitions { position }, + GetDefinitions { + position, + workspace_only, + }, cx, ); cx.background_spawn(async move { @@ -6077,9 +6165,31 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, + ) -> Task>>> { + self.type_definitions_with_filter(buffer, position, false, cx) + } + + pub fn workspace_type_definitions( + &mut self, + buffer: &Entity, + position: PointUtf16, + cx: &mut Context, + ) -> Task>>> { + self.type_definitions_with_filter(buffer, position, true, cx) + } + + fn type_definitions_with_filter( + &mut self, + buffer: &Entity, + position: PointUtf16, + workspace_only: bool, + cx: &mut Context, ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { - let request = GetTypeDefinitions { position }; + let request = GetTypeDefinitions { + position, + workspace_only, + }; if !self.is_capable_for_proto_request(buffer, &request, cx) { return Task::ready(Ok(None)); } @@ -6102,7 +6212,11 @@ impl LspStore { return Ok(None); }; let actions = join_all(responses.payload.into_iter().map(|response| { - GetTypeDefinitions { position }.response_from_proto( + GetTypeDefinitions { + position, + workspace_only, + } + .response_from_proto( response.response, lsp_store.clone(), buffer.clone(), @@ -6125,7 +6239,10 @@ impl LspStore { let type_definitions_task = self.request_multiple_lsp_locally( buffer, Some(position), - GetTypeDefinitions { position }, + GetTypeDefinitions { + position, + workspace_only, + }, cx, ); cx.background_spawn(async move { @@ -8320,26 +8437,21 @@ impl LspStore { } fn maintain_workspace_config( - external_refresh_requests: watch::Receiver<()>, + mut external_refresh_requests: watch::Receiver<()>, cx: &mut Context, ) -> Task> { - let (mut settings_changed_tx, mut settings_changed_rx) = watch::channel(); - let _ = postage::stream::Stream::try_recv(&mut settings_changed_rx); - - let settings_observation = cx.observe_global::(move |_, _| { - *settings_changed_tx.borrow_mut() = (); - }); - - let mut joint_future = - futures::stream::select(settings_changed_rx, external_refresh_requests); // Multiple things can happen when a workspace environment (selected toolchain + settings) change: // - We might shut down a language server if it's no longer enabled for a given language (and there are no buffers using it otherwise). // - We might also shut it down when the workspace configuration of all of the users of a given language server converges onto that of the other. // - In the same vein, we might also decide to start a new language server if the workspace configuration *diverges* from the other. // - In the easiest case (where we're not wrangling the lifetime of a language server anyhow), if none of the roots of a single language server diverge in their configuration, // but it is still different to what we had before, we're gonna send out a workspace configuration update. + // + // Settings-store changes reach this loop via `on_settings_changed` -> `request_workspace_config_refresh`, + // which writes to `external_refresh_requests`. Observing `SettingsStore` here as well would cause every + // settings change to drive the loop twice and emit duplicate `workspace/didChangeConfiguration` notifications. cx.spawn(async move |this, cx| { - while let Some(()) = joint_future.next().await { + while let Some(()) = external_refresh_requests.next().await { this.update(cx, |this, cx| { this.refresh_server_tree(cx); }) @@ -8348,7 +8460,6 @@ impl LspStore { Self::refresh_workspace_configurations(&this, cx).await; } - drop(settings_observation); anyhow::Ok(()) }) } @@ -9199,6 +9310,36 @@ impl LspStore { ) .await?; } + Request::GetDocumentLinks(get_document_links) => { + let (buffer_version, buffer) = Self::wait_for_buffer_version::( + &lsp_store, + &get_document_links, + &mut cx, + ) + .await?; + lsp_store.update(&mut cx, |lsp_store, cx| { + let document_links_task = lsp_store.fetch_document_links(&buffer, cx); + let fetch_task = cx.background_spawn(async move { + document_links_task + .await + .unwrap_or_default() + .into_iter() + .map(|(server_id, links)| { + (server_id, links.into_values().collect::>()) + }) + .collect() + }); + lsp_store.serve_lsp_query::( + server_id, + sender_id, + lsp_request_id, + &buffer, + buffer_version, + fetch_task, + cx, + ); + }); + } Request::GetHover(get_hover) => { let position = get_hover.position.clone().and_then(deserialize_anchor); Self::query_lsp_locally::( @@ -9396,66 +9537,19 @@ impl LspStore { .await?; let for_server = semantic_tokens.for_server.map(LanguageServerId::from_proto); lsp_store.update(&mut cx, |lsp_store, cx| { - if let Some((client, project_id)) = lsp_store.downstream_client.clone() { - let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); - let key = LspKey { - request_type: TypeId::of::(), - server_queried: server_id, - }; - if ::ProtoRequest::stop_previous_requests() { - if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) { - lsp_requests.clear(); - }; - } - - lsp_data.lsp_requests.entry(key).or_default().insert( - lsp_request_id, - cx.spawn(async move |lsp_store, cx| { - let tokens_fetch = lsp_store - .update(cx, |lsp_store, cx| { - lsp_store - .fetch_semantic_tokens_for_buffer(&buffer, for_server, cx) - }) - .ok(); - if let Some(tokens_fetch) = tokens_fetch { - let new_tokens = tokens_fetch.await; - if let Some(new_tokens) = new_tokens { - lsp_store - .update(cx, |lsp_store, cx| { - let response = new_tokens - .into_iter() - .map(|(server_id, response)| { - ( - server_id.to_proto(), - SemanticTokensFull::response_to_proto( - response, - lsp_store, - sender_id, - &buffer_version, - cx, - ), - ) - }) - .collect::>(); - match client.send_lsp_response::<::ProtoRequest>( - project_id, - lsp_request_id, - response, - ) { - Ok(()) => {} - Err(e) => { - log::error!( - "Failed to send semantic tokens LSP response: {e:#}", - ) - } - } - }) - .ok(); - } - } - }), - ); - } + let semantic_tokens_task = + lsp_store.fetch_semantic_tokens_for_buffer(&buffer, for_server, cx); + lsp_store.serve_lsp_query::( + server_id, + sender_id, + lsp_request_id, + &buffer, + buffer_version, + cx.background_spawn(async move { + semantic_tokens_task.await.unwrap_or_default() + }), + cx, + ); }); } } @@ -10042,6 +10136,16 @@ impl LspStore { .map(|(key, value)| (*key, value)) } + #[cfg(feature = "test-support")] + pub fn has_language_server_seed_for_worktree(&self, worktree_id: WorktreeId) -> bool { + self.as_local().is_some_and(|local| { + local + .language_server_ids + .keys() + .any(|seed| seed.worktree_id == worktree_id) + }) + } + pub(super) fn did_rename_entry( &self, worktree_id: WorktreeId, @@ -12423,11 +12527,45 @@ impl LspStore { .and_then(|local| local.language_servers.get_mut(&server_id)) { for diagnostics in workspace_diagnostics_refresh_tasks.values_mut() { - diagnostics.refresh_tx.try_send(()).ok(); + diagnostics.refresh_tx.try_send(None).ok(); } } } + /// Triggers a workspace diagnostics pull on all running language servers + /// and returns a [`Task`] that resolves once the requests have completed. + /// + /// This reuses the same background refresh loops as + /// [`Self::pull_workspace_diagnostics`], but provides a completion signal + /// so callers can wait for fresh diagnostics before reading them. + pub fn pull_workspace_diagnostics_once(&mut self, cx: &mut Context) -> Task { + let Some(local) = self.as_local_mut() else { + return Task::ready(true); + }; + + let mut receivers = Vec::new(); + for state in local.language_servers.values_mut() { + let LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } = state + else { + continue; + }; + for task in workspace_diagnostics_refresh_tasks.values_mut() { + let (tx, rx) = oneshot::channel(); + task.refresh_tx.try_send(Some(tx)).ok(); + receivers.push(rx); + } + } + + cx.background_spawn(async { + FuturesUnordered::from_iter(receivers) + .all(async |result| result.unwrap_or(false)) + .await + }) + } + /// Refreshes `textDocument/diagnostic` for all open buffers associated with the given server. /// This is called in response to `workspace/diagnostic/refresh` to comply with the LSP spec, /// which requires refreshing both workspace and document diagnostics. @@ -12932,6 +13070,18 @@ impl LspStore { }); notify_server_capabilities_updated(&server, cx); } + "textDocument/documentLink" => { + if let Some(caps) = reg + .register_options + .map(serde_json::from_value) + .transpose()? + { + server.update_capabilities(|capabilities| { + capabilities.document_link_provider = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } + } _ => log::warn!("unhandled capability registration: {reg:?}"), } } @@ -13135,6 +13285,12 @@ impl LspStore { }); notify_server_capabilities_updated(&server, cx); } + "textDocument/documentLink" => { + server.update_capabilities(|capabilities| { + capabilities.document_link_provider = None; + }); + notify_server_capabilities_updated(&server, cx); + } _ => log::warn!("unhandled capability unregistration: {unreg:?}"), } } @@ -13374,6 +13530,72 @@ impl LspStore { Ok(()) } + fn serve_lsp_query( + &mut self, + server_id: Option, + sender_id: proto::PeerId, + lsp_request_id: LspRequestId, + buffer: &Entity, + buffer_version: Global, + fetch_task: Task>, + cx: &mut Context, + ) where + T: LspCommand + 'static, + T::ProtoRequest: proto::LspRequestMessage, + ::Response: + Into<::Response>, + { + let Some((client, project_id)) = self.downstream_client.clone() else { + return; + }; + let lsp_data = self.latest_lsp_data(buffer, cx); + let key = LspKey { + request_type: TypeId::of::(), + server_queried: server_id, + }; + if T::ProtoRequest::stop_previous_requests() { + if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) { + lsp_requests.clear(); + } + } + lsp_data.lsp_requests.entry(key).or_default().insert( + lsp_request_id, + cx.spawn(async move |lsp_store, cx| { + let by_server = fetch_task.await; + lsp_store + .update(cx, |lsp_store, cx| { + let response = by_server + .into_iter() + .map(|(server_id, response)| { + ( + server_id.to_proto(), + T::response_to_proto( + response, + lsp_store, + sender_id, + &buffer_version, + cx, + ) + .into(), + ) + }) + .collect::>(); + if let Err(e) = client.send_lsp_response::( + project_id, + lsp_request_id, + response, + ) { + log::error!( + "Failed to send {} LSP response: {e:#}", + std::any::type_name::() + ); + } + }) + .ok(); + }), + ); + } + async fn wait_for_buffer_version( lsp_store: &Entity, proto_request: &T::ProtoRequest, @@ -13517,8 +13739,8 @@ fn lsp_workspace_diagnostics_refresh( let registration_id_shared = registration_id.as_ref().map(SharedString::from); let (progress_tx, mut progress_rx) = mpsc::channel(1); - let (mut refresh_tx, mut refresh_rx) = mpsc::channel(1); - refresh_tx.try_send(()).ok(); + let (mut refresh_tx, mut refresh_rx) = mpsc::channel::>>(1); + refresh_tx.try_send(None).ok(); let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings @@ -13538,7 +13760,7 @@ fn lsp_workspace_diagnostics_refresh( let mut requests = 0; loop { - let Some(()) = refresh_rx.recv().await else { + let Some(mut completion_tx) = refresh_rx.recv().await else { return; }; @@ -13614,6 +13836,9 @@ fn lsp_workspace_diagnostics_refresh( } ConnectionResult::Result(Err(e)) => { log::error!("Error during workspace diagnostics pull: {e:#}"); + if let Some(tx) = completion_tx.take() { + tx.send(false).ok(); + } break 'request; } ConnectionResult::Result(Ok(pulled_diagnostics)) => { @@ -13631,6 +13856,9 @@ fn lsp_workspace_diagnostics_refresh( { return; } + if let Some(tx) = completion_tx.take() { + tx.send(true).ok(); + } break 'request; } } @@ -14109,7 +14337,7 @@ impl LanguageServerLogType { } pub struct WorkspaceRefreshTask { - refresh_tx: mpsc::Sender<()>, + refresh_tx: mpsc::Sender>>, progress_tx: mpsc::Sender<()>, #[allow(dead_code)] task: Task<()>, @@ -14328,7 +14556,7 @@ impl LspInstaller for SshLspAdapter { type BinaryVersion = (); async fn check_if_user_installed( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: Option, _: &AsyncApp, ) -> Option { @@ -14345,7 +14573,7 @@ impl LspInstaller for SshLspAdapter { async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, + _: &Arc, _: bool, _: &mut AsyncApp, ) -> Result<()> { diff --git a/crates/project/src/lsp_store/document_links.rs b/crates/project/src/lsp_store/document_links.rs new file mode 100644 index 00000000000..5bfcab4913b --- /dev/null +++ b/crates/project/src/lsp_store/document_links.rs @@ -0,0 +1,456 @@ +use std::ops::Range; +use std::str::FromStr as _; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context as _; +use clock::Global; +use collections::HashMap; +use futures::FutureExt as _; +use futures::future::{Shared, join_all}; +use gpui::{AppContext as _, AsyncApp, Context, Entity, SharedString, Task}; +use language::{Buffer, point_to_lsp}; +use lsp::LanguageServerId; +use lsp::request::DocumentLinkResolve; +use rpc::{TypedEnvelope, proto}; +use settings::Settings as _; +use text::{Anchor, BufferId, ToPointUtf16 as _}; +use util::ResultExt as _; + +use crate::lsp_command::{GetDocumentLinks, LspCommand as _}; +use crate::lsp_store::LspStore; +use crate::project_settings::ProjectSettings; + +#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub struct DocumentLinkId(u64); + +#[derive(Clone, Debug)] +pub struct LspDocumentLink { + pub range: Range, + pub target: Option, + pub tooltip: Option, + pub data: Option, + pub resolved: bool, +} + +pub type BufferDocumentLinks = HashMap>; + +pub(super) type DocumentLinksTask = + Shared, Arc>>>; + +pub type DocumentLinkResolveTask = Shared>>; + +#[derive(Debug, Default)] +pub(super) struct DocumentLinksData { + pub(super) links: BufferDocumentLinks, + pub(super) next_id: u64, + links_update: Option<(Global, DocumentLinksTask)>, + pub(super) link_resolves: HashMap<(LanguageServerId, DocumentLinkId), DocumentLinkResolveTask>, +} + +impl DocumentLinksData { + pub(super) fn remove_server_data(&mut self, server_id: LanguageServerId) { + self.links.remove(&server_id); + self.link_resolves + .retain(|(resolved_server, _), _| *resolved_server != server_id); + } +} + +/// Mirror of [`crate::lsp_store::ResolvedHint`] for document links: callers +/// either get the resolved entry directly, an in-flight `Shared` task to await +/// (deduplicated across editors), or `None` when the cache no longer contains +/// a matching link. +pub enum ResolvedDocumentLink { + Resolved(LspDocumentLink), + Resolving(DocumentLinkResolveTask), +} + +impl LspStore { + /// `Some(..)` means the underlying state was actually refreshed; `None` + /// means the fetch was skipped or failed, and the caller should keep its + /// previous data. + pub fn fetch_document_links( + &mut self, + buffer: &Entity, + cx: &mut Context, + ) -> Task> { + let version_queried_for = buffer.read(cx).version(); + let buffer_id = buffer.read(cx).remote_id(); + + let current_language_servers = self.as_local().map(|local| { + local + .buffers_opened_in_servers + .get(&buffer_id) + .cloned() + .unwrap_or_default() + }); + + if let Some(lsp_data) = self.current_lsp_data(buffer_id) + && let Some(cached) = &lsp_data.document_links + && !version_queried_for.changed_since(&lsp_data.buffer_version) + { + let has_different_servers = + current_language_servers.is_some_and(|current_language_servers| { + current_language_servers != cached.links.keys().copied().collect() + }); + if !has_different_servers { + return Task::ready(Some(cached.links.clone())); + } + } + + let links_lsp_data = self + .latest_lsp_data(buffer, cx) + .document_links + .get_or_insert_default(); + if let Some((updating_for, running_update)) = &links_lsp_data.links_update + && !version_queried_for.changed_since(updating_for) + { + let running = running_update.clone(); + return cx.background_spawn(async move { running.await.ok().flatten() }); + } + + let buffer = buffer.clone(); + let query_version = version_queried_for.clone(); + let new_task = cx + .spawn(async move |lsp_store, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; + + let fetched = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.fetch_document_links_for_buffer(&buffer, cx) + }) + .map_err(Arc::new)? + .await + .context("fetching document links") + .map_err(Arc::new); + + let fetched = match fetched { + Ok(fetched) => fetched, + Err(e) => { + lsp_store + .update(cx, |lsp_store, _| { + if let Some(lsp_data) = lsp_store.lsp_data.get_mut(&buffer_id) + && let Some(document_links) = &mut lsp_data.document_links + { + document_links.links_update = None; + } + }) + .ok(); + return Err(e); + } + }; + + lsp_store + .update(cx, |lsp_store, cx| { + let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); + let links_data = lsp_data.document_links.get_or_insert_default(); + links_data.links_update = None; + + let Some(fetched_links) = fetched else { + return None; + }; + + let mut tagged = BufferDocumentLinks::default(); + for (server_id, server_links) in fetched_links { + let mut by_id = HashMap::default(); + by_id.reserve(server_links.len()); + for link in server_links { + let id = DocumentLinkId(links_data.next_id); + links_data.next_id += 1; + by_id.insert(id, link); + } + tagged.insert(server_id, by_id); + } + + if lsp_data.buffer_version == query_version { + for (server_id, new_links) in &tagged { + links_data.links.insert(*server_id, new_links.clone()); + } + // The newly inserted links are unresolved by definition; drop any + // pending resolves that were keyed against the prior entries for + // those servers so callers re-issue against the fresh ids. + links_data.link_resolves.clear(); + Some(links_data.links.clone()) + } else if !lsp_data.buffer_version.changed_since(&query_version) { + lsp_data.buffer_version = query_version; + links_data.links = tagged; + links_data.link_resolves.clear(); + Some(links_data.links.clone()) + } else { + None + } + }) + .map_err(Arc::new) + }) + .shared(); + + links_lsp_data.links_update = Some((version_queried_for, new_task.clone())); + + cx.background_spawn(async move { new_task.await.ok().flatten() }) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn document_links_for_buffer(&self, buffer_id: BufferId) -> Option { + let data = self.lsp_data.get(&buffer_id)?; + let document_links = data.document_links.as_ref()?; + Some(document_links.links.clone()) + } + + fn fetch_document_links_for_buffer( + &mut self, + buffer: &Entity, + cx: &mut Context, + ) -> Task>>>> { + if let Some((client, project_id)) = self.upstream_client() { + let request = GetDocumentLinks; + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(None)); + } + + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + let request_task = client.request_lsp( + project_id, + None, + request_timeout, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + let buffer = buffer.clone(); + cx.spawn(async move |weak_lsp_store, cx| { + let Some(lsp_store) = weak_lsp_store.upgrade() else { + return Ok(None); + }; + let Some(responses) = request_task.await? else { + return Ok(None); + }; + + let document_links = join_all(responses.payload.into_iter().map(|response| { + let lsp_store = lsp_store.clone(); + let buffer = buffer.clone(); + let cx = cx.clone(); + async move { + let server_id = LanguageServerId::from_proto(response.server_id); + let links = GetDocumentLinks + .response_from_proto(response.response, lsp_store, buffer, cx) + .await; + (server_id, links) + } + })) + .await; + + let mut has_errors = false; + let result = document_links + .into_iter() + .filter_map(|(server_id, links)| match links { + Ok(links) => Some((server_id, links)), + Err(e) => { + has_errors = true; + log::error!( + "Failed to fetch document links for server {server_id}: {e:#}" + ); + None + } + }) + .collect::>(); + anyhow::ensure!( + !has_errors || !result.is_empty(), + "Failed to fetch document links" + ); + Ok(Some(result)) + }) + } else { + let links_task = + self.request_multiple_lsp_locally(buffer, None::, GetDocumentLinks, cx); + cx.background_spawn(async move { Ok(Some(links_task.await.into_iter().collect())) }) + } + } + + /// Returns the resolved state for a cached document link, deduplicating + /// in-flight `documentLink/resolve` requests across editors via a `Shared` + /// task stored on `DocumentLinksData`. + /// + /// `link_id` is the [`DocumentLinkId`] stamped on the cached link by + /// [`Self::fetch_document_links`]; sibling links sharing the same buffer + /// range are disambiguated by it. `None` is returned when the cache no + /// longer holds a matching link (likely a version bump in between). + pub fn resolved_document_link( + &mut self, + buffer: &Entity, + server_id: LanguageServerId, + link_id: DocumentLinkId, + cx: &mut Context, + ) -> Option { + let buffer_id = buffer.read(cx).remote_id(); + + let document_links = self.lsp_data.get(&buffer_id)?.document_links.as_ref()?; + let cached_link = document_links.links.get(&server_id)?.get(&link_id)?.clone(); + + if cached_link.resolved { + return Some(ResolvedDocumentLink::Resolved(cached_link)); + } + + let key = (server_id, link_id); + if let Some(running) = document_links.link_resolves.get(&key) { + return Some(ResolvedDocumentLink::Resolving(running.clone())); + } + + let resolve_task = self.resolve_document_link_request(buffer, server_id, &cached_link, cx); + let query_version = self.lsp_data.get(&buffer_id)?.buffer_version.clone(); + let resolve_task = cx + .spawn(async move |lsp_store, cx| { + let resolved = resolve_task.await; + lsp_store + .update(cx, |lsp_store, _| { + let lsp_data = lsp_store.lsp_data.get_mut(&buffer_id)?; + if lsp_data.buffer_version != query_version { + return None; + } + let links_data = lsp_data.document_links.as_mut()?; + links_data.link_resolves.remove(&key); + let updated = match resolved { + Some(resolved) => lsp_store + .cache_resolved_link(buffer_id, server_id, link_id, &resolved)?, + None => { + // No further resolution is possible (no capability, + // missing server, or LSP error); mark as resolved so we + // do not keep retrying on every hover, and yield the + // entry as-is so awaiters can still surface it. + let links_data = lsp_data.document_links.as_mut()?; + let link = + links_data.links.get_mut(&server_id)?.get_mut(&link_id)?; + link.resolved = true; + link.clone() + } + }; + Some((link_id, updated)) + }) + .ok() + .flatten() + }) + .shared(); + + let document_links = self.lsp_data.get_mut(&buffer_id)?.document_links.as_mut()?; + document_links + .link_resolves + .insert(key, resolve_task.clone()); + Some(ResolvedDocumentLink::Resolving(resolve_task)) + } + + /// Builds the LSP/proto request task for a single unresolved link. Returns + /// a task that yields `None` when the resolve request cannot be issued + /// (no upstream capability, no local server, or no `resolveProvider`). + fn resolve_document_link_request( + &self, + buffer: &Entity, + server_id: LanguageServerId, + cached_link: &LspDocumentLink, + cx: &mut Context, + ) -> Task> { + let snapshot = buffer.read(cx).snapshot(); + let buffer_id = buffer.read(cx).remote_id(); + let lsp_link = lsp::DocumentLink { + range: lsp::Range { + start: point_to_lsp(cached_link.range.start.to_point_utf16(&snapshot)), + end: point_to_lsp(cached_link.range.end.to_point_utf16(&snapshot)), + }, + target: cached_link + .target + .as_ref() + .and_then(|s| lsp::Uri::from_str(s).ok()), + tooltip: cached_link.tooltip.as_deref().map(str::to_string), + data: cached_link.data.clone(), + }; + + if let Some((upstream_client, project_id)) = self.upstream_client() { + if !self.check_if_capable_for_proto_request(buffer, can_resolve_link, cx) { + return Task::ready(None); + } + let request = proto::ResolveDocumentLink { + project_id, + buffer_id: buffer_id.into(), + language_server_id: server_id.0 as u64, + lsp_link: serde_json::to_vec(&lsp_link).unwrap_or_default(), + }; + cx.background_spawn(async move { + let response = upstream_client.request(request).await.log_err()?; + serde_json::from_slice::(&response.lsp_link).log_err() + }) + } else { + let Some(server) = self.language_server_for_id(server_id) else { + return Task::ready(None); + }; + if !can_resolve_link(&server.capabilities()) { + return Task::ready(None); + } + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + cx.background_spawn(async move { + server + .request::(lsp_link, request_timeout) + .await + .into_response() + .log_err() + }) + } + } + + fn cache_resolved_link( + &mut self, + buffer_id: BufferId, + server_id: LanguageServerId, + link_id: DocumentLinkId, + resolved: &lsp::DocumentLink, + ) -> Option { + let document_links = self.lsp_data.get_mut(&buffer_id)?.document_links.as_mut()?; + let link = document_links + .links + .get_mut(&server_id)? + .get_mut(&link_id)?; + link.target = resolved.target.as_ref().map(|u| u.to_string().into()); + if let Some(tooltip) = &resolved.tooltip { + link.tooltip = Some(tooltip.clone().into()); + } + link.data = resolved.data.clone(); + link.resolved = true; + Some(link.clone()) + } + + pub(super) async fn handle_resolve_document_link( + lsp_store: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> anyhow::Result { + let lsp_link: lsp::DocumentLink = serde_json::from_slice(&envelope.payload.lsp_link) + .context("deserializing document link to resolve")?; + let server_id = LanguageServerId::from_proto(envelope.payload.language_server_id); + + let resolve_task = lsp_store.update(&mut cx, |lsp_store, cx| { + let server = lsp_store + .language_server_for_id(server_id) + .with_context(|| format!("No language server {server_id}"))?; + let timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + anyhow::Ok(server.request::(lsp_link, timeout)) + })?; + let resolved = resolve_task.await.into_response()?; + + Ok(proto::ResolveDocumentLinkResponse { + lsp_link: serde_json::to_vec(&resolved) + .context("serializing resolved document link")?, + }) + } +} + +fn can_resolve_link(capabilities: &lsp::ServerCapabilities) -> bool { + capabilities + .document_link_provider + .as_ref() + .and_then(|opts| opts.resolve_provider) + .unwrap_or(false) +} diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index bb994492d00..dd7010275dc 100644 --- a/crates/project/src/lsp_store/lsp_ext_command.rs +++ b/crates/project/src/lsp_store/lsp_ext_command.rs @@ -443,6 +443,7 @@ impl LspCommand for GoToParentModule { lsp_store, buffer, server_id, + false, cx, ) .await diff --git a/crates/project/src/prettier_store.rs b/crates/project/src/prettier_store.rs index faa2cca7986..8d9399dce64 100644 --- a/crates/project/src/prettier_store.rs +++ b/crates/project/src/prettier_store.rs @@ -930,23 +930,11 @@ async fn install_prettier_packages( plugins_to_install: HashSet>, node: NodeRuntime, ) -> anyhow::Result<()> { - let packages_to_versions = future::try_join_all( - plugins_to_install - .iter() - .chain(Some(&"prettier".into())) - .map(|package_name| async { - let returned_package_name = package_name.to_string(); - let latest_version = node - .npm_package_latest_version(package_name) - .await - .with_context(|| { - format!("fetching latest npm version for package {returned_package_name}") - })?; - anyhow::Ok((returned_package_name, latest_version.to_string())) - }), - ) - .await - .context("fetching latest npm versions")?; + let packages_to_install = plugins_to_install + .iter() + .map(|package_name| package_name.to_string()) + .chain(Some("prettier".to_string())) + .collect::>(); let default_prettier_dir = default_prettier_dir().as_path(); match fs.metadata(default_prettier_dir).await.with_context(|| { @@ -962,12 +950,12 @@ async fn install_prettier_packages( .with_context(|| format!("creating default prettier dir {default_prettier_dir:?}"))?, } - log::info!("Installing default prettier and plugins: {packages_to_versions:?}"); - let borrowed_packages = packages_to_versions + log::info!("Installing default prettier and plugins: {packages_to_install:?}"); + let borrowed_packages = packages_to_install .iter() - .map(|(package, version)| (package.as_str(), version.as_str())) + .map(|package_name| package_name.as_str()) .collect::>(); - node.npm_install_packages(default_prettier_dir, &borrowed_packages) + node.npm_install_latest_packages(default_prettier_dir, &borrowed_packages) .await .context("fetching formatter packages")?; anyhow::Ok(()) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 1674a26b871..8544e0b833d 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -89,9 +89,9 @@ use gpui::{ Task, TaskExt, WeakEntity, Window, }; use language::{ - Buffer, BufferEvent, Capability, CodeLabel, CursorShape, DiskState, Language, LanguageName, - LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainMetadata, - ToolchainScope, Transaction, Unclipped, language_settings::InlayHintKind, + Buffer, BufferEditSource, BufferEvent, Capability, CodeLabel, CursorShape, DiskState, Language, + LanguageName, LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, + ToolchainMetadata, ToolchainScope, Transaction, Unclipped, language_settings::InlayHintKind, proto::split_operations, }; use lsp::{ @@ -410,7 +410,9 @@ pub enum Event { EntryRenamed(ProjectTransaction, ProjectPath, PathBuf), WorkspaceEditApplied(ProjectTransaction), AgentLocationChanged, - BufferEdited, + BufferEdited { + source: BufferEditSource, + }, } pub struct AgentLocationChanged; @@ -3810,8 +3812,8 @@ impl Project { self.request_buffer_diff_recalculation(&buffer, cx); } - if matches!(event, BufferEvent::Edited { .. }) { - cx.emit(Event::BufferEdited); + if let BufferEvent::Edited { source } = event { + cx.emit(Event::BufferEdited { source: *source }); } let buffer_id = buffer.read(cx).remote_id(); @@ -4180,6 +4182,24 @@ impl Project { }) } + pub fn workspace_definitions( + &mut self, + buffer: &Entity, + position: T, + cx: &mut Context, + ) -> Task>>> { + let position = position.to_point_utf16(buffer.read(cx)); + let guard = self.retain_remotely_created_models(cx); + let task = self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store.workspace_definitions(buffer, position, cx) + }); + cx.background_spawn(async move { + let result = task.await; + drop(guard); + result + }) + } + pub fn declarations( &mut self, buffer: &Entity, @@ -4216,6 +4236,24 @@ impl Project { }) } + pub fn workspace_type_definitions( + &mut self, + buffer: &Entity, + position: T, + cx: &mut Context, + ) -> Task>>> { + let position = position.to_point_utf16(buffer.read(cx)); + let guard = self.retain_remotely_created_models(cx); + let task = self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store.workspace_type_definitions(buffer, position, cx) + }); + cx.background_spawn(async move { + let result = task.await; + drop(guard); + result + }) + } + pub fn implementations( &mut self, buffer: &Entity, diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index d2dc70b8392..f11263a8f70 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -201,6 +201,10 @@ pub enum ContextServerSettings { headers: HashMap, /// Timeout for tool calls in milliseconds. timeout: Option, + /// Pre-registered OAuth client credentials for authorization servers that + /// require out-of-band client registration. + #[serde(default, skip_serializing_if = "Option::is_none")] + oauth: Option, }, Extension { /// Whether the context server is enabled. @@ -243,11 +247,16 @@ impl From for ContextServerSettings { url, headers, timeout, + oauth, } => ContextServerSettings::Http { enabled, url, headers, timeout, + oauth: oauth.map(|o| OAuthClientSettings { + client_id: o.client_id, + client_secret: o.client_secret, + }), }, } } @@ -278,16 +287,35 @@ impl Into for ContextServerSettings { url, headers, timeout, + oauth, } => settings::ContextServerSettingsContent::Http { enabled, url, headers, timeout, + oauth: oauth.map(|o| settings::OAuthClientSettings { + client_id: o.client_id, + client_secret: o.client_secret, + }), }, } } } +/// Pre-registered OAuth client credentials for MCP servers that don't support +/// Dynamic Client Registration. +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)] +pub struct OAuthClientSettings { + /// The OAuth client ID obtained from out-of-band registration with the + /// authorization server. + pub client_id: String, + /// The OAuth client secret, if this is a confidential client. For security, + /// prefer providing this interactively; we will prompt and store it in + /// the system keychain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, +} + impl ContextServerSettings { pub fn default_extension() -> Self { Self::Extension { diff --git a/crates/project/src/trusted_worktrees.rs b/crates/project/src/trusted_worktrees.rs index 69d410adc66..8d8804c3f97 100644 --- a/crates/project/src/trusted_worktrees.rs +++ b/crates/project/src/trusted_worktrees.rs @@ -113,6 +113,17 @@ impl TrustedWorktrees { pub fn try_get_global(cx: &App) -> Option> { cx.try_global::().map(|this| this.0.clone()) } + + /// Whether the given project store has any restricted worktrees. + pub fn has_restricted_worktrees(worktree_store: &Entity, cx: &App) -> bool { + Self::try_get_global(cx) + .map(|trusted| { + trusted + .read(cx) + .has_restricted_worktrees(worktree_store, cx) + }) + .unwrap_or(false) + } } /// A collection of worktrees that are considered trusted and not trusted. diff --git a/crates/project/tests/integration/context_server_store.rs b/crates/project/tests/integration/context_server_store.rs index 5b68e11bb95..090baacf032 100644 --- a/crates/project/tests/integration/context_server_store.rs +++ b/crates/project/tests/integration/context_server_store.rs @@ -664,6 +664,93 @@ async fn test_context_server_respects_disable_ai(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_context_server_refreshed_when_worktree_added(cx: &mut TestAppContext) { + const SERVER_1_ID: &str = "mcp-1"; + + let server_1_id = ContextServerId(SERVER_1_ID.into()); + + let (fs, project) = setup_context_server_test(cx, json!({"code.rs": ""}), vec![]).await; + fs.insert_tree(path!("/second"), json!({"other.rs": ""})) + .await; + + let executor = cx.executor(); + let store = project.read_with(cx, |project, _| project.context_server_store()); + store.update(cx, |store, _| { + store.set_context_server_factory(Box::new(move |id, _| { + Arc::new(ContextServer::new( + id.clone(), + Arc::new(create_fake_transport(id.0.to_string(), executor.clone())), + )) + })); + }); + + set_context_server_configuration( + vec![( + server_1_id.0.clone(), + settings::ContextServerSettingsContent::Stdio { + enabled: true, + remote: false, + command: ContextServerCommand { + path: "somebinary".into(), + args: vec!["arg".to_string()], + env: None, + timeout: None, + }, + }, + )], + cx, + ); + + { + let _server_events = assert_server_events( + &store, + vec![ + (server_1_id.clone(), ContextServerStatus::Starting), + (server_1_id.clone(), ContextServerStatus::Running), + ], + cx, + ); + cx.run_until_parked(); + } + + // Witness that adding a worktree triggers the store to refresh available + // servers (via `cx.notify` after `maintain_servers`). Without the + // `WorktreeStoreEvent::WorktreeAdded` subscription in `ContextServerStore`, + // this counter would remain zero. + let notify_count = Rc::new(RefCell::new(0usize)); + let _notify_subscription = cx.update(|cx| { + let count = notify_count.clone(); + cx.observe(&store, move |_, _| { + *count.borrow_mut() += 1; + }) + }); + + { + let _server_events = assert_server_events(&store, vec![], cx); + let _ = project.update(cx, |project, cx| { + project.find_or_create_worktree(path!("/second"), true, cx) + }); + cx.run_until_parked(); + } + + cx.update(|cx| { + assert!( + *notify_count.borrow() > 0, + "Adding a worktree should trigger the context server store to refresh" + ); + assert!( + store.read(cx).server_ids().contains(&server_1_id), + "Configured server list should still include the server after a worktree is added" + ); + assert_eq!( + store.read(cx).status_for_server(&server_1_id), + Some(ContextServerStatus::Running), + "Server should still be running after a worktree is added" + ); + }); +} + #[gpui::test] async fn test_server_ids_includes_disabled_servers(cx: &mut TestAppContext) { const ENABLED_SERVER_ID: &str = "enabled-server"; @@ -810,6 +897,7 @@ async fn test_remote_context_server(cx: &mut TestAppContext) { url: server_url.to_string(), headers: Default::default(), timeout: None, + oauth: None, }, )], cx, @@ -876,6 +964,7 @@ async fn test_context_server_global_timeout(cx: &mut TestAppContext) { url: url::Url::parse("http://localhost:8080").expect("Failed to parse test URL"), headers: Default::default(), timeout: None, + oauth: None, }), &mut async_cx, ) @@ -911,6 +1000,7 @@ async fn test_context_server_per_server_timeout_override(cx: &mut TestAppContext url: "http://localhost:8080".to_string(), headers: Default::default(), timeout: Some(120), + oauth: None, }, )], ) @@ -934,6 +1024,7 @@ async fn test_context_server_per_server_timeout_override(cx: &mut TestAppContext url: url::Url::parse("http://localhost:8080").expect("Failed to parse test URL"), headers: Default::default(), timeout: Some(120), + oauth: None, }), &mut async_cx, ) diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs deleted file mode 100644 index 82135485d3f..00000000000 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ /dev/null @@ -1,224 +0,0 @@ -use anyhow::Result; -use collections::HashMap; -use gpui::{AsyncApp, SharedString, Task}; -use project::agent_server_store::*; -use std::{any::Any, collections::HashSet, fmt::Write as _, path::PathBuf}; -// A simple fake that implements ExternalAgentServer without needing async plumbing. -struct NoopExternalAgent; - -impl ExternalAgentServer for NoopExternalAgent { - fn get_command( - &self, - _extra_args: Vec, - _extra_env: HashMap, - _cx: &mut AsyncApp, - ) -> Task> { - Task::ready(Ok(AgentServerCommand { - path: PathBuf::from("noop"), - args: Vec::new(), - env: None, - })) - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -#[test] -fn external_agent_server_name_display() { - let name = AgentId(SharedString::from("Ext: Tool")); - let mut s = String::new(); - write!(&mut s, "{name}").unwrap(); - assert_eq!(s, "Ext: Tool"); -} - -#[test] -fn sync_extension_agents_removes_previous_extension_entries() { - let mut store = AgentServerStore::collab(); - - // Seed with a couple of agents that will be replaced by extensions - store.external_agents.insert( - AgentId(SharedString::from("foo-agent")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Custom, - None, - None, - ), - ); - store.external_agents.insert( - AgentId(SharedString::from("bar-agent")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Custom, - None, - None, - ), - ); - store.external_agents.insert( - AgentId(SharedString::from("custom")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Custom, - None, - None, - ), - ); - - // Simulate the removal phase: if we're syncing extensions that provide - // "foo-agent" and "bar-agent", those should be removed first - let extension_agent_names: HashSet = ["foo-agent".to_string(), "bar-agent".to_string()] - .into_iter() - .collect(); - - let keys_to_remove: Vec<_> = store - .external_agents - .keys() - .filter(|name| extension_agent_names.contains(name.0.as_ref())) - .cloned() - .collect(); - - for key in keys_to_remove { - store.external_agents.remove(&key); - } - - // Only the custom entry should remain. - let remaining: Vec<_> = store - .external_agents - .keys() - .map(|k| k.0.to_string()) - .collect(); - assert_eq!(remaining, vec!["custom".to_string()]); -} - -#[test] -fn resolve_extension_icon_path_allows_valid_paths() { - // Create a temporary directory structure for testing - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - std::fs::create_dir_all(&ext_dir).unwrap(); - - // Create a valid icon file - let icon_path = ext_dir.join("icon.svg"); - std::fs::write(&icon_path, "").unwrap(); - - // Test that a valid relative path works - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "icon.svg", - ); - assert!(result.is_some()); - assert!(result.unwrap().ends_with("icon.svg")); -} - -#[test] -fn resolve_extension_icon_path_allows_nested_paths() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - let icons_dir = ext_dir.join("assets").join("icons"); - std::fs::create_dir_all(&icons_dir).unwrap(); - - let icon_path = icons_dir.join("logo.svg"); - std::fs::write(&icon_path, "").unwrap(); - - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "assets/icons/logo.svg", - ); - assert!(result.is_some()); - assert!(result.unwrap().ends_with("logo.svg")); -} - -#[test] -fn resolve_extension_icon_path_blocks_path_traversal() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - - // Create two extension directories - let ext1_dir = extensions_dir.join("extension1"); - let ext2_dir = extensions_dir.join("extension2"); - std::fs::create_dir_all(&ext1_dir).unwrap(); - std::fs::create_dir_all(&ext2_dir).unwrap(); - - // Create a file in extension2 - let secret_file = ext2_dir.join("secret.svg"); - std::fs::write(&secret_file, "secret").unwrap(); - - // Try to access extension2's file from extension1 using path traversal - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "extension1", - "../extension2/secret.svg", - ); - assert!( - result.is_none(), - "Path traversal to sibling extension should be blocked" - ); -} - -#[test] -fn resolve_extension_icon_path_blocks_absolute_escape() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - std::fs::create_dir_all(&ext_dir).unwrap(); - - // Create a file outside the extensions directory - let outside_file = temp_dir.path().join("outside.svg"); - std::fs::write(&outside_file, "outside").unwrap(); - - // Try to escape to parent directory - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "../outside.svg", - ); - assert!( - result.is_none(), - "Path traversal to parent directory should be blocked" - ); -} - -#[test] -fn resolve_extension_icon_path_blocks_deep_traversal() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - std::fs::create_dir_all(&ext_dir).unwrap(); - - // Try deep path traversal - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "../../../../../../etc/passwd", - ); - assert!( - result.is_none(), - "Deep path traversal should be blocked (file doesn't exist)" - ); -} - -#[test] -fn resolve_extension_icon_path_returns_none_for_nonexistent() { - let temp_dir = tempfile::tempdir().unwrap(); - let extensions_dir = temp_dir.path(); - let ext_dir = extensions_dir.join("my-extension"); - std::fs::create_dir_all(&ext_dir).unwrap(); - - // Try to access a file that doesn't exist - let result = project::agent_server_store::resolve_extension_icon_path( - extensions_dir, - "my-extension", - "nonexistent.svg", - ); - assert!(result.is_none(), "Nonexistent file should return None"); -} diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs deleted file mode 100644 index 5af2cd229c4..00000000000 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ /dev/null @@ -1,332 +0,0 @@ -use anyhow::Result; -use collections::HashMap; -use gpui::{AppContext, AsyncApp, SharedString, Task, TestAppContext}; -use node_runtime::NodeRuntime; -use project::worktree_store::WorktreeStore; -use project::{agent_server_store::*, worktree_store::WorktreeIdCounter}; -use std::{any::Any, path::PathBuf, sync::Arc}; - -#[test] -fn extension_agent_constructs_proper_display_names() { - // Verify the display name format for extension-provided agents - let name1 = AgentId(SharedString::from("Extension: Agent")); - assert!(name1.0.contains(": ")); - - let name2 = AgentId(SharedString::from("MyExt: MyAgent")); - assert_eq!(name2.0, "MyExt: MyAgent"); - - // Non-extension agents shouldn't have the separator - let custom = AgentId(SharedString::from("custom")); - assert!(!custom.0.contains(": ")); -} - -struct NoopExternalAgent; - -impl ExternalAgentServer for NoopExternalAgent { - fn get_command( - &self, - _extra_args: Vec, - _extra_env: HashMap, - _cx: &mut AsyncApp, - ) -> Task> { - Task::ready(Ok(AgentServerCommand { - path: PathBuf::from("noop"), - args: Vec::new(), - env: None, - })) - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -#[test] -fn sync_removes_only_extension_provided_agents() { - let mut store = AgentServerStore::collab(); - - // Seed with extension agents (contain ": ") and custom agents (don't contain ": ") - store.external_agents.insert( - AgentId(SharedString::from("Ext1: Agent1")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Extension, - None, - None, - ), - ); - store.external_agents.insert( - AgentId(SharedString::from("Ext2: Agent2")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Extension, - None, - None, - ), - ); - store.external_agents.insert( - AgentId(SharedString::from("custom-agent")), - ExternalAgentEntry::new( - Box::new(NoopExternalAgent) as Box, - ExternalAgentSource::Custom, - None, - None, - ), - ); - - // Simulate removal phase - store - .external_agents - .retain(|_, entry| entry.source != ExternalAgentSource::Extension); - - // Only custom-agent should remain - assert_eq!(store.external_agents.len(), 1); - assert!( - store - .external_agents - .contains_key(&AgentId(SharedString::from("custom-agent"))) - ); -} - -#[test] -fn archive_launcher_constructs_with_all_fields() { - use extension::AgentServerManifestEntry; - - let mut env = HashMap::default(); - env.insert("GITHUB_TOKEN".into(), "secret".into()); - - let mut targets = HashMap::default(); - targets.insert( - "darwin-aarch64".to_string(), - extension::TargetConfig { - archive: - "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip" - .into(), - cmd: "./agent".into(), - args: vec![], - sha256: None, - env: Default::default(), - }, - ); - - let _entry = AgentServerManifestEntry { - name: "GitHub Agent".into(), - targets, - env, - icon: None, - }; - - // Verify display name construction - let expected_name = AgentId(SharedString::from("GitHub Agent")); - assert_eq!(expected_name.0, "GitHub Agent"); -} - -#[gpui::test] -async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) { - let fs = fs::FakeFs::new(cx.background_executor.clone()); - let http_client = http_client::FakeHttpClient::with_404_response(); - let worktree_store = - cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx))); - let project_environment = cx.new(|cx| { - crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx) - }); - - let agent = LocalExtensionArchiveAgent { - fs, - http_client, - node_runtime: node_runtime::NodeRuntime::unavailable(), - project_environment, - extension_id: Arc::from("my-extension"), - agent_id: Arc::from("my-agent"), - version: Some(SharedString::from("1.0.0")), - targets: { - let mut map = HashMap::default(); - map.insert( - "darwin-aarch64".to_string(), - extension::TargetConfig { - archive: "https://example.com/my-agent-darwin-arm64.zip".into(), - cmd: "./my-agent".into(), - args: vec!["--serve".into()], - sha256: None, - env: Default::default(), - }, - ); - map - }, - env: { - let mut map = HashMap::default(); - map.insert("PORT".into(), "8080".into()); - map - }, - new_version_available_tx: None, - }; - - // Verify agent is properly constructed - assert_eq!(agent.extension_id.as_ref(), "my-extension"); - assert_eq!(agent.agent_id.as_ref(), "my-agent"); - assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string())); - assert!(agent.targets.contains_key("darwin-aarch64")); -} - -#[test] -fn sync_extension_agents_registers_archive_launcher() { - use extension::AgentServerManifestEntry; - - let expected_name = AgentId(SharedString::from("Release Agent")); - assert_eq!(expected_name.0, "Release Agent"); - - // Verify the manifest entry structure for archive-based installation - let mut env = HashMap::default(); - env.insert("API_KEY".into(), "secret".into()); - - let mut targets = HashMap::default(); - targets.insert( - "linux-x86_64".to_string(), - extension::TargetConfig { - archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(), - cmd: "./release-agent".into(), - args: vec!["serve".into()], - sha256: None, - env: Default::default(), - }, - ); - - let manifest_entry = AgentServerManifestEntry { - name: "Release Agent".into(), - targets: targets.clone(), - env, - icon: None, - }; - - // Verify target config is present - assert!(manifest_entry.targets.contains_key("linux-x86_64")); - let target = manifest_entry.targets.get("linux-x86_64").unwrap(); - assert_eq!(target.cmd, "./release-agent"); -} - -#[gpui::test] -async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) { - let fs = fs::FakeFs::new(cx.background_executor.clone()); - let http_client = http_client::FakeHttpClient::with_404_response(); - let node_runtime = NodeRuntime::unavailable(); - let worktree_store = - cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx))); - let project_environment = cx.new(|cx| { - crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx) - }); - - let agent = LocalExtensionArchiveAgent { - fs: fs.clone(), - http_client, - node_runtime, - project_environment, - extension_id: Arc::from("node-extension"), - agent_id: Arc::from("node-agent"), - version: Some(SharedString::from("1.0.0")), - targets: { - let mut map = HashMap::default(); - map.insert( - "darwin-aarch64".to_string(), - extension::TargetConfig { - archive: "https://example.com/node-agent.zip".into(), - cmd: "node".into(), - args: vec!["index.js".into()], - sha256: None, - env: Default::default(), - }, - ); - map - }, - env: HashMap::default(), - new_version_available_tx: None, - }; - - // Verify that when cmd is "node", it attempts to use the node runtime - assert_eq!(agent.extension_id.as_ref(), "node-extension"); - assert_eq!(agent.agent_id.as_ref(), "node-agent"); - - let target = agent.targets.get("darwin-aarch64").unwrap(); - assert_eq!(target.cmd, "node"); - assert_eq!(target.args, vec!["index.js"]); -} - -#[gpui::test] -async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) { - let fs = fs::FakeFs::new(cx.background_executor.clone()); - let http_client = http_client::FakeHttpClient::with_404_response(); - let node_runtime = NodeRuntime::unavailable(); - let worktree_store = - cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx))); - let project_environment = cx.new(|cx| { - crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx) - }); - - let agent = LocalExtensionArchiveAgent { - fs: fs.clone(), - http_client, - node_runtime, - project_environment, - extension_id: Arc::from("test-ext"), - agent_id: Arc::from("test-agent"), - version: Some(SharedString::from("1.0.0")), - targets: { - let mut map = HashMap::default(); - map.insert( - "darwin-aarch64".to_string(), - extension::TargetConfig { - archive: "https://example.com/test.zip".into(), - cmd: "node".into(), - args: vec![ - "server.js".into(), - "--config".into(), - "./config.json".into(), - ], - sha256: None, - env: Default::default(), - }, - ); - map - }, - env: Default::default(), - new_version_available_tx: None, - }; - - // Verify the agent is configured with relative paths in args - let target = agent.targets.get("darwin-aarch64").unwrap(); - assert_eq!(target.args[0], "server.js"); - assert_eq!(target.args[2], "./config.json"); - // These relative paths will resolve relative to the extraction directory - // when the command is executed -} - -#[test] -fn test_tilde_expansion_in_settings() { - let settings = settings::CustomAgentServerSettings::Custom { - path: PathBuf::from("~/custom/agent"), - args: vec!["serve".into()], - env: Default::default(), - default_mode: None, - default_model: None, - favorite_models: vec![], - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }; - - let converted: CustomAgentServerSettings = settings.into(); - let CustomAgentServerSettings::Custom { - command: AgentServerCommand { path, .. }, - .. - } = converted - else { - panic!("Expected Custom variant"); - }; - - assert!( - !path.to_string_lossy().starts_with("~"), - "Tilde should be expanded for custom agent path" - ); -} diff --git a/crates/project/tests/integration/lsp_store.rs b/crates/project/tests/integration/lsp_store.rs index 7d266ff1365..100042f5d99 100644 --- a/crates/project/tests/integration/lsp_store.rs +++ b/crates/project/tests/integration/lsp_store.rs @@ -1,8 +1,94 @@ use std::path::Path; -use language::{CodeLabel, HighlightId}; +use fs::FakeFs; +use futures::StreamExt; +use gpui::TestAppContext; +use language::{CodeLabel, FakeLspAdapter, HighlightId, rust_lang}; +use lsp::Uri; +use project::{Project, lsp_store::*}; +use serde_json::json; +use util::path; -use project::lsp_store::*; +use crate::init_test; + +#[gpui::test] +async fn test_removing_invisible_worktree_cleans_reused_lsp_bookkeeping(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/the-root"), json!({ "main.rs": "fn main() {}" })) + .await; + fs.insert_tree( + path!("/the-registry"), + json!({ "dep": { "src": { "dep.rs": "pub fn dep() {}" } } }), + ) + .await; + + let project = Project::test(fs, [path!("/the-root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + let (_visible_buffer, _visible_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/main.rs"), cx) + }) + .await + .unwrap(); + fake_servers.next().await.unwrap(); + cx.run_until_parked(); + + let server_id = project.read_with(cx, |project, cx| { + project + .lsp_store() + .read(cx) + .language_server_statuses() + .next() + .unwrap() + .0 + }); + let external_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_via_lsp( + Uri::from_file_path(path!("/the-registry/dep/src/dep.rs")).unwrap(), + server_id, + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let invisible_worktree_id = + external_buffer.read_with(cx, |buffer, cx| buffer.file().unwrap().worktree_id(cx)); + project.read_with(cx, |project, cx| { + let worktree = project.worktree_for_id(invisible_worktree_id, cx).unwrap(); + assert!(!worktree.read(cx).is_visible()); + assert!( + project + .lsp_store() + .read(cx) + .has_language_server_seed_for_worktree(invisible_worktree_id) + ); + }); + + project.update(cx, |project, cx| { + project.remove_worktree(invisible_worktree_id, cx); + }); + cx.run_until_parked(); + + project.read_with(cx, |project, cx| { + let lsp_store = project.lsp_store(); + let lsp_store = lsp_store.read(cx); + assert!( + lsp_store + .language_server_statuses() + .any(|(status_server_id, _)| status_server_id == server_id) + ); + assert!(!lsp_store.has_language_server_seed_for_worktree(invisible_worktree_id)); + }); +} #[test] fn test_glob_literal_prefix() { diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index b93dd8a7274..daaaa0bd2c6 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -5,8 +5,6 @@ mod bookmark_store; mod color_extractor; mod context_server_store; mod debugger; -mod ext_agent_tests; -mod extension_agent_tests; mod git_store; mod image_store; mod lsp_command; @@ -3357,6 +3355,68 @@ async fn test_toggling_enable_language_server(cx: &mut gpui::TestAppContext) { .await; } +#[gpui::test] +async fn test_updating_lsp_settings_sends_one_did_change_configuration( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "" })).await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + let mut fake_rust_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "rust-lsp", + ..Default::default() + }, + ); + language_registry.add(rust_lang()); + + let _rs_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_rust_server = fake_rust_servers.next().await.unwrap(); + + let did_change_count = Arc::new(atomic::AtomicUsize::new(0)); + fake_rust_server.handle_notification::({ + let did_change_count = did_change_count.clone(); + move |_, _| { + did_change_count.fetch_add(1, atomic::Ordering::SeqCst); + } + }); + cx.executor().run_until_parked(); + did_change_count.store(0, atomic::Ordering::SeqCst); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.project.lsp.0.insert( + "rust-lsp".into(), + settings::LspSettings { + settings: Some(json!({ "foo": true })), + ..Default::default() + }, + ); + }); + }) + }); + cx.executor().run_until_parked(); + + assert_eq!( + did_change_count.load(atomic::Ordering::SeqCst), + 1, + "expected exactly one workspace/didChangeConfiguration after a settings change" + ); +} + #[gpui::test(iterations = 3)] async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -6075,7 +6135,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged ] ); @@ -6104,9 +6166,13 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged, - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, ], ); events.lock().clear(); @@ -6121,7 +6187,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged ] ); @@ -6161,7 +6229,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( mem::take(&mut *events.lock()), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged ] ); @@ -6176,7 +6246,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited { is_local: true }, + language::BufferEvent::Edited { + source: language::BufferEditSource::User + }, language::BufferEvent::DirtyChanged ] ); @@ -11231,6 +11303,10 @@ async fn test_rename_work_directory(cx: &mut gpui::TestAppContext) { ) .unwrap(); tree.flush_fs_events(cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + cx.executor().run_until_parked(); repository.read_with(cx, |repository, _| { assert_eq!( @@ -12736,7 +12812,7 @@ fn git_reset(offset: usize, repo: &git2::Repository) { let new_head = commit .parents() .inspect(|parnet| { - parnet.message(); + let _ = parnet.message(); }) .nth(offset) .expect("Not enough history"); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 2cd835c26c5..02aa6cddf28 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -7365,7 +7365,7 @@ fn git_status_indicator(git_status: GitSummary) -> Option<(&'static str, Color)> return Some(("D", Color::Deleted)); } if git_status.worktree.modified > 0 { - return Some(("M", Color::Warning)); + return Some(("M", Color::Modified)); } if git_status.index.deleted > 0 { return Some(("D", Color::Deleted)); diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 6722a300dd8..448f5183b20 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -5467,6 +5467,241 @@ async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) { ); } +/// Mirrors real multi-buffer views (`ProjectDiagnosticsEditor`, `ProjectDiff`, +/// etc.): the workspace `Item` is a thin wrapper that holds an inner `Editor` +/// and re-emits its events. +mod multibuffer_wrapper { + use editor::{Editor, EditorEvent}; + use gpui::{ + App, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, IntoElement, + ParentElement, Render, SharedString, Subscription, Window, div, + }; + use workspace::item::{Item, ItemEvent, TabContentParams}; + + pub struct TestMultibufferWrapper { + pub editor: Entity, + _subscription: Subscription, + } + + impl TestMultibufferWrapper { + pub fn new(editor: Entity, cx: &mut Context) -> Self { + let _subscription = cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()); + }); + Self { + editor, + _subscription, + } + } + } + + impl EventEmitter for TestMultibufferWrapper {} + + impl Focusable for TestMultibufferWrapper { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.read(cx).focus_handle(cx) + } + } + + impl Render for TestMultibufferWrapper { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child(self.editor.clone()) + } + } + + impl Item for TestMultibufferWrapper { + type Event = EditorEvent; + + fn tab_content_text(&self, _: usize, _: &App) -> SharedString { + "wrapper".into() + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), + ) { + self.editor.read(cx).for_each_project_item(cx, f) + } + + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn tab_content(&self, params: TabContentParams, _: &Window, cx: &App) -> gpui::AnyElement { + ui::Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx)) + .into_any_element() + } + } +} + +#[gpui::test] +async fn test_autoreveal_follows_multibuffer_selection(cx: &mut gpui::TestAppContext) { + use editor::{ + Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, ToOffset, + }; + use language::Point; + use multibuffer_wrapper::TestMultibufferWrapper; + + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings + .project_panel + .get_or_insert_default() + .auto_reveal_entries = Some(true); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project_root"), + json!({ + "dir_1": { "file_1.py": "alpha 1\nalpha 2\nalpha 3\n" }, + "dir_2": { "file_2.py": "beta 1\nbeta 2\nbeta 3\n" }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + let buffer_1 = project + .update(cx, |project, cx| { + let project_path = project + .find_project_path("project_root/dir_1/file_1.py", cx) + .unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + let project_path = project + .find_project_path("project_root/dir_2/file_2.py", cx) + .unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.update(|_, cx| { + cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(2, 0)], + 0, + cx, + ); + multi_buffer + }) + }); + + let inner_editor = cx.update(|window, cx| { + cx.new(|cx| { + Editor::new( + EditorMode::full(), + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ) + }) + }); + + // Wrap the multibuffer editor in an `Item`, mirroring real multibuffer + // views (`ProjectDiagnosticsEditor`, `ProjectDiff`, etc.). Auto-reveal + // should follow the inner editor's active buffer. + workspace.update_in(cx, |workspace, window, cx| { + let wrapper = cx.new(|cx| TestMultibufferWrapper::new(inner_editor.clone(), cx)); + workspace.add_item_to_active_pane(Box::new(wrapper), None, true, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " v dir_1", + " file_1.py <== selected <== marked", + " > dir_2", + ], + "When a multibuffer becomes active, its first excerpt's file should be revealed" + ); + + let buffer_2_offset = multi_buffer.read_with(cx, |multi_buffer, cx| { + let snapshot = multi_buffer.snapshot(cx); + let buffer_2_id = buffer_2.read(cx).remote_id(); + let excerpt = snapshot + .excerpts_for_buffer(buffer_2_id) + .next() + .expect("buffer_2 excerpt must exist"); + snapshot + .anchor_in_excerpt(excerpt.context.start) + .expect("excerpt anchor must resolve") + .to_offset(&snapshot) + }); + + inner_editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([buffer_2_offset..buffer_2_offset]); + }); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " v dir_1", + " file_1.py", + " v dir_2", + " file_2.py <== selected <== marked", + ], + "Moving the cursor into a different excerpt buffer should reveal that buffer's entry" + ); + + // Wrappers re-emit inner-editor events through `to_item_events`, so a + // benign `TitleChanged` (e.g. diagnostic summary updates) ultimately + // reaches `Workspace::active_item_path_changed`. The active path should be + // recomputed from the wrapper instead of falling back to a stale selection. + inner_editor.update(cx, |_, cx| cx.emit(EditorEvent::TitleChanged)); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " v dir_1", + " file_1.py", + " v dir_2", + " file_2.py <== selected <== marked", + ], + "Wrapper-level title updates must not clobber the inner editor's reveal" + ); +} + #[gpui::test] async fn test_reveal_in_project_panel_fallback(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); diff --git a/crates/prompt_store/Cargo.toml b/crates/prompt_store/Cargo.toml index 91bcdd251bf..bc4179e5e72 100644 --- a/crates/prompt_store/Cargo.toml +++ b/crates/prompt_store/Cargo.toml @@ -18,10 +18,9 @@ assets.workspace = true chrono.workspace = true collections.workspace = true db.workspace = true -feature_flags.workspace = true + fs.workspace = true futures.workspace = true -fuzzy.workspace = true gpui.workspace = true handlebars.workspace = true heed.workspace = true @@ -29,7 +28,6 @@ language.workspace = true log.workspace = true parking_lot.workspace = true paths.workspace = true -rope.workspace = true serde.workspace = true serde_json.workspace = true strum.workspace = true diff --git a/crates/prompt_store/src/prompt_store.rs b/crates/prompt_store/src/prompt_store.rs index bf9b2f981bd..df270bb0a42 100644 --- a/crates/prompt_store/src/prompt_store.rs +++ b/crates/prompt_store/src/prompt_store.rs @@ -6,24 +6,17 @@ use chrono::{DateTime, Utc}; use collections::HashMap; use futures::FutureExt as _; use futures::future::Shared; -use fuzzy::StringMatchCandidate; -use gpui::{ - App, AppContext, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString, Task, -}; + +use gpui::{App, AppContext, Entity, Global, ReadGlobal, SharedString, Task}; use heed::{ Database, RoTxn, types::{SerdeBincode, SerdeJson, Str}, }; use parking_lot::RwLock; pub use prompts::*; -use rope::Rope; + use serde::{Deserialize, Serialize}; -use std::{ - cmp::Reverse, - future::Future, - path::PathBuf, - sync::{Arc, atomic::AtomicBool}, -}; +use std::{future::Future, path::PathBuf, sync::Arc}; use strum::{EnumIter, IntoEnumIterator as _}; use text::LineEnding; use util::ResultExt; @@ -122,15 +115,6 @@ impl PromptId { pub fn is_built_in(&self) -> bool { matches!(self, Self::BuiltIn { .. }) } - - pub fn can_edit(&self) -> bool { - match self { - Self::User { .. } => true, - Self::BuiltIn(builtin) => match builtin { - BuiltInPrompt::CommitMessage => true, - }, - } - } } impl From for PromptId { @@ -173,14 +157,9 @@ impl std::fmt::Display for PromptId { pub struct PromptStore { env: heed::Env, metadata_cache: RwLock, - metadata: Database, SerdeJson>, bodies: Database, Str>, } -pub struct PromptsUpdatedEvent; - -impl EventEmitter for PromptStore {} - #[derive(Default)] struct MetadataCache { metadata: Vec, @@ -220,21 +199,6 @@ impl MetadataCache { Ok(cache) } - fn insert(&mut self, metadata: PromptMetadata) { - self.metadata_by_id.insert(metadata.id, metadata.clone()); - if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) { - *old_metadata = metadata; - } else { - self.metadata.push(metadata); - } - self.sort(); - } - - fn remove(&mut self, id: PromptId) { - self.metadata.retain(|metadata| metadata.id != id); - self.metadata_by_id.remove(&id); - } - fn sort(&mut self) { self.metadata.sort_unstable_by(|a, b| { a.title @@ -275,7 +239,6 @@ impl PromptStore { Ok(PromptStore { env: db_env, metadata_cache: RwLock::new(metadata_cache), - metadata, bodies, }) }) @@ -363,219 +326,6 @@ impl PromptStore { pub fn all_prompt_metadata(&self) -> Vec { self.metadata_cache.read().metadata.clone() } - - pub fn default_prompt_metadata(&self) -> Vec { - return self - .metadata_cache - .read() - .metadata - .iter() - .filter(|metadata| metadata.default) - .cloned() - .collect::>(); - } - - pub fn delete(&self, id: PromptId, cx: &Context) -> Task> { - self.metadata_cache.write().remove(id); - - let db_connection = self.env.clone(); - let bodies = self.bodies; - let metadata = self.metadata; - - let task = cx.background_spawn(async move { - let mut txn = db_connection.write_txn()?; - - metadata.delete(&mut txn, &id)?; - bodies.delete(&mut txn, &id)?; - - if let PromptId::User { uuid } = id { - let prompt_id_v1 = PromptIdV1::from(uuid); - - if let Some(metadata_v1_db) = db_connection - .open_database::, SerdeBincode<()>>( - &txn, - Some("metadata"), - )? - { - metadata_v1_db.delete(&mut txn, &prompt_id_v1)?; - } - - if let Some(bodies_v1_db) = db_connection - .open_database::, SerdeBincode<()>>( - &txn, - Some("bodies"), - )? - { - bodies_v1_db.delete(&mut txn, &prompt_id_v1)?; - } - } - - txn.commit()?; - anyhow::Ok(()) - }); - - cx.spawn(async move |this, cx| { - task.await?; - this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok(); - anyhow::Ok(()) - }) - } - - pub fn metadata(&self, id: PromptId) -> Option { - self.metadata_cache.read().metadata_by_id.get(&id).cloned() - } - - pub fn first(&self) -> Option { - self.metadata_cache.read().metadata.first().cloned() - } - - pub fn id_for_title(&self, title: &str) -> Option { - let metadata_cache = self.metadata_cache.read(); - let metadata = metadata_cache - .metadata - .iter() - .find(|metadata| metadata.title.as_deref() == Some(title))?; - Some(metadata.id) - } - - pub fn search( - &self, - query: String, - cancellation_flag: Arc, - cx: &App, - ) -> Task> { - let cached_metadata = self.metadata_cache.read().metadata.clone(); - let executor = cx.background_executor().clone(); - cx.background_spawn(async move { - let mut matches = if query.is_empty() { - cached_metadata - } else { - let candidates = cached_metadata - .iter() - .enumerate() - .filter_map(|(ix, metadata)| { - Some(StringMatchCandidate::new(ix, metadata.title.as_ref()?)) - }) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - 100, - &cancellation_flag, - executor, - ) - .await; - matches - .into_iter() - .map(|mat| cached_metadata[mat.candidate_id].clone()) - .collect() - }; - matches.sort_by_key(|metadata| Reverse(metadata.default)); - matches - }) - } - - pub fn save( - &self, - id: PromptId, - title: Option, - default: bool, - body: Rope, - cx: &Context, - ) -> Task> { - if !id.can_edit() { - return Task::ready(Err(anyhow!("this prompt cannot be edited"))); - } - - let body = body.to_string(); - let is_default_content = id - .as_built_in() - .is_some_and(|builtin| body.trim() == builtin.default_content().trim()); - - let metadata = if let Some(builtin) = id.as_built_in() { - PromptMetadata::builtin(builtin) - } else { - PromptMetadata { - id, - title, - default, - saved_at: Utc::now(), - } - }; - - self.metadata_cache.write().insert(metadata.clone()); - - let db_connection = self.env.clone(); - let bodies = self.bodies; - let metadata_db = self.metadata; - - let task = cx.background_spawn(async move { - let mut txn = db_connection.write_txn()?; - - if is_default_content { - metadata_db.delete(&mut txn, &id)?; - bodies.delete(&mut txn, &id)?; - } else { - metadata_db.put(&mut txn, &id, &metadata)?; - bodies.put(&mut txn, &id, &body)?; - } - - txn.commit()?; - - anyhow::Ok(()) - }); - - cx.spawn(async move |this, cx| { - task.await?; - this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok(); - anyhow::Ok(()) - }) - } - - pub fn save_metadata( - &self, - id: PromptId, - mut title: Option, - default: bool, - cx: &Context, - ) -> Task> { - let mut cache = self.metadata_cache.write(); - - if !id.can_edit() { - title = cache - .metadata_by_id - .get(&id) - .and_then(|metadata| metadata.title.clone()); - } - - let prompt_metadata = PromptMetadata { - id, - title, - default, - saved_at: Utc::now(), - }; - - cache.insert(prompt_metadata.clone()); - - let db_connection = self.env.clone(); - let metadata = self.metadata; - - let task = cx.background_spawn(async move { - let mut txn = db_connection.write_txn()?; - metadata.put(&mut txn, &id, &prompt_metadata)?; - txn.commit()?; - - anyhow::Ok(()) - }); - - cx.spawn(async move |this, cx| { - task.await?; - this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok(); - anyhow::Ok(()) - }) - } } /// Deprecated: Legacy V1 prompt ID format, used only for migrating data from old databases. Use `PromptId` instead. @@ -608,7 +358,7 @@ mod tests { use gpui::TestAppContext; #[gpui::test] - async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) { + async fn test_built_in_prompt_load(cx: &mut TestAppContext) { cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); @@ -632,265 +382,14 @@ mod tests { "Loading a built-in prompt not in DB should return default content" ); - let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id)); - assert!( - metadata.is_some(), - "Built-in prompt should always have metadata" - ); assert!( store.read_with(cx, |store, _| { store - .metadata_cache - .read() - .metadata_by_id - .contains_key(&commit_message_id) + .all_prompt_metadata() + .iter() + .any(|metadata| metadata.id == commit_message_id) }), "Built-in prompt should always be in cache" ); - - let custom_content = "Custom commit message prompt"; - store - .update(cx, |store, cx| { - store.save( - commit_message_id, - Some("Commit message".into()), - false, - Rope::from(custom_content), - cx, - ) - }) - .await - .unwrap(); - - let loaded_custom = store - .update(cx, |store, cx| store.load(commit_message_id, cx)) - .await - .unwrap(); - assert_eq!( - loaded_custom.trim(), - custom_content.trim(), - "Custom content should be loaded after saving" - ); - - assert!( - store - .read_with(cx, |store, _| store.metadata(commit_message_id)) - .is_some(), - "Built-in prompt should have metadata after customization" - ); - - store - .update(cx, |store, cx| { - store.save( - commit_message_id, - Some("Commit message".into()), - false, - Rope::from(BuiltInPrompt::CommitMessage.default_content()), - cx, - ) - }) - .await - .unwrap(); - - let metadata_after_reset = - store.read_with(cx, |store, _| store.metadata(commit_message_id)); - assert!( - metadata_after_reset.is_some(), - "Built-in prompt should still have metadata after reset" - ); - assert_eq!( - metadata_after_reset - .as_ref() - .and_then(|m| m.title.as_ref().map(|t| t.as_ref())), - Some("Commit message"), - "Built-in prompt should have default title after reset" - ); - - let loaded_after_reset = store - .update(cx, |store, cx| store.load(commit_message_id, cx)) - .await - .unwrap(); - let mut expected_content_after_reset = - BuiltInPrompt::CommitMessage.default_content().to_string(); - LineEnding::normalize(&mut expected_content_after_reset); - assert_eq!( - loaded_after_reset.trim(), - expected_content_after_reset.trim(), - "Content should be back to default after saving default content" - ); - } - - /// Test that the prompt store initializes successfully even when the database - /// contains records with incompatible/undecodable PromptId keys (e.g., from - /// a different branch that used a different serialization format). - /// - /// This is a regression test for the "fail-open" behavior: we should skip - /// bad records rather than failing the entire store initialization. - #[gpui::test] - async fn test_prompt_store_handles_incompatible_db_records(cx: &mut TestAppContext) { - cx.executor().allow_parking(); - - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("prompts-db-with-bad-records"); - std::fs::create_dir_all(&db_path).unwrap(); - - // First, create the DB and write an incompatible record directly. - // We simulate a record written by a different branch that used - // `{"kind":"CommitMessage"}` instead of `{"kind":"BuiltIn", ...}`. - { - let db_env = unsafe { - heed::EnvOpenOptions::new() - .map_size(1024 * 1024 * 1024) - .max_dbs(4) - .open(&db_path) - .unwrap() - }; - - let mut txn = db_env.write_txn().unwrap(); - // Create the metadata.v2 database with raw bytes so we can write - // an incompatible key format. - let metadata_db: Database = db_env - .create_database(&mut txn, Some("metadata.v2")) - .unwrap(); - - // Write an incompatible PromptId key: `{"kind":"CommitMessage"}` - // This is the old/branch format that current code can't decode. - let bad_key = br#"{"kind":"CommitMessage"}"#; - let dummy_metadata = br#"{"id":{"kind":"CommitMessage"},"title":"Bad Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#; - metadata_db.put(&mut txn, bad_key, dummy_metadata).unwrap(); - - // Also write a valid record to ensure we can still read good data. - let good_key = br#"{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"}"#; - let good_metadata = br#"{"id":{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"},"title":"Good Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#; - metadata_db.put(&mut txn, good_key, good_metadata).unwrap(); - - txn.commit().unwrap(); - } - - // Now try to create a PromptStore from this DB. - // With fail-open behavior, this should succeed and skip the bad record. - // Without fail-open, this would return an error. - let store_result = cx.update(|cx| PromptStore::new(db_path, cx)).await; - - assert!( - store_result.is_ok(), - "PromptStore should initialize successfully even with incompatible DB records. \ - Got error: {:?}", - store_result.err() - ); - - let store = cx.new(|_cx| store_result.unwrap()); - - // Verify the good record was loaded. - let good_id = PromptId::User { - uuid: UserPromptId("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()), - }; - let metadata = store.read_with(cx, |store, _| store.metadata(good_id)); - assert!( - metadata.is_some(), - "Valid records should still be loaded after skipping bad ones" - ); - assert_eq!( - metadata - .as_ref() - .and_then(|m| m.title.as_ref().map(|t| t.as_ref())), - Some("Good Record"), - "Valid record should have correct title" - ); - } - - #[gpui::test] - async fn test_deleted_prompt_does_not_reappear_after_migration(cx: &mut TestAppContext) { - cx.executor().allow_parking(); - - let temp_dir = tempfile::tempdir().unwrap(); - let db_path = temp_dir.path().join("prompts-db-v1-migration"); - std::fs::create_dir_all(&db_path).unwrap(); - - let prompt_uuid: Uuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap(); - let prompt_id_v1 = PromptIdV1(prompt_uuid); - let prompt_id_v2 = PromptId::User { - uuid: UserPromptId(prompt_uuid), - }; - - // Create V1 database with a prompt - { - let db_env = unsafe { - heed::EnvOpenOptions::new() - .map_size(1024 * 1024 * 1024) - .max_dbs(4) - .open(&db_path) - .unwrap() - }; - - let mut txn = db_env.write_txn().unwrap(); - - let metadata_v1_db: Database, SerdeBincode> = - db_env.create_database(&mut txn, Some("metadata")).unwrap(); - - let bodies_v1_db: Database, SerdeBincode> = - db_env.create_database(&mut txn, Some("bodies")).unwrap(); - - let metadata_v1 = PromptMetadataV1 { - id: prompt_id_v1.clone(), - title: Some("V1 Prompt".into()), - default: false, - saved_at: Utc::now(), - }; - - metadata_v1_db - .put(&mut txn, &prompt_id_v1, &metadata_v1) - .unwrap(); - bodies_v1_db - .put(&mut txn, &prompt_id_v1, &"V1 prompt body".to_string()) - .unwrap(); - - txn.commit().unwrap(); - } - - // Migrate V1 to V2 by creating PromptStore - let store = cx - .update(|cx| PromptStore::new(db_path.clone(), cx)) - .await - .unwrap(); - let store = cx.new(|_cx| store); - - // Verify the prompt was migrated - let metadata = store.read_with(cx, |store, _| store.metadata(prompt_id_v2)); - assert!(metadata.is_some(), "V1 prompt should be migrated to V2"); - assert_eq!( - metadata - .as_ref() - .and_then(|m| m.title.as_ref().map(|t| t.as_ref())), - Some("V1 Prompt"), - "Migrated prompt should have correct title" - ); - - // Delete the prompt - store - .update(cx, |store, cx| store.delete(prompt_id_v2, cx)) - .await - .unwrap(); - - // Verify prompt is deleted - let metadata_after_delete = store.read_with(cx, |store, _| store.metadata(prompt_id_v2)); - assert!( - metadata_after_delete.is_none(), - "Prompt should be deleted from V2" - ); - - drop(store); - - // "Restart" by creating a new PromptStore from the same path - let store_after_restart = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap(); - let store_after_restart = cx.new(|_cx| store_after_restart); - - // Test the prompt does not reappear - let metadata_after_restart = - store_after_restart.read_with(cx, |store, _| store.metadata(prompt_id_v2)); - assert!( - metadata_after_restart.is_none(), - "Deleted prompt should NOT reappear after restart/migration" - ); } } diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index b3194dd1d61..328ee93af02 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -19,8 +19,6 @@ use util::{ ResultExt, get_default_system_shell_preferring_bash, rel_path::RelPath, shell::ShellKind, }; -use crate::UserPromptId; - pub const RULES_FILE_NAMES: &[&str] = &[ ".rules", ".cursorrules", @@ -38,14 +36,11 @@ pub struct ProjectContext { pub worktrees: Vec, /// Whether any worktree has a rules_file. Provided as a field because handlebars can't do this. pub has_rules: bool, - pub user_rules: Vec, - /// `!user_rules.is_empty()` - provided as a field because handlebars can't do this. - pub has_user_rules: bool, pub os: String, pub arch: String, pub shell: String, - // Similarly to `has_rules` / `has_user_rules`, `has_skills` is a - // derived flag exposed to the handlebars template (which can't do + // Similarly to `has_rules`, `has_skills` is a derived flag exposed + // to the handlebars template (which can't do // `!skills.is_empty()`). These are `pub(crate)` so the only way to // set them from outside is via `with_skills`, which keeps the two // fields in sync. @@ -54,15 +49,13 @@ pub struct ProjectContext { } impl ProjectContext { - pub fn new(worktrees: Vec, default_user_rules: Vec) -> Self { + pub fn new(worktrees: Vec) -> Self { let has_rules = worktrees .iter() .any(|worktree| worktree.rules_file.is_some()); Self { worktrees, has_rules, - has_user_rules: !default_user_rules.is_empty(), - user_rules: default_user_rules, os: std::env::consts::OS.to_string(), arch: std::env::consts::ARCH.to_string(), shell: ShellKind::new(&get_default_system_shell_preferring_bash(), cfg!(windows)) @@ -91,13 +84,6 @@ impl ProjectContext { } } -#[derive(Debug, Clone, Eq, PartialEq, Serialize)] -pub struct UserRulesContext { - pub uuid: UserPromptId, - pub title: Option, - pub contents: String, -} - #[derive(Debug, Clone, Eq, PartialEq, Serialize)] pub struct WorktreeContext { pub root_name: String, @@ -163,17 +149,18 @@ mod tests { directory_path: PathBuf::from("/skills/oversized"), skill_file_path: PathBuf::from("/skills/oversized/SKILL.md"), disable_model_invocation: false, + embedded_body: None, }; let summary = SkillSummary::from(&skill); - let context = ProjectContext::new(vec![], vec![]).with_skills(vec![summary]); + let context = ProjectContext::new(vec![]).with_skills(vec![summary]); assert_eq!(context.skills.len(), 1); assert_eq!(context.skills[0].description, huge_description); } #[test] fn test_empty_skills_sets_has_skills_false() { - let context = ProjectContext::new(vec![], vec![]); + let context = ProjectContext::new(vec![]); assert!(!context.has_skills); assert!(context.skills.is_empty()); } diff --git a/crates/prompt_store/src/rules_to_skills_migration.rs b/crates/prompt_store/src/rules_to_skills_migration.rs index 13dba07207d..acc8509097c 100644 --- a/crates/prompt_store/src/rules_to_skills_migration.rs +++ b/crates/prompt_store/src/rules_to_skills_migration.rs @@ -24,29 +24,28 @@ //! (still using Zed's shipped default content) are skipped so we don't //! pollute AGENTS.md with text the user never wrote. //! -//! Both migrations are gated by: -//! -//! * the `skills` feature flag — users without it never have their Rules -//! touched in any way; -//! * a single global "migration already ran" flag persisted in -//! [`GlobalKeyValueStore`] — keyed by [`MIGRATION_DONE_KEY`], so a -//! shared home directory only gets populated once per machine even -//! across release channels. +//! Both migrations are gated by a single global "migration already ran" +//! flag persisted in [`GlobalKeyValueStore`] — keyed by +//! [`MIGRATION_DONE_KEY`], so a shared home directory only gets +//! populated once per machine even across release channels. //! //! The migration is intentionally non-destructive: rule rows in the LMDB //! database are left in place after the migration. That way users can //! still see and edit their Rules via the existing UI, and a user who //! downgrades to a Zed build without skills support won't lose anything. +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use agent_skills::{SKILL_FILE_NAME, global_skills_dir, slugify_skill_name}; +use agent_skills::{ + SKILL_FILE_NAME, global_skills_dir, parse_skill_file_content, slugify_skill_name, +}; use anyhow::{Context as _, Result}; use db::kvp::GlobalKeyValueStore; -use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag}; use fs::Fs; +use futures::StreamExt as _; use gpui::{App, AsyncApp, Entity, TaskExt as _}; use serde::{Deserialize, Serialize}; use util::ResultExt as _; @@ -62,15 +61,15 @@ pub const MIGRATION_DONE_KEY: &str = "rules_to_skills_migration_done"; /// Global KVP key for the JSON-serialized [`MigrationResult`] produced by /// the most recent migration run — the lists of source-Rule titles that -/// were migrated to each destination. The title-bar banner and its -/// explainer modal read this to decide what (if anything) to tell the -/// user about what changed. +/// were migrated to each destination. The skills announcement toast +/// reads this to decide whether to mention the migration in its copy. pub const MIGRATION_RESULT_KEY: &str = "rules_to_skills_migration_result"; /// A persistent record of what the rules-to-skills migration actually /// migrated. Persisted in [`GlobalKeyValueStore`] under -/// [`MIGRATION_RESULT_KEY`] and read back by the announcement UI so the -/// modal can list specific rule names instead of vaguely gesturing. +/// [`MIGRATION_RESULT_KEY`] and read back by the skills announcement +/// toast so it can tailor its copy to users who actually had Rules to +/// migrate. /// /// All three lists hold the *original* user-facing Rule titles, not the /// derived skill slug or any other transformed identifier — those are @@ -92,10 +91,9 @@ pub struct MigrationResult { impl MigrationResult { /// `true` if the migration didn't actually move any Rule anywhere — - /// i.e. the user had no Rules of any kind to migrate. The - /// announcement banner/modal uses this to switch between the - /// "Introducing: Skills" generic intro and the "Skills have replaced - /// Rules" migration summary. + /// i.e. the user had no Rules of any kind to migrate. The skills + /// announcement toast uses this to omit the migration-flavored + /// bullet for users who never had any Rules. pub fn is_empty(&self) -> bool { self.skill_names.is_empty() && self.agents_md_names.is_empty() @@ -151,13 +149,9 @@ static MIGRATION_TASK_SPAWNED: AtomicBool = AtomicBool::new(false); /// Migrate non-Default user rules to global Skills, if not already done. /// /// Safe to call on every startup — short-circuits immediately when the -/// migration has already run, when another invocation in this process -/// has already started it, or when the user doesn't have the `skills` -/// feature flag enabled. +/// migration has already run or when another invocation in this process +/// has already started it. pub fn migrate_rules_to_skills_if_needed(fs: Arc, cx: &mut App) { - if !cx.has_flag::() { - return; - } if migration_done() { return; } @@ -251,6 +245,7 @@ async fn migrate_non_default_rules_to_skills( return Vec::new(); } let skills_dir = global_skills_dir(); + let mut existing_skill_contents = existing_skill_contents(fs, &skills_dir).await; let mut migrated = Vec::with_capacity(rules.len()); for (id, title) in rules { let body = match load_rule_body(prompt_store, cx, id, &title).await { @@ -264,7 +259,9 @@ async fn migrate_non_default_rules_to_skills( ); continue; }; - match write_migrated_skill(fs, &skills_dir, &slug, &body).await { + match write_migrated_skill(fs, &skills_dir, &slug, &body, &mut existing_skill_contents) + .await + { Ok(()) => migrated.push(title), Err(err) => { log::warn!("Failed to write skill for rule {title:?}: {err:#}"); @@ -422,11 +419,9 @@ async fn write_migration_result(result: &MigrationResult) { /// /// Three cases: /// -/// 1. `//SKILL.md` already exists with byte-identical -/// content to what we'd write — likely because the migration ran -/// successfully on a previous launch and is now being asked to -/// re-migrate the same source rule. Skip silently; don't create a -/// `-2` duplicate of the same content. +/// 1. Any existing skill file already matches the content we'd write, or +/// already has the same instruction body as this rule. Skip silently; +/// don't create a duplicate skill. /// 2. `//` doesn't exist — happy path. Create it and /// write the SKILL.md there. /// 3. `//` exists with *different* content (a real @@ -438,39 +433,59 @@ async fn write_migrated_skill( skills_dir: &Path, slug: &str, body: &str, + existing_skill_contents: &mut ExistingSkillContents, ) -> Result<()> { - let primary_dir = skills_dir.join(slug); - let primary_file = primary_dir.join(SKILL_FILE_NAME); - let primary_content = format_skill_file(slug, body); - - // Case 1: primary exists with identical content — nothing to do. - // Compare trimmed so a stray leading/trailing newline difference - // (which is meaningless inside a SKILL.md) doesn't trick us into - // generating a `-N` duplicate. - if fs.is_file(&primary_file).await - && fs - .load(&primary_file) - .await - .ok() - .is_some_and(|existing| existing.trim() == primary_content.trim()) - { + let trimmed_body = body.trim(); + if existing_skill_contents.bodies.contains(trimmed_body) { return Ok(()); } // Cases 2 and 3: find a free directory (the primary if free, // otherwise a `-N` suffix) and write the SKILL.md there. let (name, dir) = pick_available_skill_dir(fs, skills_dir, slug).await?; + let content = format_skill_file(&name, body); + let trimmed_content = content.trim(); + if existing_skill_contents.files.contains(trimmed_content) { + return Ok(()); + } + fs.create_dir(&dir).await?; - let content = if name == slug { - primary_content - } else { - format_skill_file(&name, body) - }; let skill_file_path = dir.join(SKILL_FILE_NAME); fs.write(&skill_file_path, content.as_bytes()).await?; + existing_skill_contents + .files + .insert(trimmed_content.to_string()); + existing_skill_contents + .bodies + .insert(trimmed_body.to_string()); Ok(()) } +#[derive(Default)] +struct ExistingSkillContents { + files: HashSet, + bodies: HashSet, +} + +async fn existing_skill_contents(fs: &dyn Fs, skills_dir: &Path) -> ExistingSkillContents { + let mut contents = ExistingSkillContents::default(); + let Ok(mut entries) = fs.read_dir(skills_dir).await else { + return contents; + }; + while let Some(entry) = entries.next().await { + let Ok(skill_dir) = entry else { continue }; + let Ok(file_content) = fs.load(&skill_dir.join(SKILL_FILE_NAME)).await else { + continue; + }; + contents.files.insert(file_content.trim().to_string()); + let Ok((_metadata, body)) = parse_skill_file_content(&file_content) else { + continue; + }; + contents.bodies.insert(body.trim().to_string()); + } + contents +} + /// Build the SKILL.md file contents for a migrated rule. fn format_skill_file(name: &str, body: &str) -> String { let mut output = format!( @@ -522,6 +537,16 @@ mod tests { use fs::FakeFs; use gpui::TestAppContext; + async fn write_migrated_skill_for_test( + fs: &dyn Fs, + skills_dir: &Path, + slug: &str, + body: &str, + ) -> Result<()> { + let mut existing_skill_contents = existing_skill_contents(fs, skills_dir).await; + write_migrated_skill(fs, skills_dir, slug, body, &mut existing_skill_contents).await + } + #[test] fn format_skill_file_includes_disable_model_invocation() { let content = format_skill_file("my-rule", "Body text."); @@ -586,7 +611,7 @@ mod tests { let skills_dir = PathBuf::from("/skills"); fs.create_dir(&skills_dir).await.unwrap(); - write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Body.") + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Body.") .await .unwrap(); @@ -618,7 +643,7 @@ mod tests { ) .await; - write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Body.") + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Body.") .await .unwrap(); @@ -650,7 +675,7 @@ mod tests { ) .await; - write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Body.") + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Body.") .await .unwrap(); @@ -676,7 +701,7 @@ mod tests { ) .await; - write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Migrated body.") + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Migrated body.") .await .unwrap(); @@ -694,6 +719,34 @@ mod tests { assert!(migrated.contains("disable-model-invocation: true")); } + #[gpui::test] + async fn write_migrated_skill_skips_when_any_existing_skill_has_same_body( + cx: &mut TestAppContext, + ) { + let fs = FakeFs::new(cx.executor()); + let skills_dir = PathBuf::from("/skills"); + fs.create_dir(&skills_dir.join("unrelated-skill")) + .await + .unwrap(); + let existing = format_skill_file("unrelated-skill", "Migrated body."); + fs.insert_file( + &skills_dir.join("unrelated-skill").join(SKILL_FILE_NAME), + existing.as_bytes().to_vec(), + ) + .await; + + write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Migrated body.") + .await + .unwrap(); + + assert!(!fs.is_dir(&skills_dir.join("my-rule")).await); + let unrelated = fs + .load(&skills_dir.join("unrelated-skill").join(SKILL_FILE_NAME)) + .await + .unwrap(); + assert_eq!(unrelated, existing); + } + #[test] fn format_default_rules_section_renders_headings_and_bodies() { let rules = vec![ diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 8c7d09eb4b0..ac7e39a83d9 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -6,6 +6,7 @@ import "worktree.proto"; message GitBranchesResponse { repeated Branch branches = 1; + optional string error = 2; } message UpdateDiffBases { @@ -130,6 +131,7 @@ message UpdateRepository { repeated Branch branch_list = 18; optional string repository_dir_abs_path = 19; optional string common_dir_abs_path = 20; + optional string branch_list_error = 21; } message RemoveRepository { diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 813f9e9ec65..ff9ec4d4e64 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -8,6 +8,7 @@ message GetDefinition { uint64 buffer_id = 2; Anchor position = 3; repeated VectorClockEntry version = 4; + bool workspace_only = 5; } message GetDefinitionResponse { @@ -30,6 +31,7 @@ message GetTypeDefinition { uint64 buffer_id = 2; Anchor position = 3; repeated VectorClockEntry version = 4; + bool workspace_only = 5; } message GetTypeDefinitionResponse { @@ -120,6 +122,35 @@ message GetDocumentSymbolsResponse { repeated DocumentSymbol symbols = 1; } +message GetDocumentLinks { + uint64 project_id = 1; + uint64 buffer_id = 2; + repeated VectorClockEntry version = 3; +} + +message GetDocumentLinksResponse { + repeated DocumentLinkProto links = 1; + repeated VectorClockEntry version = 2; +} + +message DocumentLinkProto { + optional AnchorRange range = 1; + optional string target = 2; + optional string tooltip = 3; + optional string data = 4; +} + +message ResolveDocumentLink { + uint64 project_id = 1; + uint64 buffer_id = 2; + uint64 language_server_id = 3; + bytes lsp_link = 4; +} + +message ResolveDocumentLinkResponse { + bytes lsp_link = 1; +} + message DocumentSymbol { string name = 1; int32 kind = 2; @@ -851,6 +882,7 @@ message LspQuery { SemanticTokens semantic_tokens = 16; GetFoldingRanges get_folding_ranges = 17; GetDocumentSymbols get_document_symbols = 18; + GetDocumentLinks get_document_links = 19; } } @@ -877,6 +909,7 @@ message LspResponse { SemanticTokensResponse semantic_tokens_response = 14; GetFoldingRangesResponse get_folding_ranges_response = 15; GetDocumentSymbolsResponse get_document_symbols_response = 16; + GetDocumentLinksResponse get_document_links_response = 17; } uint64 server_id = 7; } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index a0fde40a84b..ab5ea5ce729 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -484,7 +484,11 @@ message Envelope { SearchCommits search_commits = 449; SearchCommitsResponse search_commits_response = 450; GetInitialGraphData get_initial_graph_data = 451; - GetInitialGraphDataResponse get_initial_graph_data_response = 452; // current max + GetInitialGraphDataResponse get_initial_graph_data_response = 452; + GetDocumentLinks get_document_links = 453; + GetDocumentLinksResponse get_document_links_response = 454; + ResolveDocumentLink resolve_document_link = 455; + ResolveDocumentLinkResponse resolve_document_link_response = 456; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 49b9db0d5c3..2836c28a552 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -224,6 +224,10 @@ messages!( (GetDocumentColorResponse, Background), (GetColorPresentation, Background), (GetColorPresentationResponse, Background), + (GetDocumentLinks, Background), + (GetDocumentLinksResponse, Background), + (ResolveDocumentLink, Background), + (ResolveDocumentLinkResponse, Background), (GetFoldingRanges, Background), (GetFoldingRangesResponse, Background), (RefreshCodeLens, Background), @@ -469,6 +473,8 @@ request_messages!( ), (ResolveInlayHint, ResolveInlayHintResponse), (GetDocumentColor, GetDocumentColorResponse), + (GetDocumentLinks, GetDocumentLinksResponse), + (ResolveDocumentLink, ResolveDocumentLinkResponse), (GetFoldingRanges, GetFoldingRangesResponse), (GetColorPresentation, GetColorPresentationResponse), (RespondToChannelInvite, Ack), @@ -595,6 +601,7 @@ lsp_messages!( (GetDocumentColor, GetDocumentColorResponse, true), (GetFoldingRanges, GetFoldingRangesResponse, true), (GetDocumentSymbols, GetDocumentSymbolsResponse, true), + (GetDocumentLinks, GetDocumentLinksResponse, true), (GetHover, GetHoverResponse, true), (GetCodeActions, GetCodeActionsResponse, true), (GetSignatureHelp, GetSignatureHelpResponse, true), @@ -628,6 +635,8 @@ entity_messages!( CreateImageForPeer, CreateProjectEntry, GetDocumentColor, + GetDocumentLinks, + ResolveDocumentLink, GetFoldingRanges, DeleteProjectEntry, ExpandProjectEntry, @@ -926,6 +935,7 @@ pub fn split_repository_update( let mut updated_statuses_iter = mem::take(&mut update.updated_statuses).into_iter().fuse(); let mut removed_statuses_iter = mem::take(&mut update.removed_statuses).into_iter().fuse(); let branch_list = mem::take(&mut update.branch_list); + let branch_list_error = update.branch_list_error.take(); std::iter::from_fn({ let update = update.clone(); move || { @@ -944,6 +954,7 @@ pub fn split_repository_update( updated_statuses, removed_statuses, branch_list: Vec::new(), + branch_list_error: None, is_last_update: false, ..update.clone() }) @@ -953,6 +964,7 @@ pub fn split_repository_update( updated_statuses: Vec::new(), removed_statuses: Vec::new(), branch_list, + branch_list_error, is_last_update: true, ..update }]) @@ -976,6 +988,7 @@ impl LspQuery { Some(lsp_query::Request::GetDocumentColor(_)) => ("GetDocumentColor", false), Some(lsp_query::Request::GetFoldingRanges(_)) => ("GetFoldingRanges", false), Some(lsp_query::Request::GetDocumentSymbols(_)) => ("GetDocumentSymbols", false), + Some(lsp_query::Request::GetDocumentLinks(_)) => ("GetDocumentLinks", false), Some(lsp_query::Request::InlayHints(_)) => ("InlayHints", false), Some(lsp_query::Request::SemanticTokens(_)) => ("SemanticTokens", false), None => ("", true), @@ -1023,6 +1036,7 @@ mod tests { ref_name: "refs/heads/main".into(), ..Default::default() }], + branch_list_error: Some("partial branch scan".into()), ..Default::default() }; @@ -1033,6 +1047,12 @@ mod tests { assert!(chunks[1].branch_list.is_empty()); assert_eq!(chunks[2].branch_list.len(), 1); assert_eq!(chunks[2].branch_list[0].ref_name, "refs/heads/main"); + assert_eq!(chunks[0].branch_list_error, None); + assert_eq!(chunks[1].branch_list_error, None); + assert_eq!( + chunks[2].branch_list_error.as_deref(), + Some("partial branch scan") + ); assert!(!chunks[0].is_last_update); assert!(!chunks[1].is_last_update); assert!(chunks[2].is_last_update); diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 7ed1db6bfc5..712dd34f353 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1135,24 +1135,41 @@ impl PickerDelegate for RecentProjectsDelegate { } let key = key.clone(); - let path_list = key.path_list().clone(); if let Some(handle) = window.window_handle().downcast::() { cx.defer(move |cx| { - if let Some(task) = handle - .update(cx, |multi_workspace, window, cx| { - multi_workspace.find_or_create_local_workspace( - path_list, - Some(key.clone()), - &[], - None, - OpenMode::Activate, - window, - cx, - ) + // Try to activate an existing workspace for this project group + // first, so we preserve the actual worktree paths (which may + // differ from the main git worktree paths stored in the key). + if let Some(workspace) = handle + .update(cx, |multi_workspace, _window, cx| { + multi_workspace.last_active_workspace_for_group(&key, cx) }) .log_err() + .flatten() { - task.detach_and_log_err(cx); + handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(workspace, None, window, cx); + }) + .log_err(); + } else { + let path_list = key.path_list().clone(); + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.find_or_create_local_workspace( + path_list, + Some(key.clone()), + &[], + None, + OpenMode::Activate, + window, + cx, + ) + }) + .log_err() + { + task.detach_and_log_err(cx); + } } }); } diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 85e07aee0b4..993872b179f 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -1555,7 +1555,7 @@ type ResponseChannels = Mutex, oneshot::Sender<()>)>>>>; -struct Signal { +struct Signal { tx: Mutex>>, rx: Shared>>, } diff --git a/crates/remote/src/transport/mock.rs b/crates/remote/src/transport/mock.rs index f567d24eb12..01f7579ea56 100644 --- a/crates/remote/src/transport/mock.rs +++ b/crates/remote/src/transport/mock.rs @@ -1,342 +1,342 @@ -//! Mock transport for testing remote connections. -//! -//! This module provides a mock implementation of the `RemoteConnection` trait -//! that allows testing remote editing functionality without actual SSH/WSL/Docker -//! connections. -//! -//! # Usage -//! -//! ```rust,ignore -//! use remote::{MockConnection, RemoteClient}; -//! -//! #[gpui::test] -//! async fn test_remote_editing(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { -//! let (opts, server_session) = MockConnection::new(cx, server_cx); -//! -//! // Create the headless project (server side) -//! server_cx.update(HeadlessProject::init); -//! let _headless = server_cx.new(|cx| { -//! HeadlessProject::new( -//! HeadlessAppState { session: server_session, /* ... */ }, -//! false, -//! cx, -//! ) -//! }); -//! -//! // Create the client using the helper -//! let (client, server_client) = RemoteClient::new_mock(cx, server_cx).await; -//! // ... test logic ... -//! } -//! ``` - -use crate::remote_client::{ - ChannelClient, CommandTemplate, Interactive, RemoteClientDelegate, RemoteConnection, - RemoteConnectionOptions, -}; -use anyhow::Result; -use async_trait::async_trait; -use collections::HashMap; -use futures::{ - FutureExt, SinkExt, StreamExt, - channel::{ - mpsc::{self, Sender}, - oneshot, - }, - select_biased, -}; -use gpui::{App, AppContext as _, AsyncApp, Global, Task, TestAppContext}; -use rpc::{AnyProtoClient, proto::Envelope}; -use std::{ - path::PathBuf, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, -}; -use util::paths::{PathStyle, RemotePathBuf}; - -/// Unique identifier for a mock connection. -#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct MockConnectionOptions { - pub id: u64, -} - -/// A mock implementation of `RemoteConnection` for testing. -pub struct MockRemoteConnection { - options: MockConnectionOptions, - server_channel: Arc, - server_cx: SendableCx, -} - -/// Wrapper to pass `AsyncApp` across thread boundaries in tests. -/// -/// # Safety -/// -/// This is safe because in test mode, GPUI is always single-threaded and so -/// having access to one async app means being on the same main thread. -pub(crate) struct SendableCx(AsyncApp); - -impl SendableCx { - pub(crate) fn new(cx: &TestAppContext) -> Self { - Self(cx.to_async()) - } - - pub(crate) fn get(&self, _: &AsyncApp) -> AsyncApp { - self.0.clone() - } -} - -// SAFETY: In test mode, GPUI is always single-threaded, and SendableCx -// is only accessed from the main thread via the get() method which -// requires a valid AsyncApp reference. -unsafe impl Send for SendableCx {} -unsafe impl Sync for SendableCx {} - -/// Global registry that holds pre-created mock connections. -/// -/// When `ConnectionPool::connect` is called with `MockConnectionOptions`, -/// it retrieves the connection from this registry. -#[derive(Default)] -pub struct MockConnectionRegistry { - pending: HashMap, Arc)>, -} - -impl Global for MockConnectionRegistry {} - -impl MockConnectionRegistry { - /// Called by `ConnectionPool::connect` to retrieve a pre-registered mock connection. - pub fn take( - &mut self, - opts: &MockConnectionOptions, - ) -> Option> + use<>> { - let (guard, con) = self.pending.remove(&opts.id)?; - Some(async move { - _ = guard.await; - con - }) - } -} - -/// Helper for creating mock connection pairs in tests. -pub struct MockConnection; - -pub type ConnectGuard = oneshot::Sender<()>; - -impl MockConnection { - /// Creates a new mock connection pair for testing. - /// - /// This function: - /// 1. Creates a unique `MockConnectionOptions` identifier - /// 2. Sets up the server-side channel (returned as `AnyProtoClient`) - /// 3. Creates a `MockRemoteConnection` and registers it in the global registry - /// 4. The connection will be retrieved from the registry when `ConnectionPool::connect` is called - /// - /// Returns: - /// - `MockConnectionOptions` to pass to `remote::connect()` or `RemoteClient` creation - /// - `AnyProtoClient` to pass to `HeadlessProject::new()` as the session - /// - /// # Arguments - /// - `client_cx`: The test context for the client side - /// - `server_cx`: The test context for the server/headless side - pub(crate) fn new( - client_cx: &mut TestAppContext, - server_cx: &mut TestAppContext, - ) -> (MockConnectionOptions, AnyProtoClient, ConnectGuard) { - static NEXT_ID: AtomicU64 = AtomicU64::new(0); - let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); - let opts = MockConnectionOptions { id }; - let (server_client, connect_guard) = - Self::new_with_opts(opts.clone(), client_cx, server_cx); - (opts, server_client, connect_guard) - } - - /// Creates a mock connection pair for existing `MockConnectionOptions`. - /// - /// This is useful when simulating reconnection: after a connection is torn - /// down, register a new mock server under the same options so the next - /// `ConnectionPool::connect` call finds it. - pub(crate) fn new_with_opts( - opts: MockConnectionOptions, - client_cx: &mut TestAppContext, - server_cx: &mut TestAppContext, - ) -> (AnyProtoClient, ConnectGuard) { - let (outgoing_tx, _) = mpsc::unbounded::(); - let (_, incoming_rx) = mpsc::unbounded::(); - let server_client = server_cx - .update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "mock-server", false)); - - let connection = Arc::new(MockRemoteConnection { - options: opts.clone(), - server_channel: server_client.clone(), - server_cx: SendableCx::new(server_cx), - }); - - let (tx, rx) = oneshot::channel(); - - client_cx.update(|cx| { - cx.default_global::() - .pending - .insert(opts.id, (rx, connection)); - }); - - (server_client.into(), tx) - } -} - -#[async_trait(?Send)] -impl RemoteConnection for MockRemoteConnection { - async fn kill(&self) -> Result<()> { - Ok(()) - } - - fn has_been_killed(&self) -> bool { - false - } - - fn build_command( - &self, - program: Option, - args: &[String], - env: &HashMap, - _working_dir: Option, - _port_forward: Option<(u16, String, u16)>, - _interactive: Interactive, - ) -> Result { - let shell_program = program.unwrap_or_else(|| "sh".to_string()); - let mut shell_args = Vec::new(); - shell_args.push(shell_program); - shell_args.extend(args.iter().cloned()); - Ok(CommandTemplate { - program: "mock".into(), - args: shell_args, - env: env.clone(), - }) - } - - fn build_forward_ports_command( - &self, - forwards: Vec<(u16, String, u16)>, - ) -> Result { - Ok(CommandTemplate { - program: "mock".into(), - args: std::iter::once("-N".to_owned()) - .chain(forwards.into_iter().map(|(local_port, host, remote_port)| { - format!("{local_port}:{host}:{remote_port}") - })) - .collect(), - env: Default::default(), - }) - } - - fn upload_directory( - &self, - _src_path: PathBuf, - _dest_path: RemotePathBuf, - _cx: &App, - ) -> Task> { - Task::ready(Ok(())) - } - - fn connection_options(&self) -> RemoteConnectionOptions { - RemoteConnectionOptions::Mock(self.options.clone()) - } - - fn simulate_disconnect(&self, cx: &AsyncApp) { - let (outgoing_tx, _) = mpsc::unbounded::(); - let (_, incoming_rx) = mpsc::unbounded::(); - self.server_channel - .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx)); - } - - fn start_proxy( - &self, - _unique_identifier: String, - _reconnect: bool, - mut client_incoming_tx: mpsc::UnboundedSender, - mut client_outgoing_rx: mpsc::UnboundedReceiver, - mut connection_activity_tx: Sender<()>, - _delegate: Arc, - cx: &mut AsyncApp, - ) -> Task> { - let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::(); - let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::(); - - self.server_channel.reconnect( - server_incoming_rx, - server_outgoing_tx, - &self.server_cx.get(cx), - ); - - cx.background_spawn(async move { - loop { - select_biased! { - server_to_client = server_outgoing_rx.next().fuse() => { - let Some(server_to_client) = server_to_client else { - return Ok(1) - }; - connection_activity_tx.try_send(()).ok(); - client_incoming_tx.send(server_to_client).await.ok(); - } - client_to_server = client_outgoing_rx.next().fuse() => { - let Some(client_to_server) = client_to_server else { - return Ok(1) - }; - server_incoming_tx.send(client_to_server).await.ok(); - } - } - } - }) - } - - fn path_style(&self) -> PathStyle { - PathStyle::local() - } - - fn shell(&self) -> String { - "sh".to_owned() - } - - fn default_system_shell(&self) -> String { - "sh".to_owned() - } - - fn has_wsl_interop(&self) -> bool { - false - } -} - -/// Mock delegate for tests that don't need delegate functionality. -pub struct MockDelegate; - -impl RemoteClientDelegate for MockDelegate { - fn ask_password( - &self, - _prompt: String, - _sender: futures::channel::oneshot::Sender, - _cx: &mut AsyncApp, - ) { - unreachable!("MockDelegate::ask_password should not be called in tests") - } - - fn download_server_binary_locally( - &self, - _platform: crate::RemotePlatform, - _release_channel: release_channel::ReleaseChannel, - _version: Option, - _cx: &mut AsyncApp, - ) -> Task> { - unreachable!("MockDelegate::download_server_binary_locally should not be called in tests") - } - - fn get_download_url( - &self, - _platform: crate::RemotePlatform, - _release_channel: release_channel::ReleaseChannel, - _version: Option, - _cx: &mut AsyncApp, - ) -> Task>> { - unreachable!("MockDelegate::get_download_url should not be called in tests") - } - - fn set_status(&self, _status: Option<&str>, _cx: &mut AsyncApp) {} -} +//! Mock transport for testing remote connections. +//! +//! This module provides a mock implementation of the `RemoteConnection` trait +//! that allows testing remote editing functionality without actual SSH/WSL/Docker +//! connections. +//! +//! # Usage +//! +//! ```rust,ignore +//! use remote::{MockConnection, RemoteClient}; +//! +//! #[gpui::test] +//! async fn test_remote_editing(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { +//! let (opts, server_session) = MockConnection::new(cx, server_cx); +//! +//! // Create the headless project (server side) +//! server_cx.update(HeadlessProject::init); +//! let _headless = server_cx.new(|cx| { +//! HeadlessProject::new( +//! HeadlessAppState { session: server_session, /* ... */ }, +//! false, +//! cx, +//! ) +//! }); +//! +//! // Create the client using the helper +//! let (client, server_client) = RemoteClient::new_mock(cx, server_cx).await; +//! // ... test logic ... +//! } +//! ``` + +use crate::remote_client::{ + ChannelClient, CommandTemplate, Interactive, RemoteClientDelegate, RemoteConnection, + RemoteConnectionOptions, +}; +use anyhow::Result; +use async_trait::async_trait; +use collections::HashMap; +use futures::{ + FutureExt, SinkExt, StreamExt, + channel::{ + mpsc::{self, Sender}, + oneshot, + }, + select_biased, +}; +use gpui::{App, AppContext as _, AsyncApp, Global, Task, TestAppContext}; +use rpc::{AnyProtoClient, proto::Envelope}; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; +use util::paths::{PathStyle, RemotePathBuf}; + +/// Unique identifier for a mock connection. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct MockConnectionOptions { + pub id: u64, +} + +/// A mock implementation of `RemoteConnection` for testing. +pub struct MockRemoteConnection { + options: MockConnectionOptions, + server_channel: Arc, + server_cx: SendableCx, +} + +/// Wrapper to pass `AsyncApp` across thread boundaries in tests. +/// +/// # Safety +/// +/// This is safe because in test mode, GPUI is always single-threaded and so +/// having access to one async app means being on the same main thread. +pub(crate) struct SendableCx(AsyncApp); + +impl SendableCx { + pub(crate) fn new(cx: &TestAppContext) -> Self { + Self(cx.to_async()) + } + + pub(crate) fn get(&self, _: &AsyncApp) -> AsyncApp { + self.0.clone() + } +} + +// SAFETY: In test mode, GPUI is always single-threaded, and SendableCx +// is only accessed from the main thread via the get() method which +// requires a valid AsyncApp reference. +unsafe impl Send for SendableCx {} +unsafe impl Sync for SendableCx {} + +/// Global registry that holds pre-created mock connections. +/// +/// When `ConnectionPool::connect` is called with `MockConnectionOptions`, +/// it retrieves the connection from this registry. +#[derive(Default)] +pub struct MockConnectionRegistry { + pending: HashMap, Arc)>, +} + +impl Global for MockConnectionRegistry {} + +impl MockConnectionRegistry { + /// Called by `ConnectionPool::connect` to retrieve a pre-registered mock connection. + pub fn take( + &mut self, + opts: &MockConnectionOptions, + ) -> Option> + use<>> { + let (guard, con) = self.pending.remove(&opts.id)?; + Some(async move { + _ = guard.await; + con + }) + } +} + +/// Helper for creating mock connection pairs in tests. +pub struct MockConnection; + +pub type ConnectGuard = oneshot::Sender<()>; + +impl MockConnection { + /// Creates a new mock connection pair for testing. + /// + /// This function: + /// 1. Creates a unique `MockConnectionOptions` identifier + /// 2. Sets up the server-side channel (returned as `AnyProtoClient`) + /// 3. Creates a `MockRemoteConnection` and registers it in the global registry + /// 4. The connection will be retrieved from the registry when `ConnectionPool::connect` is called + /// + /// Returns: + /// - `MockConnectionOptions` to pass to `remote::connect()` or `RemoteClient` creation + /// - `AnyProtoClient` to pass to `HeadlessProject::new()` as the session + /// + /// # Arguments + /// - `client_cx`: The test context for the client side + /// - `server_cx`: The test context for the server/headless side + pub(crate) fn new( + client_cx: &mut TestAppContext, + server_cx: &mut TestAppContext, + ) -> (MockConnectionOptions, AnyProtoClient, ConnectGuard) { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); + let opts = MockConnectionOptions { id }; + let (server_client, connect_guard) = + Self::new_with_opts(opts.clone(), client_cx, server_cx); + (opts, server_client, connect_guard) + } + + /// Creates a mock connection pair for existing `MockConnectionOptions`. + /// + /// This is useful when simulating reconnection: after a connection is torn + /// down, register a new mock server under the same options so the next + /// `ConnectionPool::connect` call finds it. + pub(crate) fn new_with_opts( + opts: MockConnectionOptions, + client_cx: &mut TestAppContext, + server_cx: &mut TestAppContext, + ) -> (AnyProtoClient, ConnectGuard) { + let (outgoing_tx, _) = mpsc::unbounded::(); + let (_, incoming_rx) = mpsc::unbounded::(); + let server_client = server_cx + .update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "mock-server", false)); + + let connection = Arc::new(MockRemoteConnection { + options: opts.clone(), + server_channel: server_client.clone(), + server_cx: SendableCx::new(server_cx), + }); + + let (tx, rx) = oneshot::channel(); + + client_cx.update(|cx| { + cx.default_global::() + .pending + .insert(opts.id, (rx, connection)); + }); + + (server_client.into(), tx) + } +} + +#[async_trait(?Send)] +impl RemoteConnection for MockRemoteConnection { + async fn kill(&self) -> Result<()> { + Ok(()) + } + + fn has_been_killed(&self) -> bool { + false + } + + fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + _working_dir: Option, + _port_forward: Option<(u16, String, u16)>, + _interactive: Interactive, + ) -> Result { + let shell_program = program.unwrap_or_else(|| "sh".to_string()); + let mut shell_args = Vec::new(); + shell_args.push(shell_program); + shell_args.extend(args.iter().cloned()); + Ok(CommandTemplate { + program: "mock".into(), + args: shell_args, + env: env.clone(), + }) + } + + fn build_forward_ports_command( + &self, + forwards: Vec<(u16, String, u16)>, + ) -> Result { + Ok(CommandTemplate { + program: "mock".into(), + args: std::iter::once("-N".to_owned()) + .chain(forwards.into_iter().map(|(local_port, host, remote_port)| { + format!("{local_port}:{host}:{remote_port}") + })) + .collect(), + env: Default::default(), + }) + } + + fn upload_directory( + &self, + _src_path: PathBuf, + _dest_path: RemotePathBuf, + _cx: &App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn connection_options(&self) -> RemoteConnectionOptions { + RemoteConnectionOptions::Mock(self.options.clone()) + } + + fn simulate_disconnect(&self, cx: &AsyncApp) { + let (outgoing_tx, _) = mpsc::unbounded::(); + let (_, incoming_rx) = mpsc::unbounded::(); + self.server_channel + .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx)); + } + + fn start_proxy( + &self, + _unique_identifier: String, + _reconnect: bool, + mut client_incoming_tx: mpsc::UnboundedSender, + mut client_outgoing_rx: mpsc::UnboundedReceiver, + mut connection_activity_tx: Sender<()>, + _delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::(); + let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::(); + + self.server_channel.reconnect( + server_incoming_rx, + server_outgoing_tx, + &self.server_cx.get(cx), + ); + + cx.background_spawn(async move { + loop { + select_biased! { + server_to_client = server_outgoing_rx.next().fuse() => { + let Some(server_to_client) = server_to_client else { + return Ok(1) + }; + connection_activity_tx.try_send(()).ok(); + client_incoming_tx.send(server_to_client).await.ok(); + } + client_to_server = client_outgoing_rx.next().fuse() => { + let Some(client_to_server) = client_to_server else { + return Ok(1) + }; + server_incoming_tx.send(client_to_server).await.ok(); + } + } + } + }) + } + + fn path_style(&self) -> PathStyle { + PathStyle::local() + } + + fn shell(&self) -> String { + "sh".to_owned() + } + + fn default_system_shell(&self) -> String { + "sh".to_owned() + } + + fn has_wsl_interop(&self) -> bool { + false + } +} + +/// Mock delegate for tests that don't need delegate functionality. +pub struct MockDelegate; + +impl RemoteClientDelegate for MockDelegate { + fn ask_password( + &self, + _prompt: String, + _sender: futures::channel::oneshot::Sender, + _cx: &mut AsyncApp, + ) { + unreachable!("MockDelegate::ask_password should not be called in tests") + } + + fn download_server_binary_locally( + &self, + _platform: crate::RemotePlatform, + _release_channel: release_channel::ReleaseChannel, + _version: Option, + _cx: &mut AsyncApp, + ) -> Task> { + unreachable!("MockDelegate::download_server_binary_locally should not be called in tests") + } + + fn get_download_url( + &self, + _platform: crate::RemotePlatform, + _release_channel: release_channel::ReleaseChannel, + _version: Option, + _cx: &mut AsyncApp, + ) -> Task>> { + unreachable!("MockDelegate::get_download_url should not be called in tests") + } + + fn set_status(&self, _status: Option<&str>, _cx: &mut AsyncApp) {} +} diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 48c047252fe..dcd22a9512a 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -105,6 +105,7 @@ language_model = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } unindent.workspace = true serde_json.workspace = true +tempfile.workspace = true zlog.workspace = true [build-dependencies] diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 840485e6750..a91be89be0b 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2121,7 +2121,8 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA .update(cx, |repository, _| repository.branches()) .await .unwrap() - .unwrap(); + .unwrap() + .branches; let new_branch = branches[2]; @@ -2386,7 +2387,10 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu .run(ToolInput::resolved(input), event_stream.clone(), cx) }); let output = exists_result.await.unwrap(); - assert_eq!(output, LanguageModelToolResultContent::Text("B".into())); + assert_eq!( + output, + LanguageModelToolResultContent::Text(" 1\tB".into()) + ); let input = ReadFileToolInput { path: "project/c.txt".into(), diff --git a/crates/remote_server/src/server.rs b/crates/remote_server/src/server.rs index ec2b1963b9d..51d774f2e6c 100644 --- a/crates/remote_server/src/server.rs +++ b/crates/remote_server/src/server.rs @@ -153,18 +153,102 @@ fn init_logging_proxy() { .init(); } +const REMOTE_SERVER_LOG_MAX_BYTES: u64 = 1024 * 1024; + +struct RotatingLogFile { + path: PathBuf, + file: File, + size_bytes: u64, +} + +impl RotatingLogFile { + fn open(path: &Path) -> Result { + if std::fs::metadata(path) + .map(|metadata| metadata.len() >= REMOTE_SERVER_LOG_MAX_BYTES) + .unwrap_or(false) + { + rotate_log_file(path, &rotated_log_path(path)) + .context("failed to rotate existing remote server log")?; + } + + let file = open_log_file(path).context("failed to open remote server log")?; + let size_bytes = file + .metadata() + .context("failed to read remote server log metadata")? + .len(); + + Ok(Self { + path: path.to_path_buf(), + file, + size_bytes, + }) + } + + fn rotate(&mut self) -> std::io::Result<()> { + self.file.flush()?; + rotate_log_file(&self.path, &rotated_log_path(&self.path))?; + self.file = open_log_file(&self.path)?; + self.size_bytes = 0; + Ok(()) + } +} + +impl Write for RotatingLogFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.size_bytes.saturating_add(buf.len() as u64) > REMOTE_SERVER_LOG_MAX_BYTES { + self.rotate()?; + } + + self.file.write_all(buf)?; + self.size_bytes += buf.len() as u64; + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.file.flush() + } +} + +fn open_log_file(path: &Path) -> std::io::Result { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) +} + +fn rotated_log_path(path: &Path) -> PathBuf { + path.with_extension("1.log") +} + +fn rotate_log_file(path: &Path, rotated_path: &Path) -> std::io::Result<()> { + match std::fs::remove_file(rotated_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + + match std::fs::rename(path, rotated_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + + Ok(()) +} + fn init_logging_server(log_file_path: &Path) -> Result>> { struct MultiWrite { - file: File, + file: RotatingLogFile, channel: Sender>, buffer: Vec, } impl Write for MultiWrite { fn write(&mut self, buf: &[u8]) -> std::io::Result { - let written = self.file.write(buf)?; - self.buffer.extend_from_slice(&buf[..written]); - Ok(written) + self.file.write_all(buf)?; + self.buffer.extend_from_slice(buf); + Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { @@ -176,11 +260,8 @@ fn init_logging_server(log_file_path: &Path) -> Result>> { } } - let log_file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(log_file_path) - .context("Failed to open log file in append mode")?; + let log_file = RotatingLogFile::open(log_file_path) + .context("Failed to open rotating remote server log file")?; let (tx, rx) = async_channel::unbounded(); @@ -1264,3 +1345,63 @@ fn is_file_in_use(file_name: &OsStr) -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rotated_remote_log_path_uses_numbered_log_suffix() { + assert_eq!( + rotated_log_path(Path::new("server-workspace-12.log")), + PathBuf::from("server-workspace-12.1.log") + ); + } + + #[test] + fn opening_remote_log_rotates_existing_oversized_log() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let log_path = temp_dir.path().join("server-workspace-12.log"); + let rotated_path = temp_dir.path().join("server-workspace-12.1.log"); + let existing_contents = vec![b'x'; REMOTE_SERVER_LOG_MAX_BYTES as usize]; + std::fs::write(&log_path, &existing_contents).expect("write oversized log"); + + let _log_file = RotatingLogFile::open(&log_path).expect("open rotating log file"); + + assert_eq!( + std::fs::read(&rotated_path).expect("read rotated log"), + existing_contents + ); + assert_eq!( + std::fs::metadata(&log_path) + .expect("active log metadata") + .len(), + 0 + ); + } + + #[test] + fn writing_remote_log_rotates_before_exceeding_size_limit() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let log_path = temp_dir.path().join("server-workspace-12.log"); + let rotated_path = temp_dir.path().join("server-workspace-12.1.log"); + let existing_contents = vec![b'x'; REMOTE_SERVER_LOG_MAX_BYTES as usize - 1]; + let new_contents = b"yz"; + std::fs::write(&log_path, &existing_contents).expect("write existing log contents"); + let mut log_file = RotatingLogFile::open(&log_path).expect("open rotating log file"); + + log_file + .write_all(new_contents) + .expect("write log contents"); + log_file.flush().expect("flush log file"); + + assert_eq!( + std::fs::read(&rotated_path).expect("read rotated log"), + existing_contents + ); + assert_eq!( + std::fs::read(&log_path).expect("read active log"), + new_contents + ); + } +} diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index ac078b3338c..cf602a46def 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -5,14 +5,13 @@ use editor::{Editor, EditorMode, MultiBuffer, SizingBehavior}; use futures::future::Shared; use gpui::{ App, Entity, EventEmitter, Focusable, Hsla, InteractiveElement, RetainAllImageCache, - StatefulInteractiveElement, Task, TextStyleRefinement, prelude::*, + StatefulInteractiveElement, Task, prelude::*, }; use language::{Buffer, Language, LanguageRegistry}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; use nbformat::v4::{CellId, CellMetadata, CellType}; use runtimelib::{JupyterMessage, JupyterMessageContent}; use settings::Settings as _; -use theme_settings::ThemeSettings; use ui::{CommonAnimationExt, IconButtonShape, prelude::*}; use util::ResultExt; @@ -419,17 +418,7 @@ impl MarkdownCell { cx, ); - let theme = ThemeSettings::get_global(cx); - let refinement = TextStyleRefinement { - font_family: Some(theme.buffer_font.family.clone()), - font_size: Some(theme.buffer_font_size(cx).into()), - color: Some(cx.theme().colors().editor_foreground), - background_color: Some(gpui::transparent_black()), - ..Default::default() - }; - editor.set_show_gutter(false, cx); - editor.set_text_style_refinement(refinement); editor.set_use_modal_editing(true); editor.disable_mouse_wheel_zoom(); editor.disable_scrollbars_and_minimap(window, cx); @@ -606,10 +595,7 @@ impl Render for MarkdownCell { // Preview mode - show rendered markdown - let style = MarkdownStyle { - base_text_style: window.text_style(), - ..Default::default() - }; + let style = MarkdownStyle::themed(MarkdownFont::Preview, window, cx); v_flex() .size_full() @@ -710,20 +696,10 @@ impl CodeCell { cx, ); - let theme = ThemeSettings::get_global(cx); - let refinement = TextStyleRefinement { - font_family: Some(theme.buffer_font.family.clone()), - font_size: Some(theme.buffer_font_size(cx).into()), - color: Some(cx.theme().colors().editor_foreground), - background_color: Some(gpui::transparent_black()), - ..Default::default() - }; - editor.disable_mouse_wheel_zoom(); editor.disable_scrollbars_and_minimap(window, cx); editor.set_text(source.clone(), window, cx); editor.set_show_gutter(false, cx); - editor.set_text_style_refinement(refinement); editor.set_use_modal_editing(true); editor }); diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index d6a4db3396c..4d5aa410ae7 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -1732,7 +1732,7 @@ mod tests { use std::{cmp::Ordering, env, io::Read}; use util::RandomCharIter; - #[ctor::ctor] + #[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index cb45948d5cd..4b0b4bc7440 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -417,6 +417,9 @@ impl AnyProtoClient { Response::GetDocumentSymbolsResponse(response) => { to_any_envelope(&envelope, response) } + Response::GetDocumentLinksResponse(response) => { + to_any_envelope(&envelope, response) + } }; Some(proto::ProtoLspResponse { server_id, diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs deleted file mode 100644 index 9f87d403e72..00000000000 --- a/crates/rules_library/src/rules_library.rs +++ /dev/null @@ -1,1341 +0,0 @@ -use anyhow::Result; -use collections::{HashMap, HashSet}; -use editor::SelectionEffects; -use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab}; -use gpui::{ - App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel, - Subscription, Task, TaskExt, TextStyle, Tiling, TitlebarOptions, WindowBounds, WindowHandle, - WindowOptions, actions, point, size, transparent_black, -}; -use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; -use language_model::{ConfiguredModel, LanguageModelRegistry}; -use picker::{Picker, PickerDelegate}; -use platform_title_bar::PlatformTitleBar; -use release_channel::ReleaseChannel; -use rope::Rope; -use settings::{ActionSequence, Settings}; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::time::Duration; -use theme_settings::ThemeSettings; -use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*}; -use ui_input::ErasedEditor; -use util::{ResultExt, TryFutureExt}; -use workspace::{MultiWorkspace, Workspace, WorkspaceSettings, client_side_decorations}; -use zed_actions::assistant::InlineAssist; - -use prompt_store::*; - -pub fn init(cx: &mut App) { - prompt_store::init(cx); -} - -actions!( - rules_library, - [ - /// Creates a new rule in the rules library. - NewRule, - /// Deletes the selected rule. - DeleteRule, - /// Duplicates the selected rule. - DuplicateRule, - /// Toggles whether the selected rule is a default rule. - ToggleDefaultRule, - /// Restores a built-in rule to its default content. - RestoreDefaultContent - ] -); - -pub trait InlineAssistDelegate { - fn assist( - &self, - prompt_editor: &Entity, - initial_prompt: Option, - window: &mut Window, - cx: &mut Context, - ); - - /// Returns whether the Agent panel was focused. - fn focus_agent_panel( - &self, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) -> bool; -} - -/// This function opens a new rules library window if one doesn't exist already. -/// If one exists, it brings it to the foreground. -/// -/// Note that, when opening a new window, this waits for the PromptStore to be -/// initialized. If it was initialized successfully, it returns a window handle -/// to a rules library. -pub fn open_rules_library( - language_registry: Arc, - inline_assist_delegate: Box, - prompt_to_select: Option, - cx: &mut App, -) -> Task>> { - let store = PromptStore::global(cx); - cx.spawn(async move |cx| { - // We query windows in spawn so that all windows have been returned to GPUI - let existing_window = cx.update(|cx| { - let existing_window = cx - .windows() - .into_iter() - .find_map(|window| window.downcast::()); - if let Some(existing_window) = existing_window { - existing_window - .update(cx, |rules_library, window, cx| { - if let Some(prompt_to_select) = prompt_to_select { - rules_library.load_rule(prompt_to_select, true, window, cx); - } - window.activate_window() - }) - .ok(); - - Some(existing_window) - } else { - None - } - }); - - if let Some(existing_window) = existing_window { - return Ok(existing_window); - } - - let store = store.await?; - cx.update(|cx| { - let app_id = ReleaseChannel::global(cx).app_id(); - let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx); - let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") { - Ok(val) if val == "server" => gpui::WindowDecorations::Server, - Ok(val) if val == "client" => gpui::WindowDecorations::Client, - _ => match WorkspaceSettings::get_global(cx).window_decorations { - settings::WindowDecorations::Server => gpui::WindowDecorations::Server, - settings::WindowDecorations::Client => gpui::WindowDecorations::Client, - }, - }; - cx.open_window( - WindowOptions { - titlebar: Some(TitlebarOptions { - title: Some("Rules Library".into()), - appears_transparent: true, - traffic_light_position: Some(point(px(12.0), px(12.0))), - }), - app_id: Some(app_id.to_owned()), - window_bounds: Some(WindowBounds::Windowed(bounds)), - window_background: cx.theme().window_background_appearance(), - window_decorations: Some(window_decorations), - window_min_size: Some(DEFAULT_ADDITIONAL_WINDOW_SIZE), - kind: gpui::WindowKind::Floating, - ..Default::default() - }, - |window, cx| { - cx.new(|cx| { - RulesLibrary::new( - store, - language_registry, - inline_assist_delegate, - prompt_to_select, - window, - cx, - ) - }) - }, - ) - }) - }) -} - -pub struct RulesLibrary { - title_bar: Option>, - store: Entity, - language_registry: Arc, - rule_editors: HashMap, - active_rule_id: Option, - picker: Entity>, - pending_load: Task<()>, - inline_assist_delegate: Box, - _subscriptions: Vec, -} - -struct RuleEditor { - title_editor: Entity, - body_editor: Entity, - next_title_and_body_to_save: Option<(String, Rope)>, - pending_save: Option>>, - _subscriptions: Vec, -} - -enum RulePickerEntry { - Header(SharedString), - Rule(PromptMetadata), - Separator, -} - -struct RulePickerDelegate { - store: Entity, - selected_index: usize, - filtered_entries: Vec, -} - -enum RulePickerEvent { - Selected { prompt_id: PromptId }, - Confirmed { prompt_id: PromptId }, - Deleted { prompt_id: PromptId }, - ToggledDefault { prompt_id: PromptId }, -} - -impl EventEmitter for Picker {} - -impl PickerDelegate for RulePickerDelegate { - type ListItem = AnyElement; - - fn match_count(&self) -> usize { - self.filtered_entries.len() - } - - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - Some("No rules found matching your search.".into()) - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { - self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); - - if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) { - cx.emit(RulePickerEvent::Selected { prompt_id: rule.id }); - } - - cx.notify(); - } - - fn can_select(&self, ix: usize, _: &mut Window, _: &mut Context>) -> bool { - match self.filtered_entries.get(ix) { - Some(RulePickerEntry::Rule(_)) => true, - Some(RulePickerEntry::Header(_)) | Some(RulePickerEntry::Separator) | None => false, - } - } - - fn select_on_hover(&self) -> bool { - false - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let cancellation_flag = Arc::new(AtomicBool::default()); - let search = self.store.read(cx).search(query, cancellation_flag, cx); - - let prev_prompt_id = self - .filtered_entries - .get(self.selected_index) - .and_then(|entry| { - if let RulePickerEntry::Rule(rule) = entry { - Some(rule.id) - } else { - None - } - }); - - cx.spawn_in(window, async move |this, cx| { - let (filtered_entries, selected_index) = cx - .background_spawn(async move { - let matches = search.await; - - let (built_in_rules, user_rules): (Vec<_>, Vec<_>) = - matches.into_iter().partition(|rule| rule.id.is_built_in()); - let (default_rules, other_rules): (Vec<_>, Vec<_>) = - user_rules.into_iter().partition(|rule| rule.default); - - let mut filtered_entries = Vec::new(); - - if !built_in_rules.is_empty() { - filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into())); - - for rule in built_in_rules { - filtered_entries.push(RulePickerEntry::Rule(rule)); - } - - filtered_entries.push(RulePickerEntry::Separator); - } - - if !default_rules.is_empty() { - filtered_entries.push(RulePickerEntry::Header("Default Rules".into())); - - for rule in default_rules { - filtered_entries.push(RulePickerEntry::Rule(rule)); - } - - filtered_entries.push(RulePickerEntry::Separator); - } - - for rule in other_rules { - filtered_entries.push(RulePickerEntry::Rule(rule)); - } - - let selected_index = prev_prompt_id - .and_then(|prev_prompt_id| { - filtered_entries.iter().position(|entry| { - if let RulePickerEntry::Rule(rule) = entry { - rule.id == prev_prompt_id - } else { - false - } - }) - }) - .unwrap_or_else(|| { - filtered_entries - .iter() - .position(|entry| matches!(entry, RulePickerEntry::Rule(_))) - .unwrap_or(0) - }); - - (filtered_entries, selected_index) - }) - .await; - - this.update_in(cx, |this, window, cx| { - this.delegate.filtered_entries = filtered_entries; - this.set_selected_index( - selected_index, - Some(picker::Direction::Down), - true, - window, - cx, - ); - cx.notify(); - }) - .ok(); - }) - } - - fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context>) { - if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) { - cx.emit(RulePickerEvent::Confirmed { prompt_id: rule.id }); - } - } - - fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} - - fn render_match( - &self, - ix: usize, - selected: bool, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - match self.filtered_entries.get(ix)? { - RulePickerEntry::Header(title) => { - let tooltip_text = if title.as_ref() == "Built-in Rules" { - "Built-in rules are those included out of the box with Zed." - } else { - "Default Rules are attached by default with every new thread." - }; - - Some( - ListSubHeader::new(title.clone()) - .end_slot( - IconButton::new("info", IconName::Info) - .style(ButtonStyle::Transparent) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text(tooltip_text)) - .into_any_element(), - ) - .inset(true) - .into_any_element(), - ) - } - RulePickerEntry::Separator => Some( - h_flex() - .py_1() - .child(Divider::horizontal()) - .into_any_element(), - ), - RulePickerEntry::Rule(rule) => { - let default = rule.default; - let prompt_id = rule.id; - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - Label::new(rule.title.clone().unwrap_or("Untitled".into())) - .truncate() - .mr_10(), - ) - .end_slot::((default && !prompt_id.is_built_in()).then(|| { - IconButton::new("toggle-default-rule", IconName::Paperclip) - .toggle_state(true) - .icon_color(Color::Accent) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove from Default Rules")) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(RulePickerEvent::ToggledDefault { prompt_id }) - })) - })) - .when(!prompt_id.is_built_in(), |this| { - this.end_slot_on_hover( - h_flex() - .child( - IconButton::new("delete-rule", IconName::Trash) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Delete Rule")) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(RulePickerEvent::Deleted { prompt_id }) - })), - ) - .child( - IconButton::new("toggle-default-rule", IconName::Plus) - .selected_icon(IconName::Dash) - .toggle_state(default) - .icon_size(IconSize::Small) - .icon_color(if default { - Color::Accent - } else { - Color::Muted - }) - .map(|this| { - if default { - this.tooltip(Tooltip::text( - "Remove from Default Rules", - )) - } else { - this.tooltip(move |_window, cx| { - Tooltip::with_meta( - "Add to Default Rules", - None, - "Always included in every thread.", - cx, - ) - }) - } - }) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(RulePickerEvent::ToggledDefault { - prompt_id, - }) - })), - ), - ) - }) - .into_any_element(), - ) - } - } - } - - fn render_editor( - &self, - editor: &Arc, - _: &mut Window, - cx: &mut Context>, - ) -> Div { - let editor = editor.as_any().downcast_ref::>().unwrap(); - - h_flex() - .py_1() - .px_1p5() - .mx_1() - .gap_1p5() - .rounded_sm() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted)) - .child(editor.clone()) - } -} - -impl RulesLibrary { - fn new( - store: Entity, - language_registry: Arc, - inline_assist_delegate: Box, - rule_to_select: Option, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let (_selected_index, _matches) = if let Some(rule_to_select) = rule_to_select { - let matches = store.read(cx).all_prompt_metadata(); - let selected_index = matches - .iter() - .enumerate() - .find(|(_, metadata)| metadata.id == rule_to_select) - .map_or(0, |(ix, _)| ix); - (selected_index, matches) - } else { - (0, vec![]) - }; - - let picker_delegate = RulePickerDelegate { - store: store.clone(), - selected_index: 0, - filtered_entries: Vec::new(), - }; - - let picker = cx.new(|cx| { - let picker = Picker::list(picker_delegate, window, cx) - .modal(false) - .max_height(None); - picker.focus(window, cx); - picker - }); - - Self { - title_bar: if !cfg!(target_os = "macos") { - Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx))) - } else { - None - }, - store, - language_registry, - rule_editors: HashMap::default(), - active_rule_id: None, - pending_load: Task::ready(()), - inline_assist_delegate, - _subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)], - picker, - } - } - - fn handle_picker_event( - &mut self, - _: &Entity>, - event: &RulePickerEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - RulePickerEvent::Selected { prompt_id } => { - self.load_rule(*prompt_id, false, window, cx); - } - RulePickerEvent::Confirmed { prompt_id } => { - self.load_rule(*prompt_id, true, window, cx); - } - RulePickerEvent::ToggledDefault { prompt_id } => { - self.toggle_default_for_rule(*prompt_id, window, cx); - } - RulePickerEvent::Deleted { prompt_id } => { - self.delete_rule(*prompt_id, window, cx); - } - } - } - - pub fn new_rule(&mut self, window: &mut Window, cx: &mut Context) { - // If we already have an untitled rule, use that instead - // of creating a new one. - if let Some(metadata) = self.store.read(cx).first() - && metadata.title.is_none() - { - self.load_rule(metadata.id, true, window, cx); - return; - } - - let prompt_id = PromptId::new(); - let save = self.store.update(cx, |store, cx| { - store.save(prompt_id, None, false, "".into(), cx) - }); - self.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.spawn_in(window, async move |this, cx| { - save.await?; - this.update_in(cx, |this, window, cx| { - this.load_rule(prompt_id, true, window, cx) - }) - }) - .detach_and_log_err(cx); - } - - pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context) { - const SAVE_THROTTLE: Duration = Duration::from_millis(500); - - if !prompt_id.can_edit() { - return; - } - - let rule_metadata = self.store.read(cx).metadata(prompt_id).unwrap(); - let rule_editor = self.rule_editors.get_mut(&prompt_id).unwrap(); - let title = rule_editor.title_editor.read(cx).text(cx); - let body = rule_editor.body_editor.update(cx, |editor, cx| { - editor - .buffer() - .read(cx) - .as_singleton() - .unwrap() - .read(cx) - .as_rope() - .clone() - }); - - let store = self.store.clone(); - let executor = cx.background_executor().clone(); - - rule_editor.next_title_and_body_to_save = Some((title, body)); - if rule_editor.pending_save.is_none() { - rule_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| { - async move { - loop { - let title_and_body = this.update(cx, |this, _| { - this.rule_editors - .get_mut(&prompt_id)? - .next_title_and_body_to_save - .take() - })?; - - if let Some((title, body)) = title_and_body { - let title = if title.trim().is_empty() { - None - } else { - Some(SharedString::from(title)) - }; - cx.update(|_window, cx| { - store.update(cx, |store, cx| { - store.save(prompt_id, title, rule_metadata.default, body, cx) - }) - })? - .await - .log_err(); - this.update_in(cx, |this, window, cx| { - this.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.notify(); - })?; - - executor.timer(SAVE_THROTTLE).await; - } else { - break; - } - } - - this.update(cx, |this, _cx| { - if let Some(rule_editor) = this.rule_editors.get_mut(&prompt_id) { - rule_editor.pending_save = None; - } - }) - } - .log_err() - .await - })); - } - } - - pub fn delete_active_rule(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(active_rule_id) = self.active_rule_id { - self.delete_rule(active_rule_id, window, cx); - } - } - - pub fn duplicate_active_rule(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(active_rule_id) = self.active_rule_id { - self.duplicate_rule(active_rule_id, window, cx); - } - } - - pub fn toggle_default_for_active_rule(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(active_rule_id) = self.active_rule_id { - self.toggle_default_for_rule(active_rule_id, window, cx); - } - } - - pub fn restore_default_content_for_active_rule( - &mut self, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active_rule_id) = self.active_rule_id { - self.restore_default_content(active_rule_id, window, cx); - } - } - - pub fn restore_default_content( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - let Some(built_in) = prompt_id.as_built_in() else { - return; - }; - - if let Some(rule_editor) = self.rule_editors.get(&prompt_id) { - rule_editor.body_editor.update(cx, |editor, cx| { - editor.set_text(built_in.default_content(), window, cx); - }); - } - } - - pub fn toggle_default_for_rule( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - self.store.update(cx, move |store, cx| { - if let Some(rule_metadata) = store.metadata(prompt_id) { - store - .save_metadata(prompt_id, rule_metadata.title, !rule_metadata.default, cx) - .detach_and_log_err(cx); - } - }); - self.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.notify(); - } - - pub fn load_rule( - &mut self, - prompt_id: PromptId, - focus: bool, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule_editor) = self.rule_editors.get(&prompt_id) { - if focus { - rule_editor - .body_editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)); - } - self.set_active_rule(Some(prompt_id), window, cx); - } else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) { - let language_registry = self.language_registry.clone(); - let rule = self.store.read(cx).load(prompt_id, cx); - self.pending_load = cx.spawn_in(window, async move |this, cx| { - let rule = rule.await; - let markdown = language_registry.language_for_name("Markdown").await; - this.update_in(cx, |this, window, cx| match rule { - Ok(rule) => { - let title_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Untitled", window, cx); - editor.set_text(rule_metadata.title.unwrap_or_default(), window, cx); - if prompt_id.is_built_in() { - editor.set_read_only(true); - editor.set_show_edit_predictions(Some(false), window, cx); - } - editor - }); - let body_editor = cx.new(|cx| { - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local(rule, cx); - buffer.set_language(markdown.log_err(), cx); - buffer.set_language_registry(language_registry); - buffer - }); - - let mut editor = Editor::for_buffer(buffer, None, window, cx); - if !prompt_id.can_edit() { - editor.set_read_only(true); - editor.set_show_edit_predictions(Some(false), window, cx); - } - editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - editor.set_show_gutter(false, cx); - editor.set_show_wrap_guides(false, cx); - editor.set_show_indent_guides(false, cx); - editor.set_use_modal_editing(true); - editor.set_current_line_highlight(Some(CurrentLineHighlight::None)); - if focus { - window.focus(&editor.focus_handle(cx), cx); - } - editor - }); - let _subscriptions = vec![ - cx.subscribe_in( - &title_editor, - window, - move |this, editor, event, window, cx| { - this.handle_rule_title_editor_event( - prompt_id, editor, event, window, cx, - ) - }, - ), - cx.subscribe_in( - &body_editor, - window, - move |this, editor, event, window, cx| { - this.handle_rule_body_editor_event( - prompt_id, editor, event, window, cx, - ) - }, - ), - ]; - this.rule_editors.insert( - prompt_id, - RuleEditor { - title_editor, - body_editor, - next_title_and_body_to_save: None, - pending_save: None, - _subscriptions, - }, - ); - this.set_active_rule(Some(prompt_id), window, cx); - } - Err(error) => { - // TODO: we should show the error in the UI. - log::error!("error while loading rule: {:?}", error); - } - }) - .ok(); - }); - } - } - - fn set_active_rule( - &mut self, - prompt_id: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.active_rule_id = prompt_id; - self.picker.update(cx, |picker, cx| { - if let Some(prompt_id) = prompt_id { - if picker - .delegate - .filtered_entries - .get(picker.delegate.selected_index()) - .is_none_or(|old_selected_prompt| { - if let RulePickerEntry::Rule(rule) = old_selected_prompt { - rule.id != prompt_id - } else { - true - } - }) - && let Some(ix) = picker.delegate.filtered_entries.iter().position(|mat| { - if let RulePickerEntry::Rule(rule) = mat { - rule.id == prompt_id - } else { - false - } - }) - { - picker.set_selected_index(ix, None, true, window, cx); - } - } else { - picker.focus(window, cx); - } - }); - cx.notify(); - } - - pub fn delete_rule( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(metadata) = self.store.read(cx).metadata(prompt_id) { - let confirmation = window.prompt( - PromptLevel::Warning, - &format!( - "Are you sure you want to delete {}", - metadata.title.unwrap_or("Untitled".into()) - ), - None, - &["Delete", "Cancel"], - cx, - ); - - cx.spawn_in(window, async move |this, cx| { - if confirmation.await.ok() == Some(0) { - this.update_in(cx, |this, window, cx| { - if this.active_rule_id == Some(prompt_id) { - this.set_active_rule(None, window, cx); - } - this.rule_editors.remove(&prompt_id); - this.store - .update(cx, |store, cx| store.delete(prompt_id, cx)) - .detach_and_log_err(cx); - this.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.notify(); - })?; - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - } - - pub fn duplicate_rule( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule) = self.rule_editors.get(&prompt_id) { - const DUPLICATE_SUFFIX: &str = " copy"; - let title_to_duplicate = rule.title_editor.read(cx).text(cx); - let existing_titles = self - .rule_editors - .iter() - .filter(|&(&id, _)| id != prompt_id) - .map(|(_, rule_editor)| rule_editor.title_editor.read(cx).text(cx)) - .filter(|title| title.starts_with(&title_to_duplicate)) - .collect::>(); - - let title = if existing_titles.is_empty() { - title_to_duplicate + DUPLICATE_SUFFIX - } else { - let mut i = 1; - loop { - let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}"); - if !existing_titles.contains(&new_title) { - break new_title; - } - i += 1; - } - }; - - let new_id = PromptId::new(); - let body = rule.body_editor.read(cx).text(cx); - let save = self.store.update(cx, |store, cx| { - store.save(new_id, Some(title.into()), false, body.into(), cx) - }); - self.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.spawn_in(window, async move |this, cx| { - save.await?; - this.update_in(cx, |rules_library, window, cx| { - rules_library.load_rule(new_id, true, window, cx) - }) - }) - .detach_and_log_err(cx); - } - } - - fn focus_active_rule(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { - if let Some(active_rule) = self.active_rule_id { - self.rule_editors[&active_rule] - .body_editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)); - cx.stop_propagation(); - } - } - - fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { - self.picker - .update(cx, |picker, cx| picker.focus(window, cx)); - } - - pub fn inline_assist( - &mut self, - action: &InlineAssist, - window: &mut Window, - cx: &mut Context, - ) { - let Some(active_rule_id) = self.active_rule_id else { - cx.propagate(); - return; - }; - - let rule_editor = &self.rule_editors[&active_rule_id].body_editor; - let Some(ConfiguredModel { provider, .. }) = - LanguageModelRegistry::read_global(cx).inline_assistant_model() - else { - return; - }; - - let initial_prompt = action.prompt.clone(); - if provider.is_authenticated(cx) { - self.inline_assist_delegate - .assist(rule_editor, initial_prompt, window, cx); - } else { - for window in cx.windows() { - if let Some(multi_workspace) = window.downcast::() { - let panel = multi_workspace - .update(cx, |multi_workspace, window, cx| { - window.activate_window(); - multi_workspace.workspace().update(cx, |workspace, cx| { - self.inline_assist_delegate - .focus_agent_panel(workspace, window, cx) - }) - }) - .ok(); - if panel == Some(true) { - return; - } - } - } - } - } - - fn move_down_from_title( - &mut self, - _: &zed_actions::editor::MoveDown, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule_id) = self.active_rule_id - && let Some(rule_editor) = self.rule_editors.get(&rule_id) - { - window.focus(&rule_editor.body_editor.focus_handle(cx), cx); - } - } - - fn move_up_from_body( - &mut self, - _: &zed_actions::editor::MoveUp, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule_id) = self.active_rule_id - && let Some(rule_editor) = self.rule_editors.get(&rule_id) - { - window.focus(&rule_editor.title_editor.focus_handle(cx), cx); - } - } - - fn handle_rule_title_editor_event( - &mut self, - prompt_id: PromptId, - title_editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::BufferEdited => { - self.save_rule(prompt_id, window, cx); - } - EditorEvent::Blurred => { - title_editor.update(cx, |title_editor, cx| { - title_editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - let cursor = selections.oldest_anchor().head(); - selections.select_anchor_ranges([cursor..cursor]); - }, - ); - }); - } - _ => {} - } - } - - fn handle_rule_body_editor_event( - &mut self, - prompt_id: PromptId, - body_editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::BufferEdited => { - self.save_rule(prompt_id, window, cx); - } - EditorEvent::Blurred => { - body_editor.update(cx, |body_editor, cx| { - body_editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - let cursor = selections.oldest_anchor().head(); - selections.select_anchor_ranges([cursor..cursor]); - }, - ); - }); - } - _ => {} - } - } - - fn render_rule_list(&mut self, cx: &mut Context) -> impl IntoElement { - v_flex() - .id("rule-list") - .capture_action(cx.listener(Self::focus_active_rule)) - .px_1p5() - .h_full() - .w_64() - .overflow_x_hidden() - .bg(cx.theme().colors().panel_background) - .map(|this| { - if cfg!(target_os = "macos") { - this.child( - h_flex() - .p(DynamicSpacing::Base04.rems(cx)) - .h_9() - .w_full() - .flex_none() - .justify_end() - .child( - IconButton::new("new-rule", IconName::Plus) - .tooltip(move |_window, cx| { - Tooltip::for_action("New Rule", &NewRule, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(NewRule), cx); - }), - ), - ) - } else { - this.child( - h_flex().p_1().w_full().child( - Button::new("new-rule", "New Rule") - .full_width() - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(NewRule), cx); - }), - ), - ) - } - }) - .child(div().flex_grow().child(self.picker.clone())) - } - - fn render_active_rule_editor( - &self, - editor: &Entity, - read_only: bool, - cx: &mut Context, - ) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_color = if read_only { - cx.theme().colors().text_muted - } else { - cx.theme().colors().text - }; - - div() - .w_full() - .pl_1() - .border_1() - .border_color(transparent_black()) - .rounded_sm() - .when(!read_only, |this| { - this.group_hover("active-editor-header", |this| { - this.border_color(cx.theme().colors().border_variant) - }) - }) - .on_action(cx.listener(Self::move_down_from_title)) - .child(EditorElement::new( - &editor, - EditorStyle { - background: cx.theme().system().transparent, - local_player: cx.theme().players().local(), - text: TextStyle { - color: text_color, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_size: HeadlineSize::Medium.rems().into(), - font_weight: settings.ui_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - scrollbar_width: Pixels::ZERO, - syntax: cx.theme().syntax().clone(), - status: cx.theme().status().clone(), - inlay_hints_style: editor::make_inlay_hints_style(cx), - edit_prediction_styles: editor::make_suggestion_styles(cx), - ..EditorStyle::default() - }, - )) - } - - fn render_duplicate_rule_button(&self) -> impl IntoElement { - IconButton::new("duplicate-rule", IconName::BookCopy) - .tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(DuplicateRule), cx); - }) - } - - fn render_built_in_rule_controls(&self) -> impl IntoElement { - h_flex() - .gap_1() - .child(self.render_duplicate_rule_button()) - .child( - IconButton::new("restore-default", IconName::RotateCcw) - .tooltip(move |_window, cx| { - Tooltip::for_action( - "Restore to Default Content", - &RestoreDefaultContent, - cx, - ) - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(RestoreDefaultContent), cx); - }), - ) - } - - fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement { - h_flex() - .gap_1() - .child( - IconButton::new("toggle-default-rule", IconName::Paperclip) - .toggle_state(default) - .when(default, |this| this.icon_color(Color::Accent)) - .map(|this| { - if default { - this.tooltip(Tooltip::text("Remove from Default Rules")) - } else { - this.tooltip(move |_window, cx| { - Tooltip::with_meta( - "Add to Default Rules", - None, - "Always included in every thread.", - cx, - ) - }) - } - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(ToggleDefaultRule), cx); - }), - ) - .child(self.render_duplicate_rule_button()) - .child( - IconButton::new("delete-rule", IconName::Trash) - .tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(DeleteRule), cx); - }), - ) - } - - fn render_active_rule(&mut self, cx: &mut Context) -> gpui::Stateful
{ - div() - .id("rule-editor") - .h_full() - .flex_grow() - .border_l_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .children(self.active_rule_id.and_then(|prompt_id| { - let rule_metadata = self.store.read(cx).metadata(prompt_id)?; - let rule_editor = &self.rule_editors[&prompt_id]; - let focus_handle = rule_editor.body_editor.focus_handle(cx); - let built_in = prompt_id.is_built_in(); - - Some( - v_flex() - .id("rule-editor-inner") - .size_full() - .relative() - .overflow_hidden() - .on_click(cx.listener(move |_, _, window, cx| { - window.focus(&focus_handle, cx); - })) - .child( - h_flex() - .group("active-editor-header") - .h_12() - .px_2() - .gap_2() - .justify_between() - .child(self.render_active_rule_editor( - &rule_editor.title_editor, - built_in, - cx, - )) - .child(h_flex().h_full().flex_shrink_0().map(|this| { - if built_in { - this.child(self.render_built_in_rule_controls()) - } else { - this.child( - self.render_regular_rule_controls( - rule_metadata.default, - ), - ) - } - })), - ) - .child( - div() - .on_action(cx.listener(Self::focus_picker)) - .on_action(cx.listener(Self::inline_assist)) - .on_action(cx.listener(Self::move_up_from_body)) - .h_full() - .flex_grow() - .child( - h_flex() - .py_2() - .pl_2p5() - .h_full() - .flex_1() - .child(rule_editor.body_editor.clone()), - ), - ), - ) - })) - } -} - -impl Render for RulesLibrary { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme_settings::setup_ui_font(window, cx); - let theme = cx.theme().clone(); - - client_side_decorations( - v_flex() - .id("rules-library") - .key_context("RulesLibrary") - .on_action( - |action_sequence: &ActionSequence, window: &mut Window, cx: &mut App| { - for action in &action_sequence.0 { - window.dispatch_action(action.boxed_clone(), cx); - } - }, - ) - .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx))) - .on_action( - cx.listener(|this, &DeleteRule, window, cx| { - this.delete_active_rule(window, cx) - }), - ) - .on_action(cx.listener(|this, &DuplicateRule, window, cx| { - this.duplicate_active_rule(window, cx) - })) - .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| { - this.toggle_default_for_active_rule(window, cx) - })) - .on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| { - this.restore_default_content_for_active_rule(window, cx) - })) - .size_full() - .overflow_hidden() - .font(ui_font) - .text_color(theme.colors().text) - .children(self.title_bar.clone()) - .bg(theme.colors().background) - .child( - h_flex() - .flex_1() - .when(!cfg!(target_os = "macos"), |this| { - this.border_t_1().border_color(cx.theme().colors().border) - }) - .child(self.render_rule_list(cx)) - .child(self.render_active_rule(cx)), - ), - window, - cx, - Tiling::default(), - ) - } -} diff --git a/crates/nc/Cargo.toml b/crates/sandbox/Cargo.toml similarity index 55% rename from crates/nc/Cargo.toml rename to crates/sandbox/Cargo.toml index 534ec2271ca..9e7e4aa1e0d 100644 --- a/crates/nc/Cargo.toml +++ b/crates/sandbox/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nc" +name = "sandbox" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,11 +9,8 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/nc.rs" -doctest = false +path = "src/sandbox.rs" -[dependencies] +[target.'cfg(target_os = "macos")'.dependencies] anyhow.workspace = true -futures.workspace = true -net.workspace = true -smol.workspace = true +tempfile.workspace = true diff --git a/crates/sandbox/LICENSE-GPL b/crates/sandbox/LICENSE-GPL new file mode 120000 index 00000000000..89e542f750c --- /dev/null +++ b/crates/sandbox/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs new file mode 100644 index 00000000000..0357ecb361b --- /dev/null +++ b/crates/sandbox/src/macos_seatbelt.rs @@ -0,0 +1,600 @@ +//! macOS Seatbelt sandbox integration. +//! +//! This module is specifically about Apple's Seatbelt sandbox API — the +//! macOS-only kernel-level sandboxing framework, accessed via the +//! `sandbox-exec(1)` command-line tool and a Seatbelt-specific config +//! file (a Scheme-like policy language documented in Apple's +//! `sandbox.h` and the `sandbox-exec` man page). +//! +//! The integration wraps a shell invocation by: +//! +//! 1. Generating a Seatbelt config file (a string of Scheme-like rules) +//! from the requested [`SandboxPermissions`]. +//! 2. Writing it to a temporary file on disk (a [`SeatbeltConfigFile`], +//! which cleans itself up when dropped). +//! 3. Returning the program/args needed to launch the original command +//! under `sandbox-exec -f `. +//! +//! Reads are permitted by default; writes are restricted to a caller- +//! provided list of directories; network access and unrestricted writes +//! must be opted into per command. + +use std::path::Path; +use std::{io::Write, path::PathBuf}; + +use anyhow::{Context, Result}; +use tempfile::NamedTempFile; + +/// Per-command relaxations of the default Seatbelt sandbox. +/// +/// All-false is the default, fully-sandboxed run. Setting any field +/// requires user approval before the command is launched. +/// +/// There are some baseline OS operations (e.g. arbitrary hardware access) +/// that are disallowed by Seatbelt's baseline policy regardless of these +/// flags; even with everything `true` here those operations stay denied. +/// The only way to allow them is to skip the sandbox entirely (which this +/// module deliberately doesn't expose). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SandboxPermissions { + /// Allow network access for the command. + pub allow_network: bool, + /// Allow unrestricted filesystem writes. + pub allow_fs_write: bool, +} + +/// A Seatbelt config file written to a temporary path on disk, suitable +/// for `sandbox-exec -f `. The file is deleted when this is dropped. +/// +/// The config-file content is the Scheme-like Seatbelt policy language +/// (see `sandbox-exec(1)` and the comments in macOS's `sandbox.h`); it's +/// generated from a [`SandboxPermissions`] by [`generate_seatbelt_config`]. +pub struct SeatbeltConfigFile { + /// The temporary file containing the Seatbelt config. + /// Kept alive so the file exists for the duration of the command. + _file: NamedTempFile, + /// Path to the temporary config file on disk. + path: PathBuf, +} + +impl SeatbeltConfigFile { + /// Generate a Seatbelt config from `permissions` and write it to a + /// fresh temporary file. + /// + /// `writable_directories` lists every directory subtree where the + /// command is allowed to write when `permissions.allow_fs_write` is + /// false. Pass the project's worktree paths here — not the working + /// directory of the command, since that is model-controlled and would + /// let the model widen its own writable scope. + pub fn new(writable_directories: &[&Path], permissions: SandboxPermissions) -> Result { + let mut file = + NamedTempFile::new().context("failed to create temporary Seatbelt config file")?; + + let config = generate_seatbelt_config(writable_directories, permissions)?; + file.write_all(config.as_bytes()) + .context("failed to write Seatbelt config")?; + file.flush().context("failed to flush Seatbelt config")?; + + let path = file.path().to_path_buf(); + + Ok(Self { _file: file, path }) + } +} + +/// Wrap a process invocation so it runs under macOS's `sandbox-exec(1)` +/// with a Seatbelt config built from `permissions`. +/// +/// Returns the new program and arguments to execute, along with a +/// [`SeatbeltConfigFile`] that **must** be kept alive for the duration of +/// the command (the file is deleted when dropped, and `sandbox-exec` reads +/// it lazily when the child process starts up). +/// +/// # Arguments +/// * `program` - The program to invoke (typically a shell, e.g. `"/bin/sh"`, +/// but anything that takes its arguments via `argv` works). +/// * `args` - The full argument list that would have been passed to +/// `program`. +/// * `writable_directories` - Directory subtrees where the command is +/// allowed to write when `permissions.allow_fs_write` is false. Pass +/// the project's worktree paths here, not the working directory of the +/// command (the working directory is model-controlled, and using it as +/// the writable scope would let the model write outside the project). +/// * `permissions` - Sandbox relaxations requested for this command. +/// +/// # Returns +/// A tuple of `(program, args, config_file)` where `config_file` must be +/// kept alive. +pub fn wrap_invocation( + program: &str, + args: &[String], + writable_directories: &[&Path], + permissions: SandboxPermissions, +) -> Result<(String, Vec, SeatbeltConfigFile)> { + let config_file = SeatbeltConfigFile::new(writable_directories, permissions)?; + + let mut wrapped_args = vec![ + "-f".to_string(), + config_file + .path + .to_str() + .with_context(|| { + format!( + "Seatbelt config file path contains invalid UTF-8: {}", + config_file.path.display() + ) + })? + .to_string(), + program.to_string(), + ]; + wrapped_args.extend(args.iter().cloned()); + + Ok(( + "/usr/bin/sandbox-exec".to_string(), + wrapped_args, + config_file, + )) +} + +/// Generate a Seatbelt config string that reads everywhere by default. +/// Writes to each entry in `writable_directories` (typically the project's +/// worktree paths plus any per-command scratch directory the caller wants +/// allowed) and the standard `/dev/*` write targets are also allowed by +/// default; network access and unrestricted filesystem writes must be +/// requested via [`SandboxPermissions`]. +/// +/// The returned string is the textual content to write to the +/// [`SeatbeltConfigFile`] passed to `sandbox-exec -f`. +fn generate_seatbelt_config( + writable_directories: &[&Path], + permissions: SandboxPermissions, +) -> Result { + // Canonicalize each writable path to resolve symlinks (e.g., + // /var -> /private/var on macOS). Fall back to the original path if + // canonicalization fails. + let canonical_writable_directories: Vec = writable_directories + .iter() + .map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf())) + .collect(); + + let mut config = r#"(version 1) + +; Start by denying everything +(deny default) + +; Allow reading from the entire filesystem +(allow file-read*) + +; Allow process execution +(allow process-exec*) +(allow process-fork) + +; Allow signal handling +(allow signal) + +; Allow sysctl reads (needed for many system calls) +(allow sysctl-read) + +; Allow mach lookups (needed for IPC) +(allow mach-lookup) + +; Allow pseudo-terminal operations +(allow pseudo-tty) +"# + .to_string(); + + if permissions.allow_fs_write { + config.push_str( + r#" +; Allow unrestricted filesystem writes +(allow file-write*) +"#, + ); + } else { + for canonical_path in &canonical_writable_directories { + let escaped_path = escape_sandbox_path(canonical_path)?; + config.push_str(&format!( + r#" +; Allow writing to a permitted directory +(allow file-write* + (subpath "{escaped_path}")) +"# + )); + } + + config.push_str( + r#" +; Allow writing to common /dev paths (needed for redirections like 2>/dev/null) +(allow file-write* + (literal "/dev/null") + (literal "/dev/zero") + (literal "/dev/tty") + (literal "/dev/stdin") + (literal "/dev/stdout") + (literal "/dev/stderr") + (subpath "/dev/fd")) +"#, + ); + } + + if permissions.allow_network { + config.push_str( + r#" +; Allow network access +(allow network*) +"#, + ); + } + + Ok(config) +} + +/// Escape a path for use in a Seatbelt config string. +/// +/// Seatbelt configs use a Scheme-like syntax where certain characters need +/// to be handled carefully. +fn escape_sandbox_path(path: &Path) -> Result { + let path_str = path + .to_str() + .with_context(|| format!("path contains invalid UTF-8: {}", path.display()))?; + Ok(path_str.replace('\\', "\\\\").replace('"', "\\\"")) +} + +#[cfg(test)] +#[allow( + clippy::disallowed_methods, + reason = "tests run sandbox-exec synchronously to verify the generated Seatbelt config" +)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_generate_seatbelt_config_contains_read_and_project_write_permissions_by_default() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = + generate_seatbelt_config(&[dir.as_path()], SandboxPermissions::default()).unwrap(); + + assert!(config.contains("(allow file-read*)")); + assert!(config.contains("/Users/test/projects/myproject")); + assert!(config.contains("(allow file-write*")); + assert!(!config.contains("; Allow unrestricted filesystem writes")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_generate_seatbelt_config_allows_unrestricted_writes_when_fs_writes_allowed() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = generate_seatbelt_config( + &[dir.as_path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + assert!(config.contains("(allow file-read*)")); + assert!(config.contains("; Allow unrestricted filesystem writes")); + assert!(config.contains("(allow file-write*)")); + assert!(!config.contains("/Users/test/projects/myproject")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_generate_seatbelt_config_contains_network_when_allowed() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = generate_seatbelt_config( + &[dir.as_path()], + SandboxPermissions { + allow_network: true, + allow_fs_write: false, + }, + ) + .unwrap(); + + assert!(config.contains("(allow network*)")); + assert!(config.contains("/Users/test/projects/myproject")); + assert!(config.contains("(allow file-write*")); + assert!(!config.contains("; Allow unrestricted filesystem writes")); + } + + #[test] + fn test_generate_seatbelt_config_emits_one_subpath_per_writable_directory() { + let project_dir = PathBuf::from("/Users/test/projects/myproject"); + let scratch_dir = PathBuf::from("/private/tmp/zed-agent-command"); + let config = generate_seatbelt_config( + &[project_dir.as_path(), scratch_dir.as_path()], + SandboxPermissions::default(), + ) + .unwrap(); + + assert!(config.contains("/Users/test/projects/myproject")); + assert!(config.contains("/private/tmp/zed-agent-command")); + assert!(!config.contains("; Allow unrestricted filesystem writes")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_escape_sandbox_path_handles_special_chars() { + let path = PathBuf::from("/path/with\"quotes"); + let escaped = escape_sandbox_path(&path).unwrap(); + assert_eq!(escaped, "/path/with\\\"quotes"); + } + + #[cfg(unix)] + #[test] + fn test_escape_sandbox_path_rejects_invalid_utf8() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + let path = PathBuf::from(OsString::from_vec(b"/path/with/invalid/\xFF".to_vec())); + let error = escape_sandbox_path(&path).unwrap_err(); + + assert!(error.to_string().contains("invalid UTF-8")); + } + + #[test] + fn test_wrap_invocation_structure() { + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "echo hello".to_string()], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + assert_eq!(program, "/usr/bin/sandbox-exec"); + assert_eq!(args[0], "-f"); + // args[1] is the temp file path + assert_eq!(args[2], "/bin/sh"); + assert_eq!(args[3], "-c"); + assert_eq!(args[4], "echo hello"); + } + + #[test] + fn test_sandbox_allows_read_everywhere() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "cat /etc/hosts".to_string()], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow reading /etc/hosts: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn test_sandbox_allows_dev_null_redirection_by_default() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "echo test 2>/dev/null".to_string()], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow redirecting to /dev/null by default: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn test_sandbox_allows_dev_null_redirection_when_fs_writes_allowed() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &["-c".to_string(), "echo test 2>/dev/null".to_string()], + &[temp_dir.path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow redirecting to /dev/null: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn test_sandbox_allows_write_to_project_directory_when_fs_writes_allowed() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let test_file = temp_dir.path().join("test_write.txt"); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[temp_dir.path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to project dir: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + } + + #[test] + fn test_sandbox_allows_write_to_any_listed_writable_directory() { + use std::process::Command; + + let project_dir = tempfile::tempdir().unwrap(); + let scratch_dir = tempfile::tempdir().unwrap(); + let test_file = scratch_dir.path().join("test_write.txt"); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[project_dir.path(), scratch_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to a non-first writable directory: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + } + + #[test] + fn test_sandbox_allows_write_to_project_directory_by_default() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let test_file = temp_dir.path().join("test_write.txt"); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[temp_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to project dir by default: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + } + + #[test] + fn test_sandbox_allows_write_to_system_tmp_when_fs_writes_allowed() { + use std::process::Command; + + let project_dir = tempfile::tempdir().unwrap(); + let test_file = PathBuf::from("/tmp/zed-sandbox-write-test"); + let _ = std::fs::remove_file(&test_file); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", test_file.display()), + ], + &[project_dir.path()], + SandboxPermissions { + allow_network: false, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow writing to system tmp when filesystem writes are allowed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(test_file.exists(), "file should have been created"); + let _ = std::fs::remove_file(&test_file); + } + + #[test] + fn test_sandbox_denies_write_outside_project_directory_by_default() { + use std::process::Command; + + let project_dir = tempfile::tempdir().unwrap(); + let forbidden_file = std::env::home_dir() + .unwrap() + .join(".zed-sandbox-forbidden-write-test"); + let _ = std::fs::remove_file(&forbidden_file); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!("echo 'hello' > '{}'", forbidden_file.display()), + ], + &[project_dir.path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + !output.status.success(), + "sandbox should deny writing outside project dir when filesystem writes are not allowed" + ); + assert!( + !forbidden_file.exists(), + "file should not have been created" + ); + } +} diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs new file mode 100644 index 00000000000..64095d11da2 --- /dev/null +++ b/crates/sandbox/src/sandbox.rs @@ -0,0 +1,12 @@ +//! Per-OS sandbox integrations for terminal commands run on behalf of the +//! agent. +//! +//! Each supported operating system has its own module here, gated behind +//! its `target_os` cfg so callers reach for the right one explicitly and +//! non-host targets don't carry dead code. +//! +//! Today only macOS has an integration ([`macos_seatbelt`]), wrapping +//! Apple's Seatbelt / `sandbox-exec` framework. + +#[cfg(target_os = "macos")] +pub mod macos_seatbelt; diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 46dd4b54ba1..e5474a4cf03 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -1,5 +1,7 @@ use crate::{Instant, Priority, RunnableMeta, Scheduler, SessionId, Timer}; +use async_task::Runnable; use std::{ + any::Any, future::Future, marker::PhantomData, mem::ManuallyDrop, @@ -12,18 +14,39 @@ use std::{ time::Duration, }; +/// A `!Send` executor pinned to a single session. Tasks spawned on it run in +/// order on whichever thread drains the dispatch destination supplied at +/// construction time — typically the main thread for the default session, or +/// a dedicated OS thread for sessions created by `spawn_dedicated_thread`. #[derive(Clone)] -pub struct ForegroundExecutor { +pub struct LocalExecutor { session_id: SessionId, scheduler: Arc, + // Spawned tasks' schedule callbacks each hold an `Arc` clone of this + // closure, so the destination it captures stays alive as long as work + // could still land on it. + dispatch: Arc) + Send + Sync>, not_send: PhantomData>, } -impl ForegroundExecutor { - pub fn new(session_id: SessionId, scheduler: Arc) -> Self { +impl LocalExecutor { + /// Constructs a local executor that runs spawned tasks by sending their + /// runnables through `dispatch`. The `scheduler` is retained for access to + /// clocks, timers, and other scheduler-level services. + /// + /// For the common case of routing runnables through + /// `Scheduler::schedule_local`, callers pass a closure that does exactly + /// that. `spawn_dedicated_thread` instead passes a closure that sends to + /// the dedicated thread's channel. + pub fn new( + session_id: SessionId, + scheduler: Arc, + dispatch: impl Fn(Runnable) + Send + Sync + 'static, + ) -> Self { Self { session_id, scheduler, + dispatch: Arc::new(dispatch), not_send: PhantomData, } } @@ -42,14 +65,11 @@ impl ForegroundExecutor { F: Future + 'static, F::Output: 'static, { - let session_id = self.session_id; - let scheduler = Arc::clone(&self.scheduler); + let dispatch = self.dispatch.clone(); let location = Location::caller(); let (runnable, task) = spawn_local_with_source_location( future, - move |runnable| { - scheduler.schedule_foreground(session_id, runnable); - }, + move |runnable| dispatch(runnable), RunnableMeta { location }, ); runnable.schedule(); @@ -108,6 +128,48 @@ impl ForegroundExecutor { pub fn now(&self) -> Instant { self.scheduler.clock().now() } + + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + /// The closure runs on a new OS thread under `PlatformScheduler`, or on + /// the test scheduler's loop under `TestScheduler`. + /// + /// The returned `Task` represents the dedicated work: dropping it cancels + /// the dedicated closure, `.await`ing it yields the closure's return + /// value, `.detach()`ing it lets the dedicated work run independently of + /// the caller. + #[track_caller] + pub fn spawn_dedicated(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } +} + +/// Boxes the user-supplied dedicated closure into the type-erased shape +/// expected by [`Scheduler::spawn_dedicated`]. The user's `Fut::Output` is +/// boxed as `Box` on the dedicated side and downcast +/// back to `Fut::Output` by [`Task::downcast`] in the wrapper. +fn box_dedicated( + f: F, +) -> Box< + dyn FnOnce(LocalExecutor) -> Pin> + 'static>> + + Send + + 'static, +> +where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, +{ + Box::new(move |executor| { + Box::pin(async move { Box::new(f(executor).await) as Box }) + }) } #[derive(Clone)] @@ -135,14 +197,16 @@ impl BackgroundExecutor { F: Future + Send + 'static, F::Output: Send + 'static, { - let scheduler = Arc::clone(&self.scheduler); + let scheduler = Arc::downgrade(&self.scheduler); let location = Location::caller(); let (runnable, task) = async_task::Builder::new() .metadata(RunnableMeta { location }) .spawn( move |_| future, move |runnable| { - scheduler.schedule_background_with_priority(runnable, priority); + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_background_with_priority(runnable, priority); + } }, ); runnable.schedule(); @@ -189,6 +253,27 @@ impl BackgroundExecutor { pub fn scheduler(&self) -> &Arc { &self.scheduler } + + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + /// The closure runs on a new OS thread under `PlatformScheduler`, or on + /// the test scheduler's loop under `TestScheduler`. + /// + /// The returned `Task` represents the dedicated work: dropping it cancels + /// the dedicated closure, `.await`ing it yields the closure's return + /// value, `.detach()`ing it lets the dedicated work run independently of + /// the caller. + #[track_caller] + pub fn spawn_dedicated(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } } /// Task is a primitive that allows work to happen in the background. @@ -198,16 +283,22 @@ impl BackgroundExecutor { /// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows /// the task to continue running, but with no way to return a value. #[must_use] -#[derive(Debug)] pub struct Task(TaskState); -#[derive(Debug)] enum TaskState { /// A task that is ready to return a value Ready(Option), /// A task that is currently running. Spawned(async_task::Task), + + /// A typed view of a [`Task>`] obtained via + /// [`Task::downcast`]. The inner task drives the actual work; the + /// downcast layer just unwraps the `Box` on poll. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl Task { @@ -225,6 +316,7 @@ impl Task { match &self.0 { TaskState::Ready(_) => true, TaskState::Spawned(task) => task.is_finished(), + TaskState::Downcast { inner, .. } => inner.is_ready(), } } @@ -233,6 +325,7 @@ impl Task { match self { Task(TaskState::Ready(_)) => {} Task(TaskState::Spawned(task)) => task.detach(), + Task(TaskState::Downcast { inner, .. }) => inner.detach(), } } @@ -241,10 +334,43 @@ impl Task { FallibleTask(match self.0 { TaskState::Ready(val) => FallibleTaskState::Ready(val), TaskState::Spawned(task) => FallibleTaskState::Spawned(task.fallible()), + TaskState::Downcast { inner, .. } => FallibleTaskState::Downcast { + inner: Box::new(inner.fallible()), + marker: PhantomData, + }, }) } } +impl Task> { + /// Reinterprets the boxed output as a concrete `T` via downcast on + /// completion. Used by [`LocalExecutor::spawn_dedicated`] and + /// [`BackgroundExecutor::spawn_dedicated`] to recover the user closure's + /// `Fut::Output` from the dyn-safe [`Scheduler::spawn_dedicated`]. + /// + /// Panics on poll if the inner output is not in fact a `T` -- a logic + /// error in whatever produced the inner task, since the downcast type is + /// chosen by the caller of `downcast`. + pub fn downcast(self) -> Task { + Task(TaskState::Downcast { + inner: Box::new(self), + marker: PhantomData, + }) + } +} + +impl std::fmt::Debug for Task { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + TaskState::Ready(_) => f.debug_tuple("Task::Ready").finish(), + TaskState::Spawned(task) => f.debug_tuple("Task::Spawned").field(task).finish(), + TaskState::Downcast { inner, .. } => { + f.debug_tuple("Task::Downcast").field(inner).finish() + } + } + } +} + /// A task that returns `Option` instead of panicking when cancelled. #[must_use] pub struct FallibleTask(FallibleTaskState); @@ -255,6 +381,12 @@ enum FallibleTaskState { /// A task that is currently running (wraps async_task::FallibleTask). Spawned(async_task::FallibleTask), + + /// Mirror of [`TaskState::Downcast`] for fallible tasks. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl FallibleTask { @@ -268,17 +400,29 @@ impl FallibleTask { match self.0 { FallibleTaskState::Ready(_) => {} FallibleTaskState::Spawned(task) => task.detach(), + FallibleTaskState::Downcast { inner, .. } => inner.detach(), } } } -impl Future for FallibleTask { +impl Future for FallibleTask { type Output = Option; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { match unsafe { self.get_unchecked_mut() } { FallibleTask(FallibleTaskState::Ready(val)) => Poll::Ready(val.take()), FallibleTask(FallibleTaskState::Spawned(task)) => Pin::new(task).poll(cx), + FallibleTask(FallibleTaskState::Downcast { inner, .. }) => { + match Pin::new(inner.as_mut()).poll(cx) { + Poll::Ready(Some(boxed_any)) => Poll::Ready(Some( + *boxed_any + .downcast::() + .expect("FallibleTask::poll: downcast type mismatch"), + )), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } } } } @@ -290,17 +434,29 @@ impl std::fmt::Debug for FallibleTask { FallibleTaskState::Spawned(task) => { f.debug_tuple("FallibleTask::Spawned").field(task).finish() } + FallibleTaskState::Downcast { inner, .. } => f + .debug_tuple("FallibleTask::Downcast") + .field(inner) + .finish(), } } } -impl Future for Task { +impl Future for Task { type Output = T; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { match unsafe { self.get_unchecked_mut() } { Task(TaskState::Ready(val)) => Poll::Ready(val.take().unwrap()), Task(TaskState::Spawned(task)) => Pin::new(task).poll(cx), + Task(TaskState::Downcast { inner, .. }) => match Pin::new(inner.as_mut()).poll(cx) { + Poll::Ready(boxed_any) => Poll::Ready( + *boxed_any + .downcast::() + .expect("Task::poll: downcast type mismatch"), + ), + Poll::Pending => Poll::Pending, + }, } } } diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 05d285df8d9..92125456232 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -11,11 +11,13 @@ pub use test_scheduler::*; use async_task::Runnable; use futures::channel::oneshot; use std::{ + any::Any, future::Future, panic::Location, pin::Pin, sync::Arc, task::{Context, Poll}, + thread, time::Duration, }; @@ -82,7 +84,11 @@ pub trait Scheduler: Send + Sync { timeout: Option, ) -> bool; - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable); + /// Schedule a runnable on the local (session-pinned) queue for `session_id`. + /// Runnables scheduled here run in order on whichever thread drains the + /// session — the main thread for ordinary sessions, or a dedicated OS + /// thread for sessions created via `spawn_dedicated_thread`. + fn schedule_local(&self, session_id: SessionId, runnable: Runnable); /// Schedule a background task with the given priority. fn schedule_background_with_priority( @@ -103,11 +109,87 @@ pub trait Scheduler: Send + Sync { fn timer(&self, timeout: Duration) -> Timer; fn clock(&self) -> Arc; + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + /// + /// `PlatformScheduler` runs the closure on a new OS thread (see + /// [`spawn_dedicated_thread`]). `TestScheduler` runs it on the test + /// scheduler's loop alongside everything else so determinism under + /// `TestScheduler::many` is preserved. + /// + /// This is the dyn-safe entry point: the closure's output is type-erased + /// as `Box` so the trait stays object-safe. + /// Callers typically reach for the type-safe wrappers on + /// [`LocalExecutor::spawn_dedicated`] and + /// [`BackgroundExecutor::spawn_dedicated`], which compose this method + /// with [`Task::downcast`] to recover the closure's concrete return type. + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task>; + fn as_test(&self) -> Option<&TestScheduler> { None } } +/// Spawn work on a fresh OS thread that's exclusive to the returned task and +/// anything spawned on the executor it provides. Blocking syscalls inside that +/// work don't disturb any other executor in the process. +/// +/// `f` is called on the dedicated thread with a [`LocalExecutor`] pinned +/// to it. The future `f` returns may freely be `!Send`. The returned `Task` is +/// that future's task: dropping it cancels the root, but detached children +/// keep running until they finish. The thread shuts down once the executor and +/// every task on it are gone. +/// +/// The caller is responsible for supplying a `session_id` that's distinct from +/// every other live session on `scheduler`. Concrete schedulers typically wrap +/// this in an inherent method that allocates the id from their own counter. +pub fn spawn_dedicated_thread( + session_id: SessionId, + scheduler: Arc, + f: F, +) -> Task +where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + 'static, +{ + let (runnable_sender, runnable_receiver) = flume::unbounded::>(); + let (task_sender, task_receiver) = flume::bounded::>(1); + + thread::Builder::new() + .name(format!("spawn_dedicated session {:?}", session_id)) + .spawn(move || { + let dispatch = move |runnable: Runnable| { + let _ = runnable_sender.send(runnable); + }; + let executor = LocalExecutor::new(session_id, scheduler, dispatch); + let root_task = executor.spawn(f(executor.clone())); + let _ = task_sender.send(root_task); + // After this drop, every strong reference to the runnable sender + // lives inside a spawned task or a user-held executor clone. The + // recv loop exits once all of those are gone. + drop(executor); + + while let Ok(runnable) = runnable_receiver.recv() { + runnable.run(); + } + }) + .expect("failed to spawn dedicated thread"); + + task_receiver + .recv() + .expect("dedicated thread failed to produce root task") +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct SessionId(u16); diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index c4c536c0977..722dfb13587 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -1,6 +1,6 @@ use crate::{ - BackgroundExecutor, Clock, ForegroundExecutor, Instant, Priority, RunnableMeta, Scheduler, - SessionId, TestClock, Timer, + BackgroundExecutor, Clock, Instant, LocalExecutor, Priority, RunnableMeta, Scheduler, + SessionId, Task, TestClock, Timer, }; use async_task::Runnable; use backtrace::{Backtrace, BacktraceFrame}; @@ -10,6 +10,7 @@ use rand::{ distr::{StandardUniform, uniform::SampleRange, uniform::SampleUniform}, prelude::*, }; +use std::any::Any; use std::{ any::type_name_of_val, collections::{BTreeMap, HashSet, VecDeque}, @@ -57,10 +58,14 @@ impl TestScheduler { .map(|seed| seed.parse().unwrap()) .unwrap_or(0); + let interactive = !std::env::var("SCHEDULER_NONINTERACTIVE").is_ok(); + (seed..seed + num_iterations as u64) .map(|seed| { let mut unwind_safe_f = AssertUnwindSafe(&mut f); - eprintln!("Running seed: {seed}"); + if interactive { + eprintln!("Running seed: {seed}"); + } match panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) { Ok(result) => result, Err(error) => { @@ -148,18 +153,21 @@ impl TestScheduler { self.state.lock().is_main_thread } - /// Allocate a new session ID for foreground task scheduling. - /// This is used by GPUI's TestDispatcher to map dispatcher instances to sessions. pub fn allocate_session_id(&self) -> SessionId { let mut state = self.state.lock(); state.next_session_id.0 += 1; state.next_session_id } - /// Create a foreground executor for this scheduler - pub fn foreground(self: &Arc) -> ForegroundExecutor { + /// Create a local executor for this scheduler. + pub fn foreground(self: &Arc) -> LocalExecutor { let session_id = self.allocate_session_id(); - ForegroundExecutor::new(session_id, self.clone()) + let scheduler = Arc::downgrade(self); + LocalExecutor::new(session_id, self.clone(), move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) } /// Create a background executor for this scheduler @@ -581,7 +589,7 @@ impl Scheduler for TestScheduler { completed } - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable) { + fn schedule_local(&self, session_id: SessionId, runnable: Runnable) { assert_correct_thread(&self.thread, &self.state); let mut state = self.state.lock(); let ix = if state.randomize_order { @@ -656,6 +664,31 @@ impl Scheduler for TestScheduler { self.clock.clone() } + /// In the test world, dedicated work is just a fresh local session driven + /// by the test scheduler's run loop alongside everything else. No real + /// thread is spawned, so determinism under `TestScheduler::many` is + /// preserved. + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + let session_id = self.allocate_session_id(); + let scheduler = Arc::downgrade(&self); + let executor = LocalExecutor::new(session_id, self, move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }); + executor.spawn(f(executor.clone())) + } + fn as_test(&self) -> Option<&TestScheduler> { Some(self) } diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index 3b294e6ee2e..1e29211ca87 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -34,6 +34,44 @@ fn test_background_executor_spawn() { }); } +#[test] +fn test_scheduler_drops_with_stalled_detached_foreground_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + let (sender, receiver) = oneshot::channel::<()>(); + + scheduler + .foreground() + .spawn(async move { + receiver.await.ok(); + }) + .detach(); + scheduler.run(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); + drop(sender); +} + +#[test] +fn test_scheduler_drops_with_stalled_detached_background_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + let (sender, receiver) = oneshot::channel::<()>(); + + scheduler + .background() + .spawn(async move { + receiver.await.ok(); + }) + .detach(); + scheduler.run(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); + drop(sender); +} + #[test] fn test_foreground_ordering() { let mut traces = HashSet::new(); @@ -690,3 +728,234 @@ fn test_background_priority_scheduling() { iterations ); } + +#[test] +fn test_spawn_dedicated_basic_round_trip() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async { 42 }) + .await + }); + assert_eq!(result, 42); +} + +#[test] +fn test_spawn_dedicated_not_send_future() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async move { + // `Rc>` is `!Send`. If `spawn_dedicated` required + // the returned future to be `Send`, this wouldn't compile. + let state = Rc::new(RefCell::new(0_i32)); + for _ in 0..5 { + *state.borrow_mut() += 1; + } + *state.borrow() + }) + .await + }); + assert_eq!(result, 5); +} + +#[test] +fn test_spawn_dedicated_send_closure_captures() { + use parking_lot::Mutex; + + let observed = TestScheduler::once(async |scheduler| { + let shared = Arc::new(Mutex::new(0_i32)); + let shared_for_closure = shared.clone(); + let returned = scheduler + .background() + .spawn_dedicated(move |_executor| { + // `shared_for_closure` crossed the `Send` boundary of the + // closure; we then mutate it from inside the !Send future. + let local = shared_for_closure; + async move { + *local.lock() = 7; + } + }) + .await; + let _: () = returned; + *shared.lock() + }); + assert_eq!(observed, 7); +} + +#[test] +fn test_spawn_dedicated_inner_spawn_local() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|executor| async move { + // The provided executor can spawn additional `!Send` work + // onto the same dedicated session. + let inner = Rc::new(RefCell::new(0_i32)); + let inner_for_child = inner.clone(); + let child = executor.spawn(async move { + *inner_for_child.borrow_mut() = 99; + *inner_for_child.borrow() + }); + child.await + }) + .await + }); + assert_eq!(result, 99); +} + +#[test] +fn test_spawn_dedicated_determinism_under_many() { + use parking_lot::Mutex; + + let outcomes = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| { + let trace = Arc::new(Mutex::new(Vec::::new())); + + let background = scheduler.background(); + let mut tasks = Vec::new(); + for id in 0..4_u32 { + let trace = trace.clone(); + let task = background.spawn_dedicated(move |executor| async move { + for step in 0..3 { + trace.lock().push(id * 100 + step); + executor.spawn(async {}).await; + } + id + }); + tasks.push(task); + } + + let mut outputs = Vec::new(); + for task in tasks { + outputs.push(task.await); + } + + (trace.lock().clone(), outputs) + }); + + // Re-running with the same seed should produce the same trace. Run a + // second pass with identical seeds and compare to the first. + let outcomes_replay = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| { + let trace = Arc::new(Mutex::new(Vec::::new())); + + let background = scheduler.background(); + let mut tasks = Vec::new(); + for id in 0..4_u32 { + let trace = trace.clone(); + let task = background.spawn_dedicated(move |executor| async move { + for step in 0..3 { + trace.lock().push(id * 100 + step); + executor.spawn(async {}).await; + } + id + }); + tasks.push(task); + } + + let mut outputs = Vec::new(); + for task in tasks { + outputs.push(task.await); + } + + (trace.lock().clone(), outputs) + }); + + assert_eq!( + outcomes, outcomes_replay, + "per-seed outcomes should be reproducible" + ); + + // Sanity: at least one seed produced a non-monotonic trace, + // demonstrating that dedicated tasks really do interleave under the + // scheduler's randomization. + let any_interleaved = outcomes.iter().any(|(trace, _)| { + trace + .windows(2) + .any(|window| window[0] / 100 != window[1] / 100) + }); + assert!( + any_interleaved, + "expected at least one seed to interleave dedicated tasks" + ); +} + +#[test] +fn test_spawn_dedicated_dropping_task_cancels_future() { + use parking_lot::Mutex; + + let counter_after = TestScheduler::once(async |scheduler| { + let counter = Arc::new(Mutex::new(0_u32)); + let (resume_tx, resume_rx) = oneshot::channel::<()>(); + + let task = { + let counter = counter.clone(); + scheduler + .background() + .spawn_dedicated(move |_executor| async move { + *counter.lock() = 1; + // Park here until the test resumes us. If the task is + // dropped before this resolves, the second assignment + // below must never happen. + let _ = resume_rx.await; + *counter.lock() = 2; + }) + }; + + // Let the dedicated future make its first observable step. + scheduler.run(); + assert_eq!(*counter.lock(), 1); + + // Cancel by dropping the root task, then unblock the parked oneshot. + // The future must not advance past the await: counter stays at 1. + drop(task); + let _ = resume_tx.send(()); + scheduler.run(); + + *counter.lock() + }); + + assert_eq!( + counter_after, 1, + "dropping the dedicated task must cancel the root future before its second write" + ); +} + +#[test] +fn test_spawn_dedicated_detached_child_runs_after_root_completes() { + use parking_lot::Mutex; + + let child_ran = TestScheduler::once(async |scheduler| { + let child_ran = Arc::new(Mutex::new(false)); + + let task = { + let child_ran = child_ran.clone(); + scheduler + .background() + .spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + *child_ran.lock() = true; + }) + .detach(); + // Root returns immediately, before the child has had a + // chance to run. + }) + }; + + task.await; + + // Drain the dedicated session. The detached child must run. + scheduler.run(); + + *child_ran.lock() + }); + + assert!( + child_ran, + "detached child must complete after the root, not be cancelled with it" + ); +} + +// The production smoke test for `spawn_dedicated` lives in the `gpui` crate +// alongside `PlatformScheduler`, which is the real production implementation +// of the `Scheduler` trait. See `crates/gpui/src/platform_scheduler.rs`. diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 9aff505f907..93e4b3f9d13 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -679,6 +679,10 @@ impl Item for ProjectSearchView { self.results_editor.for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + self.results_editor.read(cx).active_project_path(cx) + } + fn can_save(&self, _: &App) -> bool { true } diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 5677b70b7c8..7e98e99f85d 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -2177,6 +2177,9 @@ mod tests { r#" { "editor.tabSize": 37 } "#.to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "tab_size": 37 } "# @@ -2195,6 +2198,9 @@ mod tests { r#"{ "editor.tabSize": 42 }"#.to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "tab_size": 42, "preferred_line_length": 99, } @@ -2215,6 +2221,9 @@ mod tests { r#"{}"#.to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "preferred_line_length": 99, "tab_size": 42 } @@ -2241,6 +2250,9 @@ mod tests { "base_keymap": "VSCode", "tabs": { "git_status": true + }, + "minimap": { + "show": "always" } } "# @@ -2265,7 +2277,10 @@ mod tests { "sort_mode": "mixed", "sort_order": "lower" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2282,6 +2297,9 @@ mod tests { r#"{ "editor.fontFamily": "Cascadia Code, 'Consolas', Courier New" }"#.to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "buffer_font_fallbacks": [ "Consolas", "Courier New" @@ -2305,7 +2323,10 @@ mod tests { "terminal": { "bell": "system" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2324,7 +2345,10 @@ mod tests { "terminal": { "bell": "off" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2343,7 +2367,10 @@ mod tests { "terminal": { "bell": "system" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2362,7 +2389,10 @@ mod tests { "terminal": { "bell": "off" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2385,7 +2415,10 @@ mod tests { "terminal": { "bell": "off" }, - "base_keymap": "VSCode" + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } } "# .unindent(), @@ -2406,6 +2439,9 @@ mod tests { .to_owned(), r#"{ "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, "hover_popover_hiding_delay": 500, "hover_popover_sticky": false } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 236f0da3403..59dcdfbccf6 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -277,6 +277,7 @@ impl VsCodeSettings { code_lens: None, jupyter: None, lsp_document_colors: None, + lsp_document_links: self.read_bool("editor.links"), lsp_highlight_debounce: None, middle_click_paste: None, minimap: self.minimap_content(), @@ -473,13 +474,12 @@ impl VsCodeSettings { } fn minimap_content(&self) -> Option { - let minimap_enabled = self.read_bool("editor.minimap.enabled"); - let autohide = self.read_bool("editor.minimap.autohide"); + let minimap_enabled = self.read_bool("editor.minimap.enabled").unwrap_or(true); + let autohide = self.read_bool("editor.minimap.autohide").unwrap_or(false); let show = match (minimap_enabled, autohide) { - (Some(true), Some(false)) => Some(ShowMinimap::Always), - (Some(true), _) => Some(ShowMinimap::Auto), - (Some(false), _) => Some(ShowMinimap::Never), - _ => None, + (true, false) => Some(ShowMinimap::Always), + (true, true) => Some(ShowMinimap::Auto), + (false, _) => Some(ShowMinimap::Never), }; skip_default(MinimapContent { @@ -977,6 +977,7 @@ impl VsCodeSettings { buffer_font_features: None, agent_ui_font_size: None, agent_buffer_font_size: None, + git_commit_buffer_font_size: None, markdown_preview_font_family: None, markdown_preview_code_font_family: None, markdown_preview_theme: None, diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 1a1d4fc6423..917ca5e0530 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -151,7 +151,7 @@ pub struct AgentSettingsContent { pub play_sound_when_agent_done: Option, /// Whether to display agent edits in single-file editors in addition to the review multibuffer pane. /// - /// Default: true + /// Default: false pub single_file_review: Option, /// Additional parameters for language model requests. When making a request /// to a model, parameters will be taken from the last entry in this list @@ -523,46 +523,8 @@ pub enum CustomAgentServerSettings { #[serde(default, skip_serializing_if = "HashMap::is_empty")] favorite_config_option_values: HashMap>, }, - Extension { - /// Additional environment variables to pass to the agent. - /// - /// Default: {} - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - env: HashMap, - /// The default mode to use for this agent. - /// - /// Note: Not only all agents support modes. - /// - /// Default: None - default_mode: Option, - /// The default model to use for this agent. - /// - /// This should be the model ID as reported by the agent. - /// - /// Default: None - default_model: Option, - /// The favorite models for this agent. - /// - /// These are the model IDs as reported by the agent. - /// - /// Default: [] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - favorite_models: Vec, - /// Default values for session config options. - /// - /// This is a map from config option ID to value ID. - /// - /// Default: {} - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - default_config_options: HashMap, - /// Favorited values for session config options. - /// - /// This is a map from config option ID to a list of favorited value IDs. - /// - /// Default: {} - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - favorite_config_option_values: HashMap>, - }, + // Used for the ACP extension migration + #[serde(alias = "extension")] Registry { /// Additional environment variables to pass to the agent. /// diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 5cd64b044a1..499fb10e3b6 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -226,6 +226,10 @@ pub struct EditorSettingsContent { /// /// Default: [`DocumentColorsRenderMode::Inlay`] pub lsp_document_colors: Option, + /// Whether to query and display LSP `textDocument/documentLink` links in the editor. + /// + /// Default: true + pub lsp_document_links: Option, /// When to show the scrollbar in the completion menu. /// This setting can take four values: /// diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index 081406a6846..2e5ef23875e 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -1,4 +1,4 @@ -use std::{num::NonZeroU32, path::Path}; +use std::num::NonZeroU32; use collections::{HashMap, HashSet}; use schemars::JsonSchema; @@ -137,8 +137,6 @@ pub struct EditPredictionSettingsContent { pub ollama: Option, /// Settings specific to using custom OpenAI-compatible servers for edit prediction. pub open_ai_compatible_api: Option, - /// The directory where manually captured edit prediction examples are stored. - pub examples_dir: Option>, /// Controls whether Zed may collect training data when using Zed's Edit Predictions. /// Data is only ever captured for files in projects that are detected as open source. /// diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 93ef9a36293..fbeede37871 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -388,6 +388,10 @@ pub enum ContextServerSettingsContent { headers: HashMap, /// Timeout for tool calls in seconds. Defaults to global context_server_timeout if not specified. timeout: Option, + /// Pre-registered OAuth client credentials for authorization servers that + /// require out-of-band client registration. + #[serde(default, skip_serializing_if = "Option::is_none")] + oauth: Option, }, Extension { /// Whether the context server is enabled. @@ -429,6 +433,20 @@ impl ContextServerSettingsContent { } } +/// Pre-registered OAuth client credentials for MCP servers that don't support +/// Dynamic Client Registration. +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)] +pub struct OAuthClientSettings { + /// The OAuth client ID obtained from out-of-band registration with the + /// authorization server. + pub client_id: String, + /// The OAuth client secret, if this is a confidential client. For security, + /// prefer providing this interactively; we will prompt and store it in + /// the system keychain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, +} + #[with_fallible_options] #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom)] pub struct ContextServerCommand { diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs index 4d33d0d9b9d..305e1d40530 100644 --- a/crates/settings_content/src/theme.rs +++ b/crates/settings_content/src/theme.rs @@ -149,6 +149,7 @@ pub struct ThemeSettingsContent { pub agent_ui_font_size: Option, /// The font size for user messages in the agent panel. pub agent_buffer_font_size: Option, + pub git_commit_buffer_font_size: Option, /// The name of a font to use for rendering in the markdown preview. /// Falls back to the UI font if unset. pub markdown_preview_font_family: Option, diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index d6865a4906b..ee725c0a1d9 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -18,6 +18,7 @@ test-support = [] [dependencies] agent.workspace = true agent_settings.workspace = true +agent_skills.workspace = true anyhow.workspace = true audio.workspace = true component.workspace = true diff --git a/crates/settings_ui/src/components/number_field.rs b/crates/settings_ui/src/components/number_field.rs index 9ddac4263e2..7ffec007fe6 100644 --- a/crates/settings_ui/src/components/number_field.rs +++ b/crates/settings_ui/src/components/number_field.rs @@ -727,43 +727,41 @@ impl Component for NumberField { "Number Field" } - fn description() -> Option<&'static str> { - Some("A numeric input element with increment and decrement buttons.") + fn description() -> &'static str { + "A numeric input element with increment and decrement buttons." } - fn preview(window: &mut Window, cx: &mut App) -> Option { + fn preview(window: &mut Window, cx: &mut App) -> AnyElement { let default_ex = window.use_state(cx, |_, _| 100.0); let edit_ex = window.use_state(cx, |_, _| 500.0); - Some( - v_flex() - .gap_6() - .children(vec![ - single_example( - "Button-Only Number Field", - NumberField::new("number-field", *default_ex.read(cx), window, cx) - .on_change({ - let default_ex = default_ex.clone(); - move |value, _, cx| default_ex.write(cx, *value) - }) - .min(1.0) - .max(100.0) - .into_any_element(), - ), - single_example( - "Editable Number Field", - NumberField::new("editable-number-field", *edit_ex.read(cx), window, cx) - .on_change({ - let edit_ex = edit_ex.clone(); - move |value, _, cx| edit_ex.write(cx, *value) - }) - .min(100.0) - .max(500.0) - .mode(NumberFieldMode::Edit, cx) - .into_any_element(), - ), - ]) - .into_any_element(), - ) + v_flex() + .gap_6() + .children(vec![ + single_example( + "Button-Only Number Field", + NumberField::new("number-field", *default_ex.read(cx), window, cx) + .on_change({ + let default_ex = default_ex.clone(); + move |value, _, cx| default_ex.write(cx, *value) + }) + .min(1.0) + .max(100.0) + .into_any_element(), + ), + single_example( + "Editable Number Field", + NumberField::new("editable-number-field", *edit_ex.read(cx), window, cx) + .on_change({ + let edit_ex = edit_ex.clone(); + move |value, _, cx| edit_ex.write(cx, *value) + }) + .min(100.0) + .max(500.0) + .mode(NumberFieldMode::Edit, cx) + .into_any_element(), + ), + ]) + .into_any_element() } } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index eafd7ed93bb..1a38337fb49 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -13,7 +13,7 @@ use crate::{ ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata, SettingsPage, SettingsPageItem, SubPageLink, USER, active_language, all_language_names, pages::{ - open_audio_test_window, render_edit_prediction_setup_page, + open_audio_test_window, render_edit_prediction_setup_page, render_skills_setup_page, render_tool_permissions_setup_page, }, }; @@ -62,7 +62,7 @@ macro_rules! concat_sections { } pub(crate) fn settings_data(cx: &App) -> Vec { - let mut pages = vec![ + vec![ general_page(cx), appearance_page(), keymap_page(), @@ -77,56 +77,58 @@ pub(crate) fn settings_data(cx: &App) -> Vec { collaboration_page(), ai_page(cx), network_page(), - ]; - - use feature_flags::FeatureFlagAppExt as _; - if cx.is_staff() || cfg!(debug_assertions) { - pages.push(developer_page()); - } - - pages + developer_page(cx), + ] } -fn developer_page() -> SettingsPage { +fn developer_page(cx: &App) -> SettingsPage { + use feature_flags::FeatureFlagAppExt as _; + + let mut items: Vec = Vec::new(); + + // Feature flag overrides are a staff-only affordance, so only surface the section when the overrides are enabled. + if cx.feature_flag_overrides_enabled() { + items.push(SettingsPageItem::SectionHeader("Feature Flags")); + items.push(SettingsPageItem::SubPageLink(SubPageLink { + title: "Feature Flags".into(), + r#type: Default::default(), + description: None, + json_path: Some("feature_flags"), + in_json: true, + files: USER, + render: crate::pages::render_feature_flags_page, + })); + } + + items.push(SettingsPageItem::SectionHeader("Instrumentation")); + items.push(SettingsPageItem::SettingItem(SettingItem { + title: "Performance Profiler", + description: "Collect timing data for foreground and background executor tasks so they can be inspected via `zed: open performance profiler`. May lead to increased memory usage.", + field: Box::new(SettingField { + json_path: Some("instrumentation.performance_profiler.enabled"), + pick: |settings_content| { + settings_content + .instrumentation + .as_ref() + .and_then(|i| i.performance_profiler.as_ref()) + .and_then(|p| p.enabled.as_ref()) + }, + write: |settings_content, value, _| { + settings_content + .instrumentation + .get_or_insert_default() + .performance_profiler + .get_or_insert_default() + .enabled = value; + }, + }), + metadata: None, + files: USER, + })); + SettingsPage { title: "Developer", - items: Box::new([ - SettingsPageItem::SectionHeader("Feature Flags"), - SettingsPageItem::SubPageLink(SubPageLink { - title: "Feature Flags".into(), - r#type: Default::default(), - description: None, - json_path: Some("feature_flags"), - in_json: true, - files: USER, - render: crate::pages::render_feature_flags_page, - }), - SettingsPageItem::SectionHeader("Instrumentation"), - SettingsPageItem::SettingItem(SettingItem { - title: "Performance Profiler", - description: "Collect timing data for foreground and background executor tasks so they can be inspected via `zed: open performance profiler`. May lead to increased memory usage.", - field: Box::new(SettingField { - json_path: Some("instrumentation.performance_profiler.enabled"), - pick: |settings_content| { - settings_content - .instrumentation - .as_ref() - .and_then(|i| i.performance_profiler.as_ref()) - .and_then(|p| p.enabled.as_ref()) - }, - write: |settings_content, value, _| { - settings_content - .instrumentation - .get_or_insert_default() - .performance_profiler - .get_or_insert_default() - .enabled = value; - }, - }), - metadata: None, - files: USER, - }), - ]), + items: items.into_boxed_slice(), } } @@ -7484,6 +7486,15 @@ fn ai_page(cx: &App) -> SettingsPage { fn agent_configuration_section(_cx: &App) -> Box<[SettingsPageItem]> { let mut items = vec![ SettingsPageItem::SectionHeader("Agent Configuration"), + SettingsPageItem::SubPageLink(SubPageLink { + title: "Skills".into(), + r#type: Default::default(), + json_path: Some("agent.skills"), + description: Some("View and manage agent skills installed globally or in project worktrees.".into()), + in_json: false, + files: USER | PROJECT, + render: render_skills_setup_page, + }), SettingsPageItem::SubPageLink(SubPageLink { title: "Tool Permissions".into(), r#type: Default::default(), diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index 4a69069148e..f2f8dab3c4c 100644 --- a/crates/settings_ui/src/pages.rs +++ b/crates/settings_ui/src/pages.rs @@ -2,6 +2,7 @@ mod audio_input_output_setup; mod audio_test_window; mod edit_prediction_provider_setup; mod feature_flags; +mod skills_setup; mod tool_permissions_setup; pub(crate) use audio_input_output_setup::{ @@ -10,6 +11,7 @@ pub(crate) use audio_input_output_setup::{ pub(crate) use audio_test_window::open_audio_test_window; pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page; pub(crate) use feature_flags::render_feature_flags_page; +pub(crate) use skills_setup::render_skills_setup_page; pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page; pub use tool_permissions_setup::{ diff --git a/crates/settings_ui/src/pages/skills_setup.rs b/crates/settings_ui/src/pages/skills_setup.rs new file mode 100644 index 00000000000..8f9c7b16f30 --- /dev/null +++ b/crates/settings_ui/src/pages/skills_setup.rs @@ -0,0 +1,273 @@ +use agent_skills::{Skill, SkillIndex, encode_skill_share_link}; +use fs::RemoveOptions; +use gpui::{Action as _, ClipboardItem, ScrollHandle, SharedString, prelude::*}; + +use ui::{Divider, Tooltip, prelude::*}; +use util::ResultExt as _; + +use crate::{SettingsUiFile, SettingsWindow}; + +pub(crate) fn render_skills_setup_page( + settings_window: &SettingsWindow, + scroll_handle: &ScrollHandle, + _window: &mut Window, + cx: &mut Context, +) -> AnyElement { + let skill_index = cx.try_global::(); + + // Pick skills that match the current settings file tab: + // - User tab → global skills only + // - Project tab → project-local skills for that worktree only + let skills: Vec = match &settings_window.current_file { + SettingsUiFile::User => skill_index + .map(|idx| idx.global_skills.clone()) + .unwrap_or_default(), + SettingsUiFile::Project((worktree_id, _)) => { + let worktree_id = usize::from(*worktree_id); + skill_index + .and_then(|index| { + index + .project_skills + .iter() + .find(|group| group.worktree_id.0 == worktree_id) + .map(|group| group.skills.clone()) + }) + .unwrap_or_default() + } + _ => Vec::new(), + } + .into_iter() + .filter(|skill| { + !settings_window + .hidden_deleted_skill_directory_paths + .contains(&skill.directory_path) + }) + .collect(); + + v_flex() + .id("skills-page") + .size_full() + .pt_2p5() + .px_8() + .pb_16() + .map(|this| { + if skills.is_empty() { + let message = match &settings_window.current_file { + SettingsUiFile::User => "No global skills installed.", + SettingsUiFile::Project(_) => "No project skills found.", + _ => "No skills available for this context.", + }; + let original_window = settings_window.original_window; + this.items_center().justify_center().child( + v_flex() + .items_center() + .gap_2() + .child(Label::new(message).color(Color::Muted)) + .child( + Button::new("open-skill-creator", "Create a Skill") + .tab_index(0_isize) + .style(ButtonStyle::Outlined) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) + .on_click(cx.listener(move |_this, _event, window, cx| { + let Some(original_window) = original_window else { + return; + }; + original_window + .update(cx, |_workspace, original_window, cx| { + original_window.dispatch_action( + zed_actions::assistant::OpenSkillCreator + .boxed_clone(), + cx, + ); + }) + .log_err(); + window.remove_window(); + })), + ), + ) + } else { + this.track_scroll(scroll_handle) + .overflow_y_scroll() + .children(skills.iter().enumerate().flat_map(|(i, skill)| { + let mut elements: Vec = + vec![render_skill_row(skill, settings_window, cx)]; + if i + 1 < skills.len() { + elements.push(Divider::horizontal().into_any_element()); + } + elements + })) + } + }) + .into_any_element() +} + +fn render_skill_row( + skill: &Skill, + settings_window: &SettingsWindow, + cx: &mut Context, +) -> AnyElement { + let skill_file_path = skill.skill_file_path.clone(); + let directory_path = skill.directory_path.clone(); + + let share_copied = settings_window.last_copied_skill_directory_path.as_deref() + == Some(skill.directory_path.as_path()); + let (share_icon, share_icon_color) = if share_copied { + (IconName::Check, Color::Success) + } else { + (IconName::Link, Color::Muted) + }; + + h_flex() + .w_full() + .justify_between() + .py_2p5() + .gap_4() + .child( + v_flex() + .gap_0p5() + .min_w_0() + .flex_1() + .child(Label::new(skill.name.clone())) + .child( + Label::new(skill.description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_2() + .child({ + let share_skill_file_path = skill.skill_file_path.clone(); + let share_directory_path = skill.directory_path.clone(); + IconButton::new( + SharedString::from(format!("share-{}", skill.name)), + share_icon, + ) + .tab_index(0_isize) + .icon_size(IconSize::Small) + .icon_color(share_icon_color) + .tooltip(Tooltip::text("Copy Share Link")) + .on_click(cx.listener( + move |_settings_window, _event, _window, cx| { + let skill_file_path = share_skill_file_path.clone(); + let directory_path = share_directory_path.clone(); + let app_state = workspace::AppState::global(cx); + let fs = app_state.fs.clone(); + cx.spawn(async move |settings_window, cx| { + match fs.load(&skill_file_path).await { + Ok(content) => { + let link = encode_skill_share_link(&content); + settings_window + .update(cx, |settings_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + link, + )); + settings_window.last_copied_skill_directory_path = + Some(directory_path.clone()); + cx.notify(); + }) + .ok(); + } + Err(error) => { + log::error!( + "failed to read skill file {} for sharing: {error:#}", + skill_file_path.display() + ); + } + } + }) + .detach(); + }, + )) + }) + .child( + IconButton::new( + SharedString::from(format!("delete-{}", skill.name)), + IconName::Trash, + ) + .tab_index(0_isize) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Delete Skill")) + .on_click(cx.listener( + move |settings_window, _event, _window, cx| { + let directory_path = directory_path.clone(); + if !settings_window + .hidden_deleted_skill_directory_paths + .insert(directory_path.clone()) + { + return; + } + cx.notify(); + + let app_state = workspace::AppState::global(cx); + let fs = app_state.fs.clone(); + cx.spawn(async move |settings_window, cx| { + let remove_result = fs + .remove_dir( + &directory_path, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await; + if let Err(error) = remove_result { + log::error!( + "failed to delete skill directory {}: {error:#}", + directory_path.display() + ); + settings_window + .update(cx, |settings_window, cx| { + settings_window + .hidden_deleted_skill_directory_paths + .remove(&directory_path); + cx.notify(); + }) + .ok(); + } + }) + .detach(); + }, + )), + ) + .child( + Button::new(SharedString::from(format!("open-{}", skill.name)), "Open") + .tab_index(0_isize) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) + .on_click(cx.listener(move |settings_window, _event, window, cx| { + let skill_file_path = skill_file_path.clone(); + let Some(original_window) = settings_window.original_window else { + return; + }; + original_window + .update(cx, |multi_workspace, original_window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace + .open_abs_path( + skill_file_path, + Default::default(), + original_window, + cx, + ) + .detach_and_log_err(cx); + }); + }) + .log_err(); + window.remove_window(); + })), + ), + ) + .into_any_element() +} diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index 0484181fb61..3122e63d2b6 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -1423,6 +1423,9 @@ mod tests { // update_plan updates UI-visible planning state but does not use // tool permission rules. "update_plan", + // update_title updates UI-visible session metadata but + // does not use tool permission rules. + "update_title", ]; let tool_info_ids: Vec<&str> = TOOLS.iter().map(|t| t.id).collect(); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f8d938e9eec..eae0e60166e 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -2,6 +2,7 @@ mod components; mod page_data; pub mod pages; +use agent_skills::SkillIndex; use anyhow::{Context as _, Result}; use editor::{Editor, EditorEvent}; use futures::{StreamExt, channel::mpsc}; @@ -29,6 +30,7 @@ use std::{ collections::{HashMap, HashSet}, num::{NonZero, NonZeroU32}, ops::Range, + path::PathBuf, rc::Rc, sync::{Arc, LazyLock, RwLock}, time::Duration, @@ -42,7 +44,8 @@ use ui::{ use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; use workspace::{ - AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, client_side_decorations, + AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, WorkspaceSettings, + client_side_decorations, }; use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt}; @@ -662,7 +665,10 @@ pub fn open_settings_editor( let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") { Ok(val) if val == "server" => gpui::WindowDecorations::Server, Ok(val) if val == "client" => gpui::WindowDecorations::Client, - _ => gpui::WindowDecorations::Client, + _ => match WorkspaceSettings::get_global(cx).window_decorations { + settings::WindowDecorations::Server => gpui::WindowDecorations::Server, + settings::WindowDecorations::Client => gpui::WindowDecorations::Client, + }, }; cx.open_window( @@ -763,8 +769,12 @@ pub struct SettingsWindow { search_index: Option>, list_state: ListState, shown_errors: HashSet, + pub(crate) hidden_deleted_skill_directory_paths: HashSet, pub(crate) regex_validation_error: Option, last_copied_link_path: Option<&'static str>, + /// Directory path of the skill whose share link was most recently copied, + /// used to show a transient "copied" checkmark on its share button. + pub(crate) last_copied_skill_directory_path: Option, } struct SearchDocument { @@ -1538,6 +1548,28 @@ impl SettingsWindow { }) .detach(); + cx.observe_global_in::(window, |this, _window, cx| { + if let Some(skill_index) = cx.try_global::() { + this.hidden_deleted_skill_directory_paths + .retain(|directory_path| { + skill_index + .global_skills + .iter() + .chain( + skill_index + .project_skills + .iter() + .flat_map(|group| group.skills.iter()), + ) + .any(|skill| skill.directory_path.as_path() == directory_path.as_path()) + }); + } else { + this.hidden_deleted_skill_directory_paths.clear(); + } + cx.notify(); + }) + .detach(); + cx.on_window_closed(|cx, _window_id| { if let Some(existing_window) = cx .windows() @@ -1685,9 +1717,11 @@ impl SettingsWindow { .tab_stop(false), search_index: None, shown_errors: HashSet::default(), + hidden_deleted_skill_directory_paths: HashSet::default(), regex_validation_error: None, list_state, last_copied_link_path: None, + last_copied_skill_directory_path: None, }; this.fetch_files(window, cx); @@ -2293,6 +2327,10 @@ impl SettingsWindow { } fn open_navbar_entry_page(&mut self, navbar_entry: usize) { + // Navigating to another page dismisses the transient "copied share + // link" checkmark shown on a Skills page row. + self.last_copied_skill_directory_path = None; + if !self.is_nav_entry_visible(navbar_entry) { self.open_first_nav_page(); } @@ -3002,19 +3040,26 @@ impl SettingsWindow { } fn render_sub_page_breadcrumbs(&self) -> impl IntoElement { + let scope_name: SharedString = self + .display_name(&self.current_file) + .unwrap_or_else(|| self.current_file.setting_type().to_string()) + .into(); + h_flex().min_w_0().gap_1().overflow_x_hidden().children( itertools::intersperse( - std::iter::once(self.current_page().title.into()).chain( - self.sub_page_stack - .iter() - .enumerate() - .flat_map(|(index, page)| { - (index == 0) - .then(|| page.section_header.clone()) - .into_iter() - .chain(std::iter::once(page.link.title.clone())) - }), - ), + std::iter::once(scope_name) + .chain(std::iter::once(self.current_page().title.into())) + .chain( + self.sub_page_stack + .iter() + .enumerate() + .flat_map(|(index, page)| { + (index == 0) + .then(|| page.section_header.clone()) + .into_iter() + .chain(std::iter::once(page.link.title.clone())) + }), + ), "/".into(), ) .map(|item| Label::new(item).color(Color::Muted)), @@ -3350,6 +3395,65 @@ impl SettingsWindow { .into_any_element() } + let mut restricted_banner = gpui::Empty.into_any_element(); + if let SettingsUiFile::Project((worktree_id, _)) = &self.current_file { + let worktree_id = *worktree_id; + let is_restricted = all_projects(self.original_window.as_ref(), cx) + .find(|project| project.read(cx).worktree_for_id(worktree_id, cx).is_some()) + .map(|project| { + let worktree_store = project.read(cx).worktree_store(); + project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees( + &worktree_store, + cx, + ) + }) + .unwrap_or(false); + + if is_restricted { + let original_window = self.original_window; + restricted_banner = Banner::new() + .severity(Severity::Warning) + .child( + v_flex() + .my_0p5() + .gap_0p5() + .child(Label::new("Restricted Mode")) + .child( + Label::new( + "This project is in restricted mode. Some project settings may not apply.", + ) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .action_slot( + div().pr_2().pb_1().child( + Button::new("manage-trust", "Manage Trust") + .style(ButtonStyle::Tinted(ui::TintColor::Warning)) + .on_click(cx.listener(move |_this, _, window, cx| { + if let Some(original_window) = original_window { + original_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace + .workspace() + .update(cx, |workspace, cx| { + workspace + .show_worktree_trust_security_modal( + true, window, cx, + ); + }); + }) + .log_err(); + } + // Close the settings window + window.remove_window(); + })), + ), + ) + .into_any_element(); + } + } + v_flex() .id("settings-ui-page") .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| { @@ -3440,7 +3544,8 @@ impl SettingsWindow { .px_8() .gap_2() .child(page_header) - .child(warning_banner), + .child(warning_banner) + .child(restricted_banner), ) .child( div() @@ -4477,8 +4582,10 @@ pub mod test { search_index: None, list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)), shown_errors: HashSet::default(), + hidden_deleted_skill_directory_paths: HashSet::default(), regex_validation_error: None, last_copied_link_path: None, + last_copied_skill_directory_path: None, } } } @@ -4603,8 +4710,10 @@ pub mod test { search_index: None, list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)), shown_errors: HashSet::default(), + hidden_deleted_skill_directory_paths: HashSet::default(), regex_validation_error: None, last_copied_link_path: None, + last_copied_skill_directory_path: None, }; settings_window.build_filter_table(); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 877b37c59e2..0a83af342ff 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -4,6 +4,9 @@ use acp_thread::ThreadStatus; use action_log::DiffStats; use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; +use agent_ui::terminal_thread_metadata_store::{ + TerminalThreadMetadata, TerminalThreadMetadataStore, +}; use agent_ui::thread_metadata_store::{ ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths, }; @@ -13,10 +16,10 @@ use agent_ui::threads_archive_view::{ fuzzy_match_positions, }; use agent_ui::{ - AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, - AgentThreadSource, ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, - NewThread, TerminalId, ThreadId, ThreadImportModal, channels_with_threads, - import_threads_from_other_channels, + AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentThreadSource, + ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewTerminalThread, + NewThread, RenameSelectedThread, TerminalId, ThreadId, ThreadImportModal, + channels_with_threads, import_threads_from_other_channels, }; use chrono::{DateTime, Utc}; use editor::Editor; @@ -47,10 +50,10 @@ use std::rc::Rc; use std::sync::Arc; use theme::ActiveTheme; use ui::{ - AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel, - KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, Scrollbars, Tab, - ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*, - render_modifiers, + AgentThreadStatus, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, GradientFade, + HighlightedLabel, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, + Scrollbars, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, + prelude::*, render_modifiers, }; use util::ResultExt as _; use util::path_list::PathList; @@ -104,6 +107,12 @@ enum SerializedSidebarView { History, } +#[derive(Clone, Copy)] +enum NewEntryTarget { + LastCreatedKind, + Terminal, +} + #[derive(Default, Serialize, Deserialize)] struct SerializedSidebar { #[serde(default)] @@ -174,7 +183,7 @@ impl ActiveEntry { .is_some_and(|(a, b)| a == b) } (ActiveEntry::Terminal { terminal_id, .. }, ListEntry::Terminal(terminal)) => { - *terminal_id == terminal.id + *terminal_id == terminal.metadata.terminal_id } _ => false, } @@ -197,9 +206,9 @@ struct ActiveThreadInfo { enum ThreadEntryWorkspace { Open(Entity), Closed { - /// The paths this thread uses (may point to linked worktrees). + /// The paths this entry uses (may point to linked worktrees). folder_paths: PathList, - /// The project group this thread belongs to. + /// The project group this entry belongs to. project_group_key: ProjectGroupKey, }, } @@ -217,6 +226,32 @@ impl ThreadEntryWorkspace { } } +fn draft_display_label_for_thread_metadata( + metadata: &ThreadMetadata, + workspace: &ThreadEntryWorkspace, + cx: &App, +) -> Option { + let workspace = match workspace { + ThreadEntryWorkspace::Open(workspace) => Some(workspace), + ThreadEntryWorkspace::Closed { .. } => None, + }; + agent_ui::draft_prompt_store::display_label_for_draft(workspace, metadata.thread_id, cx) +} + +fn thread_metadata_would_render_sidebar_row( + metadata: &ThreadMetadata, + workspace: &ThreadEntryWorkspace, + hidden_draft_thread_ids: &HashSet, + cx: &App, +) -> bool { + if !metadata.is_draft() { + return true; + } + + !hidden_draft_thread_ids.contains(&metadata.thread_id) + && draft_display_label_for_thread_metadata(metadata, workspace, cx).is_some() +} + #[derive(Clone)] struct ThreadEntry { metadata: ThreadMetadata, @@ -235,11 +270,9 @@ struct ThreadEntry { #[derive(Clone)] struct TerminalEntry { - id: TerminalId, - title: SharedString, - workspace: Entity, + metadata: TerminalThreadMetadata, + workspace: ThreadEntryWorkspace, worktrees: Vec, - created_at: DateTime, has_notification: bool, highlight_positions: Vec, } @@ -270,10 +303,11 @@ enum ListEntry { highlight_positions: Vec, has_running_threads: bool, waiting_thread_count: usize, + has_notifications: bool, is_active: bool, has_threads: bool, }, - Thread(ThreadEntry), + Thread(Arc), Terminal(TerminalEntry), } @@ -284,8 +318,8 @@ enum ActivatableEntry { workspace: ThreadEntryWorkspace, }, Terminal { - terminal_id: TerminalId, - workspace: Entity, + metadata: TerminalThreadMetadata, + workspace: ThreadEntryWorkspace, }, } @@ -297,7 +331,7 @@ impl ActivatableEntry { workspace: thread.workspace.clone(), }), ListEntry::Terminal(terminal) => Some(Self::Terminal { - terminal_id: terminal.id, + metadata: terminal.metadata.clone(), workspace: terminal.workspace.clone(), }), ListEntry::ProjectHeader { .. } => None, @@ -309,6 +343,10 @@ impl ActivatableEntry { Self::Thread { workspace: ThreadEntryWorkspace::Open(workspace), .. + } + | Self::Terminal { + workspace: ThreadEntryWorkspace::Open(workspace), + .. } => ( PathList::new(&workspace.read(cx).root_paths(cx)), workspace.read(cx).project_group_key(cx), @@ -320,11 +358,15 @@ impl ActivatableEntry { project_group_key, }, .. + } + | Self::Terminal { + workspace: + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + }, + .. } => (folder_paths.clone(), project_group_key.clone()), - Self::Terminal { workspace, .. } => ( - PathList::new(&workspace.read(cx).root_paths(cx)), - workspace.read(cx).project_group_key(cx), - ), } } } @@ -348,7 +390,10 @@ impl ListEntry { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, - ListEntry::Terminal(terminal) => vec![terminal.workspace.clone()], + ListEntry::Terminal(terminal) => match &terminal.workspace { + ThreadEntryWorkspace::Open(workspace) => vec![workspace.clone()], + ThreadEntryWorkspace::Closed { .. } => Vec::new(), + }, ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .unwrap_or_default(), @@ -358,7 +403,7 @@ impl ListEntry { impl From for ListEntry { fn from(thread: ThreadEntry) -> Self { - ListEntry::Thread(thread) + ListEntry::Thread(Arc::new(thread)) } } @@ -377,6 +422,23 @@ struct SidebarContents { has_open_projects: bool, } +/// Identity-and-layout key for a [`ListEntry`] used to preserve measured list items +/// across rebuilds. Equal shapes must render to the same height; add any new +/// height-affecting state here. +#[derive(Debug, PartialEq, Eq)] +enum EntryShape { + ProjectHeader { + key: ProjectGroupKey, + // Toggles the "No threads yet" empty-state row when not collapsed. + has_threads: bool, + // Determines whether the "No threads yet" row is rendered (only shown when + // `!is_collapsed && !has_threads`). + is_collapsed: bool, + }, + Thread(ThreadId), + Terminal(TerminalId), +} + impl SidebarContents { fn is_thread_notified(&self, thread_id: &agent_ui::ThreadId) -> bool { self.notified_threads.contains(thread_id) @@ -413,23 +475,46 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } -fn workspace_has_agent_panel_terminals(workspace: &Entity, cx: &App) -> bool { - workspace - .read(cx) - .panel::(cx) - .is_some_and(|panel| !panel.read(cx).terminals(cx).is_empty()) +fn linked_worktree_path_lists_for_workspaces( + workspaces: &[Entity], + cx: &App, +) -> Vec { + let mut linked_worktree_paths = Vec::new(); + for workspace in workspaces { + if workspace.read(cx).visible_worktrees(cx).count() != 1 { + continue; + } + for snapshot in root_repository_snapshots(workspace, cx) { + linked_worktree_paths.extend( + snapshot.linked_worktrees().iter().map(|linked_worktree| { + PathList::new(std::slice::from_ref(&linked_worktree.path)) + }), + ); + } + } + + linked_worktree_paths.sort_by(|a, b| a.paths()[0].cmp(&b.paths()[0])); + linked_worktree_paths } -fn workspace_contains_worktree_path( +fn workspace_has_terminal_metadata_except( workspace: &Entity, - worktree_path: &Path, + except_terminal_id: Option, cx: &App, ) -> bool { - let project = workspace.read(cx).project().clone(); - project + let Some(store) = TerminalThreadMetadataStore::try_global(cx) else { + return false; + }; + let path_list = workspace_path_list(workspace, cx); + let remote_connection = workspace .read(cx) - .visible_worktrees(cx) - .any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path) + .project() + .read(cx) + .remote_connection_options(cx); + store + .read(cx) + .entries_for_path(&path_list, remote_connection.as_ref()) + .any(|terminal| except_terminal_id != Some(terminal.terminal_id)) } #[derive(Clone)] @@ -545,16 +630,6 @@ fn apply_worktree_label_mode( worktrees } -fn terminal_worktree_info( - workspace: &Entity, - branch_by_path: &HashMap, - cx: &App, -) -> Vec { - let project = workspace.read(cx).project().clone(); - let worktree_paths = project.read(cx).worktree_paths(cx); - worktree_info_from_thread_paths(&worktree_paths, branch_by_path) -} - /// Shows a [`RemoteConnectionModal`] on the given workspace and establishes /// an SSH connection. Suitable for passing to /// [`MultiWorkspace::find_or_create_workspace`] as the `connect_remote` @@ -577,6 +652,7 @@ pub struct Sidebar { width: Pixels, focus_handle: FocusHandle, filter_editor: Entity, + thread_rename_editor: Entity, list_state: ListState, contents: SidebarContents, /// The index of the list item that currently has the keyboard focus @@ -586,6 +662,10 @@ pub struct Sidebar { /// Tracks which sidebar entry is currently active (highlighted). active_entry: Option, hovered_thread_index: Option, + renaming_thread_id: Option, + /// start_renaming_thread must seed current title into the title editor + /// so this prevents that BufferEdited event from being interpreted as user input. + suppress_next_rename_edit: bool, /// Updated only in response to explicit user actions (clicking a /// thread, confirming in the thread switcher, etc.) — never from @@ -595,6 +675,10 @@ pub struct Sidebar { thread_switcher: Option>, _thread_switcher_subscriptions: Vec, pending_thread_activation: Option, + /// Persists live thread statuses across rebuilds so that Running→Completed + /// transitions can be detected even when the group is collapsed (and + /// thread entries are not present in the list). + live_thread_statuses: HashMap, view: SidebarView, restoring_tasks: HashMap>, recent_projects_popover_handle: PopoverMenuHandle, @@ -627,6 +711,7 @@ impl Sidebar { editor.set_placeholder_text("Search threads…", window, cx); editor }); + let thread_rename_editor = cx.new(|cx| Editor::single_line(window, cx)); cx.subscribe_in( &multi_workspace, @@ -663,15 +748,35 @@ impl Sidebar { }) .detach(); + cx.subscribe_in( + &thread_rename_editor, + window, + |this, title_editor, event, window, cx| { + this.handle_thread_rename_editor_event(title_editor, event, window, cx); + }, + ) + .detach(); + cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { this.update_entries(cx); }) .detach(); - let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); + cx.observe( + &TerminalThreadMetadataStore::global(cx), + |this, _store, cx| { + this.update_entries(cx); + }, + ) + .detach(); + + let deferred_multi_workspace = multi_workspace.downgrade(); cx.defer_in(window, move |this, window, cx| { - for workspace in &workspaces { - this.subscribe_to_workspace(workspace, window, cx); + if let Some(multi_workspace) = deferred_multi_workspace.upgrade() { + let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); + for workspace in &workspaces { + this.subscribe_to_workspace(workspace, window, cx); + } } this.update_entries(cx); }); @@ -681,17 +786,21 @@ impl Sidebar { width: DEFAULT_WIDTH, focus_handle, filter_editor, + thread_rename_editor, list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), contents: SidebarContents::default(), selection: None, active_entry: None, hovered_thread_index: None, + renaming_thread_id: None, + suppress_next_rename_edit: false, thread_last_accessed: HashMap::new(), terminal_last_accessed: HashMap::new(), thread_switcher: None, _thread_switcher_subscriptions: Vec::new(), pending_thread_activation: None, + live_thread_statuses: HashMap::new(), view: SidebarView::default(), restoring_tasks: HashMap::new(), recent_projects_popover_handle: PopoverMenuHandle::default(), @@ -756,7 +865,7 @@ impl Sidebar { this.update_entries(cx); } ProjectEvent::WorktreePathsChanged { old_worktree_paths } => { - this.move_thread_paths(project, old_worktree_paths, cx); + this.move_entry_paths(project, old_worktree_paths, cx); this.update_entries(cx); } _ => {} @@ -787,10 +896,10 @@ impl Sidebar { cx.subscribe_in( workspace, window, - |this, _workspace, event: &workspace::Event, window, cx| { + move |this, workspace, event: &workspace::Event, window, cx| { if let workspace::Event::PanelAdded(view) = event { if let Ok(agent_panel) = view.clone().downcast::() { - this.subscribe_to_agent_panel(&agent_panel, window, cx); + this.subscribe_to_agent_panel(workspace, &agent_panel, window, cx); this.update_entries(cx); } } @@ -801,11 +910,11 @@ impl Sidebar { self.observe_docks(workspace, cx); if let Some(agent_panel) = workspace.read(cx).panel::(cx) { - self.subscribe_to_agent_panel(&agent_panel, window, cx); + self.subscribe_to_agent_panel(workspace, &agent_panel, window, cx); } } - fn move_thread_paths( + fn move_entry_paths( &mut self, project: &Entity, old_paths: &WorktreePaths, @@ -841,18 +950,27 @@ impl Sidebar { } let remote_connection = project.read(cx).remote_connection_options(cx); + let apply_path_changes = |paths: &mut WorktreePaths| { + for (main_path, folder_path) in &added_pairs { + paths.add_path(main_path, folder_path); + } + for path in &removed_folder_paths { + paths.remove_folder_path(path); + } + }; ThreadMetadataStore::global(cx).update(cx, |store, store_cx| { store.change_worktree_paths( &old_folder_paths, remote_connection.as_ref(), - |paths| { - for (main_path, folder_path) in &added_pairs { - paths.add_path(main_path, folder_path); - } - for path in &removed_folder_paths { - paths.remove_folder_path(path); - } - }, + &apply_path_changes, + store_cx, + ); + }); + TerminalThreadMetadataStore::global(cx).update(cx, |store, store_cx| { + store.change_worktree_paths( + &old_folder_paths, + remote_connection.as_ref(), + &apply_path_changes, store_cx, ); }); @@ -860,20 +978,28 @@ impl Sidebar { fn subscribe_to_agent_panel( &mut self, + workspace: &Entity, agent_panel: &Entity, window: &mut Window, cx: &mut Context, ) { + let workspace = workspace.downgrade(); cx.subscribe_in( agent_panel, window, - |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { + move |this, agent_panel, event: &AgentPanelEvent, window, cx| match event { AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused | AgentPanelEvent::EntryChanged => { this.sync_active_entry_from_panel(agent_panel, cx); this.update_entries(cx); } + AgentPanelEvent::TerminalClosed { metadata } => { + if let Some(workspace) = workspace.upgrade() { + let workspace = ThreadEntryWorkspace::Open(workspace); + this.close_terminal(metadata, &workspace, window, cx); + } + } AgentPanelEvent::ThreadInteracted { thread_id } => { this.record_thread_interacted(thread_id, cx); this.update_entries(cx); @@ -1049,6 +1175,7 @@ impl Sidebar { fn open_workspace_and_create_entry( &mut self, project_group_key: &ProjectGroupKey, + target: NewEntryTarget, window: &mut Window, cx: &mut Context, ) { @@ -1077,8 +1204,9 @@ impl Sidebar { cx.spawn_in(window, async move |this, cx| { let workspace = task.await?; - this.update_in(cx, |this, window, cx| { - this.create_new_entry(&workspace, window, cx); + this.update_in(cx, |this, window, cx| match target { + NewEntryTarget::LastCreatedKind => this.create_new_entry(&workspace, window, cx), + NewEntryTarget::Terminal => this.create_new_terminal(&workspace, window, cx), })?; anyhow::Ok(()) }) @@ -1116,26 +1244,19 @@ impl Sidebar { let previous = mem::take(&mut self.contents); - let old_statuses: HashMap = previous - .entries - .iter() - .filter_map(|entry| match entry { - ListEntry::Thread(thread) if thread.is_live => { - let sid = thread.metadata.session_id.clone()?; - Some((sid, thread.status)) - } - _ => None, - }) - .collect(); + let old_statuses = &self.live_thread_statuses; let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; let mut notified_terminals: HashSet = HashSet::new(); + let mut new_live_statuses: HashMap = + HashMap::new(); let mut current_session_ids: HashSet = HashSet::new(); let mut current_thread_ids: HashSet = HashSet::new(); let mut current_terminal_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); let mut seen_thread_ids: HashSet = HashSet::new(); + let mut seen_terminal_ids: HashSet = HashSet::new(); let has_open_projects = workspaces .iter() @@ -1156,6 +1277,18 @@ impl Sidebar { }; let groups = mw.project_groups(cx); + let mut live_notified_terminal_ids: HashSet = HashSet::new(); + for workspace in &workspaces { + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + live_notified_terminal_ids.extend( + agent_panel + .read(cx) + .terminals(cx) + .into_iter() + .filter_map(|terminal| terminal.has_notification.then_some(terminal.id)), + ); + } + } let mut all_paths: Vec = groups .iter() @@ -1195,18 +1328,101 @@ impl Sidebar { for group in &groups { let group_key = &group.key; let group_workspaces = &group.workspaces; - let terminals: Vec = group_workspaces + + let workspace_by_path_list: HashMap> = group_workspaces .iter() - .flat_map(|workspace| { - terminal_entries_for_workspace(workspace, &branch_by_path, cx) - }) + .map(|ws| (workspace_path_list(ws, cx), ws)) .collect(); - current_terminal_ids.extend(terminals.iter().map(|terminal| terminal.id)); - notified_terminals.extend( + let resolve_workspace = |folder_paths: &PathList| -> ThreadEntryWorkspace { + workspace_by_path_list + .get(folder_paths) + .map(|ws| ThreadEntryWorkspace::Open((*ws).clone())) + .unwrap_or_else(|| ThreadEntryWorkspace::Closed { + folder_paths: folder_paths.clone(), + project_group_key: group_key.clone(), + }) + }; + let linked_worktree_path_lists = + linked_worktree_path_lists_for_workspaces(group_workspaces, cx); + let make_terminal_entry = + |metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace| { + let worktrees = + worktree_info_from_thread_paths(&metadata.worktree_paths, &branch_by_path); + let has_notification = + live_notified_terminal_ids.contains(&metadata.terminal_id); + TerminalEntry { + metadata, + workspace, + worktrees, + has_notification, + highlight_positions: Vec::new(), + } + }; + + let mut terminals = Vec::new(); + let terminal_store = TerminalThreadMetadataStore::global(cx); + let group_host = group_key.host(); + let mut push_terminal_metadata = + |metadata: TerminalThreadMetadata, workspace: ThreadEntryWorkspace| { + if !seen_terminal_ids.insert(metadata.terminal_id) { + return; + } + terminals.push(make_terminal_entry(metadata, workspace)); + }; + for row in terminal_store + .read(cx) + .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) + .cloned() + { + let workspace = resolve_workspace(row.folder_paths()); + push_terminal_metadata(row, workspace); + } + for row in terminal_store + .read(cx) + .entries_for_path(group_key.path_list(), group_host.as_ref()) + .cloned() + { + let workspace = resolve_workspace(row.folder_paths()); + push_terminal_metadata(row, workspace); + } + for ws in group_workspaces { + let ws_paths = workspace_path_list(ws, cx); + if ws_paths.paths().is_empty() { + continue; + } + for row in terminal_store + .read(cx) + .entries_for_path(&ws_paths, group_host.as_ref()) + .cloned() + { + push_terminal_metadata(row, ThreadEntryWorkspace::Open(ws.clone())); + } + } + for worktree_path_list in &linked_worktree_path_lists { + for row in terminal_store + .read(cx) + .entries_for_path(worktree_path_list, group_host.as_ref()) + .cloned() + { + push_terminal_metadata( + row, + ThreadEntryWorkspace::Closed { + folder_paths: worktree_path_list.clone(), + project_group_key: group_key.clone(), + }, + ); + } + } + current_terminal_ids.extend( terminals .iter() - .filter_map(|terminal| terminal.has_notification.then_some(terminal.id)), + .map(|terminal| terminal.metadata.terminal_id), ); + notified_terminals.extend(terminals.iter().filter_map(|terminal| { + terminal + .has_notification + .then_some(terminal.metadata.terminal_id) + })); if group_key.path_list().paths().is_empty() { continue; } @@ -1221,58 +1437,33 @@ impl Sidebar { .is_some_and(|active| group_workspaces.contains(active)); // Collect live thread infos from all workspaces in this group. - let live_infos: Vec<_> = group_workspaces + let live_infos = group_workspaces .iter() - .flat_map(|ws| all_thread_infos_for_workspace(ws, cx)) - .collect(); + .flat_map(|ws| all_thread_infos_for_workspace(ws, cx)); - let mut threads: Vec = Vec::new(); + let mut threads: Vec> = Vec::new(); let mut has_running_threads = false; let mut waiting_thread_count: usize = 0; let group_host = group_key.host(); + let hidden_draft_thread_ids: HashSet = group_workspaces + .iter() + .filter_map(|ws| { + ws.read(cx) + .panel::(cx) + .and_then(|panel| panel.read(cx).ephemeral_draft_thread_id(cx)) + }) + .collect(); if should_load_threads { let thread_store = ThreadMetadataStore::global(cx); - let ephemeral_drafts: HashSet = group_workspaces - .iter() - .filter_map(|ws| { - ws.read(cx) - .panel::(cx) - .and_then(|panel| panel.read(cx).ephemeral_draft_thread_id(cx)) - }) - .collect(); - - // Build a lookup from workspace root paths to their workspace - // entity, used to assign ThreadEntryWorkspace::Open for threads - // whose folder_paths match an open workspace. - let workspace_by_path_list: HashMap> = - group_workspaces - .iter() - .map(|ws| (workspace_path_list(ws, cx), ws)) - .collect(); - - // Resolve a ThreadEntryWorkspace for a thread row. If any open - // workspace's root paths match the thread's folder_paths, use - // Open; otherwise use Closed. - let resolve_workspace = |row: &ThreadMetadata| -> ThreadEntryWorkspace { - workspace_by_path_list - .get(row.folder_paths()) - .map(|ws| ThreadEntryWorkspace::Open((*ws).clone())) - .unwrap_or_else(|| ThreadEntryWorkspace::Closed { - folder_paths: row.folder_paths().clone(), - project_group_key: group_key.clone(), - }) - }; - - // Build a ThreadEntry from a metadata row. let make_thread_entry = - |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry { + |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> Arc { let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); let worktrees = worktree_info_from_thread_paths(&row.worktree_paths, &branch_by_path); let is_draft = row.is_draft(); - ThreadEntry { + Arc::new(ThreadEntry { metadata: row, icon, icon_from_external_svg, @@ -1285,7 +1476,7 @@ impl Sidebar { highlight_positions: Vec::new(), worktrees, diff_stats: DiffStats::default(), - } + }) }; // Main code path: one query per group via main_worktree_paths. @@ -1300,7 +1491,7 @@ impl Sidebar { if !seen_thread_ids.insert(row.thread_id) { continue; } - let workspace = resolve_workspace(&row); + let workspace = resolve_workspace(row.folder_paths()); threads.push(make_thread_entry(row, workspace)); } @@ -1316,7 +1507,7 @@ impl Sidebar { if !seen_thread_ids.insert(row.thread_id) { continue; } - let workspace = resolve_workspace(&row); + let workspace = resolve_workspace(row.folder_paths()); threads.push(make_thread_entry(row, workspace)); } @@ -1351,23 +1542,11 @@ impl Sidebar { } } - // Load any legacy threads for any single linked wortree of this project group. - let mut linked_worktree_paths = HashSet::new(); - for workspace in group_workspaces { - if workspace.read(cx).visible_worktrees(cx).count() != 1 { - continue; - } - for snapshot in root_repository_snapshots(workspace, cx) { - for linked_worktree in snapshot.linked_worktrees() { - linked_worktree_paths.insert(linked_worktree.path.clone()); - } - } - } - for path in linked_worktree_paths { - let worktree_path_list = PathList::new(std::slice::from_ref(&path)); + // Load any legacy threads for any single linked worktree of this project group. + for worktree_path_list in &linked_worktree_path_lists { for row in thread_store .read(cx) - .entries_for_path(&worktree_path_list, group_host.as_ref()) + .entries_for_path(worktree_path_list, group_host.as_ref()) .cloned() { if !seen_thread_ids.insert(row.thread_id) { @@ -1383,20 +1562,18 @@ impl Sidebar { } } - if !ephemeral_drafts.is_empty() { - threads.retain(|thread| !ephemeral_drafts.contains(&thread.metadata.thread_id)); + if !hidden_draft_thread_ids.is_empty() { + threads.retain(|thread| { + !hidden_draft_thread_ids.contains(&thread.metadata.thread_id) + }); } for thread in &mut threads { if !thread.is_draft { continue; } - let workspace = match &thread.workspace { - ThreadEntryWorkspace::Open(workspace) => Some(workspace), - ThreadEntryWorkspace::Closed { .. } => None, - }; - thread.metadata.title = agent_ui::draft_prompt_store::display_label_for_draft( - workspace, - thread.metadata.thread_id, + Arc::make_mut(thread).metadata.title = draft_display_label_for_thread_metadata( + &thread.metadata, + &thread.workspace, cx, ); } @@ -1404,24 +1581,27 @@ impl Sidebar { // Build a lookup from live_infos and compute running/waiting // counts in a single pass. - let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> = + let mut live_info_by_session: HashMap = HashMap::new(); - for info in &live_infos { - live_info_by_session.insert(&info.session_id, info); + for info in live_infos { if info.status == AgentThreadStatus::Running { has_running_threads = true; } if info.status == AgentThreadStatus::WaitingForConfirmation { waiting_thread_count += 1; } + live_info_by_session.insert(info.session_id.clone(), info); } // Merge live info into threads and update notification state // in a single pass. for thread in &mut threads { - if let Some(session_id) = &thread.metadata.session_id { - if let Some(info) = live_info_by_session.get(session_id) { - thread.apply_active_info(info); + if let Some(session_id) = thread.metadata.session_id.clone() { + if let Some(info) = live_info_by_session.get(&session_id) { + let status = info.status; + let thread_id = thread.metadata.thread_id; + Arc::make_mut(thread).apply_active_info(info); + new_live_statuses.insert(session_id, (status, thread_id)); } } @@ -1435,8 +1615,10 @@ impl Sidebar { if thread.status == AgentThreadStatus::Completed && !is_active_thread - && session_id.as_ref().and_then(|sid| old_statuses.get(sid)) - == Some(&AgentThreadStatus::Running) + && session_id + .as_ref() + .and_then(|sid| old_statuses.get(sid)) + .is_some_and(|(s, _)| *s == AgentThreadStatus::Running) { notified_threads.insert(thread.metadata.thread_id); } @@ -1459,46 +1641,87 @@ impl Sidebar { if info.status == AgentThreadStatus::WaitingForConfirmation { waiting_thread_count += 1; } + // Resolve the thread_id for this session so we can + // track its status and detect transitions even while + // the group is collapsed. + let thread_id = old_statuses + .get(&info.session_id) + .map(|(_, tid)| *tid) + .or_else(|| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_by_session(&info.session_id) + .map(|m| m.thread_id) + }); + + if let Some(thread_id) = thread_id { + let old_status = old_statuses.get(&info.session_id).map(|(s, _)| *s); + new_live_statuses.insert(info.session_id.clone(), (info.status, thread_id)); + if info.status == AgentThreadStatus::Completed + && old_status == Some(AgentThreadStatus::Running) + { + notified_threads.insert(thread_id); + } + } + } + + if is_active + && let Some(ActiveEntry::Thread { thread_id, .. }) = self.active_entry.as_ref() + { + notified_threads.remove(thread_id); } } - let has_threads = if !threads.is_empty() || !terminals.is_empty() { - true - } else { + let has_visible_rows = !threads.is_empty() || !terminals.is_empty(); + let has_stored_thread_rows = !should_load_threads && !has_visible_rows && { let store = ThreadMetadataStore::global(cx).read(cx); store .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) - .next() - .is_some() + .any(|metadata| { + let workspace = resolve_workspace(metadata.folder_paths()); + thread_metadata_would_render_sidebar_row( + metadata, + &workspace, + &hidden_draft_thread_ids, + cx, + ) + }) || store .entries_for_path(group_key.path_list(), group_host.as_ref()) - .next() - .is_some() + .any(|metadata| { + let workspace = resolve_workspace(metadata.folder_paths()); + thread_metadata_would_render_sidebar_row( + metadata, + &workspace, + &hidden_draft_thread_ids, + cx, + ) + }) }; + let has_threads = has_visible_rows || has_stored_thread_rows; if !query.is_empty() { let workspace_highlight_positions = fuzzy_match_positions(&query, &label).unwrap_or_default(); let workspace_matched = !workspace_highlight_positions.is_empty(); - let mut matched_threads: Vec = Vec::new(); + let mut matched_threads: Vec> = Vec::new(); for mut thread in threads { - let title: &str = thread - .metadata - .title - .as_ref() - .map_or(DEFAULT_THREAD_TITLE, |t| t.as_ref()); - if let Some(positions) = fuzzy_match_positions(&query, title) { - thread.highlight_positions = positions; - } let mut worktree_matched = false; - for worktree in &mut thread.worktrees { - let Some(name) = worktree.worktree_name.as_ref() else { - continue; - }; - if let Some(positions) = fuzzy_match_positions(&query, name) { - worktree.highlight_positions = positions; - worktree_matched = true; + { + let thread = Arc::make_mut(&mut thread); + let title = thread.metadata.display_title(); + if let Some(positions) = fuzzy_match_positions(&query, title.as_ref()) { + thread.highlight_positions = positions; + } + for worktree in &mut thread.worktrees { + let Some(name) = worktree.worktree_name.as_ref() else { + continue; + }; + if let Some(positions) = fuzzy_match_positions(&query, name) { + worktree.highlight_positions = positions; + worktree_matched = true; + } } } if workspace_matched @@ -1512,7 +1735,9 @@ impl Sidebar { let mut matched_terminals: Vec = Vec::new(); for mut terminal in terminals { let mut terminal_matched = false; - if let Some(positions) = fuzzy_match_positions(&query, &terminal.title) { + let terminal_title = terminal.metadata.display_title(); + if let Some(positions) = fuzzy_match_positions(&query, terminal_title.as_ref()) + { terminal.highlight_positions = positions; terminal_matched = true; } @@ -1536,6 +1761,14 @@ impl Sidebar { continue; } + // Check for notifications: threads that completed while not active. + let has_thread_notifications = matched_threads + .iter() + .any(|t| notified_threads.contains(&t.metadata.thread_id)); + let has_terminal_notifications = matched_terminals + .iter() + .any(|t| notified_terminals.contains(&t.metadata.terminal_id)); + project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { key: group_key.clone(), @@ -1543,6 +1776,7 @@ impl Sidebar { highlight_positions: workspace_highlight_positions, has_running_threads, waiting_thread_count, + has_notifications: has_thread_notifications || has_terminal_notifications, is_active, has_threads, }); @@ -1555,6 +1789,32 @@ impl Sidebar { &mut current_thread_ids, ); } else { + let has_terminal_notifications = terminals + .iter() + .any(|t| notified_terminals.contains(&t.metadata.terminal_id)); + + // When collapsed, threads aren't loaded into `threads`, so we + // query the store for thread IDs to check notifications and + // to prevent the retain below from purging them. + let has_thread_notifications = if threads.is_empty() && !notified_threads.is_empty() + { + let thread_store = ThreadMetadataStore::global(cx); + let store = thread_store.read(cx); + let group_thread_ids = store + .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) + .chain(store.entries_for_path(group_key.path_list(), group_host.as_ref())) + .map(|m| m.thread_id) + .collect::>(); + current_thread_ids.extend(group_thread_ids.iter()); + group_thread_ids + .iter() + .any(|id| notified_threads.contains(id)) + } else { + threads + .iter() + .any(|t| notified_threads.contains(&t.metadata.thread_id)) + }; + project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { key: group_key.clone(), @@ -1562,6 +1822,7 @@ impl Sidebar { highlight_positions: Vec::new(), has_running_threads, waiting_thread_count, + has_notifications: has_thread_notifications || has_terminal_notifications, is_active, has_threads, }); @@ -1587,6 +1848,8 @@ impl Sidebar { self.terminal_last_accessed .retain(|id, _| current_terminal_ids.contains(id)); + self.live_thread_statuses = new_live_statuses; + self.contents = SidebarContents { entries, notified_threads, @@ -1606,13 +1869,14 @@ impl Sidebar { } let had_notifications = self.has_notifications(cx); - let scroll_position = self.list_state.logical_scroll_top(); + let previous_shapes: Vec = + self.entry_shapes(multi_workspace.read(cx)).collect(); self.rebuild_contents(cx); self.refresh_draft_editor_observations(cx); - self.list_state.reset(self.contents.entries.len()); - self.list_state.scroll_to(scroll_position); + // Preserve measurements for unchanged entries so sticky headers do not flicker. + self.apply_list_state_diff(&previous_shapes, multi_workspace.read(cx)); if had_notifications != self.has_notifications(cx) { multi_workspace.update(cx, |_, cx| { @@ -1623,6 +1887,56 @@ impl Sidebar { cx.notify(); } + /// Splices only the changed entry range, leaving unchanged item measurements intact. + fn apply_list_state_diff( + &self, + previous_shapes: &[EntryShape], + multi_workspace: &MultiWorkspace, + ) { + let mut new_iter = self.entry_shapes(multi_workspace); + let mut prefix_len = 0; + let leading_new = loop { + match (previous_shapes.get(prefix_len), new_iter.next()) { + (Some(prev), Some(next)) if *prev == next => prefix_len += 1, + (None, None) => return, + (_, leading) => break leading, + } + }; + + let new_tail: Vec = leading_new.into_iter().chain(new_iter).collect(); + let prev_tail = &previous_shapes[prefix_len..]; + let suffix_len = prev_tail + .iter() + .rev() + .zip(new_tail.iter().rev()) + .take_while(|(prev, next)| prev == next) + .count(); + + let old_changed = prefix_len..previous_shapes.len() - suffix_len; + let new_changed_count = new_tail.len() - suffix_len; + self.list_state.splice(old_changed, new_changed_count); + } + + fn entry_shapes<'a>( + &'a self, + multi_workspace: &'a MultiWorkspace, + ) -> impl Iterator + 'a { + self.contents.entries.iter().map(move |entry| match entry { + ListEntry::ProjectHeader { + key, has_threads, .. + } => EntryShape::ProjectHeader { + key: key.clone(), + has_threads: *has_threads, + is_collapsed: multi_workspace + .group_state_by_key(key) + .map(|state| !state.expanded) + .unwrap_or(false), + }, + ListEntry::Thread(thread) => EntryShape::Thread(thread.metadata.thread_id), + ListEntry::Terminal(terminal) => EntryShape::Terminal(terminal.metadata.terminal_id), + }) + } + /// Re-establishes subscriptions to each visible draft's message editor /// so we rebuild entries (and their displayed titles) as the user types. fn refresh_draft_editor_observations(&mut self, cx: &mut Context) { @@ -1700,6 +2014,7 @@ impl Sidebar { highlight_positions, has_running_threads, waiting_thread_count, + has_notifications, is_active: is_active_group, has_threads, } => { @@ -1723,6 +2038,7 @@ impl Sidebar { highlight_positions, *has_running_threads, *waiting_thread_count, + *has_notifications, *is_active_group, is_selected, *has_threads, @@ -1781,6 +2097,7 @@ impl Sidebar { highlight_positions: &[usize], has_running_threads: bool, waiting_thread_count: usize, + has_notifications: bool, is_active: bool, is_focused: bool, has_threads: bool, @@ -1892,6 +2209,16 @@ impl Sidebar { .tooltip(Tooltip::text(tooltip_text)), ) }) + .when( + has_notifications && !has_running_threads && waiting_thread_count == 0, + |this| { + this.child( + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(Color::Accent), + ) + }, + ) }) .child( div() @@ -1938,7 +2265,12 @@ impl Sidebar { if let Some(workspace) = this.workspace_for_group(&key, cx) { this.create_new_entry(&workspace, window, cx); } else { - this.open_workspace_and_create_entry(&key, window, cx); + this.open_workspace_and_create_entry( + &key, + NewEntryTarget::LastCreatedKind, + window, + cx, + ); } }, )) @@ -2059,6 +2391,19 @@ impl Sidebar { }) .unwrap_or_default(); + // Compute reorder state at menu-open time so it reflects the + // most recent group ordering. + let (group_index, total_groups) = multi_workspace + .read_with(cx, |mw, _| { + let keys = mw.project_group_keys(); + let index = keys.iter().position(|k| k == &project_group_key); + (index, keys.len()) + }) + .unwrap_or((None, 0)); + let show_reorder_entries = total_groups >= 2; + let can_move_up = group_index.is_some_and(|i| i > 0); + let can_move_down = group_index.is_some_and(|i| i + 1 < total_groups); + let active_workspace = multi_workspace .read_with(cx, |multi_workspace, _cx| { multi_workspace.workspace().clone() @@ -2118,9 +2463,9 @@ impl Sidebar { .child(Label::new("-click").color(Color::Muted)); let label = if has_threads { - "Focus Last Workspace" + "Focus Last Project" } else { - "Focus Workspace" + "Focus Project" }; h_flex() @@ -2232,7 +2577,7 @@ impl Sidebar { ) .icon_size(IconSize::Small) .visible_on_hover(&row_group_name) - .tooltip(Tooltip::text("Close Workspace")) + .tooltip(Tooltip::text("Close Worktree")) .on_click(move |_, window, cx| { cx.stop_propagation(); window.prevent_default(); @@ -2278,19 +2623,57 @@ impl Sidebar { menu }; + let menu = menu.when(show_reorder_entries, |this| { + let move_up_multi_workspace = multi_workspace.clone(); + let move_up_key = project_group_key.clone(); + let move_up_weak_menu = weak_menu.clone(); + let move_down_multi_workspace = multi_workspace.clone(); + let move_down_key = project_group_key.clone(); + let move_down_weak_menu = weak_menu.clone(); + + this.separator() + .item( + ContextMenuEntry::new("Move Up") + .disabled(!can_move_up) + .handler(move |_window, cx| { + move_up_multi_workspace + .update(cx, |mw, cx| { + mw.move_project_group_up(&move_up_key, cx); + }) + .ok(); + move_up_weak_menu + .update(cx, |_, cx| cx.emit(DismissEvent)) + .ok(); + }), + ) + .item( + ContextMenuEntry::new("Move Down") + .disabled(!can_move_down) + .handler(move |_window, cx| { + move_down_multi_workspace + .update(cx, |mw, cx| { + mw.move_project_group_down(&move_down_key, cx); + }) + .ok(); + move_down_weak_menu + .update(cx, |_, cx| cx.emit(DismissEvent)) + .ok(); + }), + ) + }); + let project_group_key = project_group_key.clone(); let remove_multi_workspace = multi_workspace.clone(); - menu.separator() - .entry("Remove Project", None, move |window, cx| { - remove_multi_workspace - .update(cx, |multi_workspace, cx| { - multi_workspace - .remove_project_group(&project_group_key, window, cx) - .detach_and_log_err(cx); - }) - .ok(); - weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); - }) + menu.separator().entry("Remove", None, move |window, cx| { + remove_multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace + .remove_project_group(&project_group_key, window, cx) + .detach_and_log_err(cx); + }) + .ok(); + weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + }) }); let this = this.clone(); @@ -2342,6 +2725,7 @@ impl Sidebar { highlight_positions, has_running_threads, waiting_thread_count, + has_notifications, is_active, has_threads, } = self.contents.entries.get(header_idx)? @@ -2371,6 +2755,7 @@ impl Sidebar { &highlight_positions, *has_running_threads, *waiting_thread_count, + *has_notifications, *is_active, is_selected, *has_threads, @@ -2430,10 +2815,17 @@ impl Sidebar { let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx)); + let is_renaming_thread = self + .thread_rename_editor + .focus_handle(cx) + .is_focused(window); + let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) || is_archived_search_focused { "searching" + } else if is_renaming_thread { + "editing" } else { "not_searching" }; @@ -2458,6 +2850,11 @@ impl Sidebar { } fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + if self.renaming_thread_id.is_some() { + self.finish_thread_rename(window, cx); + return; + } + if self.filter_editor.read(cx).is_focused(window) { if self.reset_filter_editor_text(window, cx) { self.selection = None; @@ -2518,6 +2915,104 @@ impl Sidebar { !self.filter_editor.read(cx).text(cx).is_empty() } + fn start_renaming_thread( + &mut self, + ix: usize, + thread_id: ThreadId, + title: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + if self.renaming_thread_id.is_some() && self.renaming_thread_id != Some(thread_id) { + self.finish_thread_rename(window, cx); + } + + self.selection = Some(ix); + self.renaming_thread_id = Some(thread_id); + self.suppress_next_rename_edit = true; + self.list_state.scroll_to_reveal_item(ix); + self.thread_rename_editor.update(cx, |editor, cx| { + editor.set_text(title, window, cx); + editor.select_all(&editor::actions::SelectAll, window, cx); + editor.focus_handle(cx).focus(window, cx); + }); + cx.notify(); + } + + fn handle_thread_rename_editor_event( + &mut self, + title_editor: &Entity, + event: &editor::EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + editor::EditorEvent::BufferEdited => { + if self.suppress_next_rename_edit { + self.suppress_next_rename_edit = false; + return; + } + if !title_editor.read(cx).is_focused(window) { + return; + } + let new_title = title_editor.read(cx).text(cx); + if new_title.is_empty() { + return; + } + let Some(thread_id) = self.renaming_thread_id else { + return; + }; + self.apply_thread_rename(thread_id, SharedString::from(new_title), window, cx); + } + editor::EditorEvent::Blurred => { + self.finish_thread_rename(window, cx); + } + _ => {} + } + } + + fn apply_thread_rename( + &mut self, + thread_id: ThreadId, + title: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let mut found = false; + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); + for workspace in workspaces { + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + if let Some(view) = agent_panel + .read(cx) + .conversation_view_for_id(&thread_id, cx) + && let Some(thread_view) = view.read(cx).root_thread_view() + { + thread_view.update(cx, |thread_view, cx| { + thread_view.rename(title.clone(), window, cx); + }); + found = true; + } + } + } + } + + if !found { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.set_title_override(thread_id, title, cx); + }); + } + } + + fn finish_thread_rename(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.renaming_thread_id.take().is_none() { + return false; + } + self.focus_handle.focus(window, cx); + self.update_entries(cx); + true + } + fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { self.select_next(&SelectNext, window, cx); if self.selection.is_some() { @@ -2592,6 +3087,10 @@ impl Sidebar { } fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + if self.finish_thread_rename(window, cx) { + return; + } + let Some(ix) = self.selection else { return }; let Some(entry) = self.contents.entries.get(ix) else { return; @@ -2626,8 +3125,9 @@ impl Sidebar { } } ListEntry::Terminal(terminal) => { + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); - self.activate_terminal(&workspace, terminal.id, false, window, cx); + self.activate_terminal_entry(metadata, workspace, false, window, cx); } } } @@ -3313,24 +3813,120 @@ impl Sidebar { true } ActivatableEntry::Terminal { - terminal_id, + metadata, workspace, } => { - let Some(workspace) = self - .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace) - else { - return false; - }; - self.activate_terminal(&workspace, *terminal_id, false, window, cx); + self.activate_terminal_entry( + metadata.clone(), + workspace.clone(), + false, + window, + cx, + ); true } } } - fn activate_terminal( + fn activate_terminal_entry( + &mut self, + metadata: TerminalThreadMetadata, + workspace: ThreadEntryWorkspace, + retain: bool, + window: &mut Window, + cx: &mut Context, + ) { + match workspace { + ThreadEntryWorkspace::Open(workspace) => { + self.activate_terminal_in_workspace(&workspace, metadata, retain, window, cx); + } + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } => { + self.open_workspace_and_activate_terminal( + metadata, + folder_paths, + &project_group_key, + window, + cx, + ); + } + } + } + + fn load_agent_terminal_in_workspace( + workspace: &Entity, + metadata: &TerminalThreadMetadata, + focus: bool, + window: &mut Window, + cx: &mut App, + ) { + let restore_terminal = |agent_panel: Entity, + metadata: &TerminalThreadMetadata, + focus: bool, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut App| { + agent_panel.update(cx, |panel, cx| { + panel.restore_terminal( + metadata.clone(), + focus, + AgentThreadSource::Sidebar, + workspace, + window, + cx, + ); + }); + }; + + let mut existing_panel = None; + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + existing_panel = Some(panel); + } + }); + + if let Some(agent_panel) = existing_panel { + restore_terminal(agent_panel, metadata, focus, None, window, cx); + workspace.update(cx, |workspace, cx| { + if focus { + workspace.focus_panel::(window, cx); + } else { + workspace.reveal_panel::(window, cx); + } + }); + return; + } + + let workspace = workspace.downgrade(); + let metadata = metadata.clone(); + let mut async_window_cx = window.to_async(cx); + cx.spawn(async move |_cx| { + let panel = AgentPanel::load(workspace.clone(), async_window_cx.clone()).await?; + + workspace.update_in(&mut async_window_cx, |workspace, window, cx| { + let panel = workspace.panel::(cx).unwrap_or_else(|| { + workspace.add_panel(panel.clone(), window, cx); + panel.clone() + }); + restore_terminal(panel, &metadata, focus, Some(workspace), window, cx); + if focus { + workspace.focus_panel::(window, cx); + } else { + workspace.reveal_panel::(window, cx); + } + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn activate_terminal_in_workspace( &mut self, workspace: &Entity, - terminal_id: TerminalId, + metadata: TerminalThreadMetadata, retain: bool, window: &mut Window, cx: &mut Context, @@ -3339,6 +3935,7 @@ impl Sidebar { return; }; + let terminal_id = metadata.terminal_id; self.record_terminal_access(terminal_id); self.active_entry = Some(ActiveEntry::Terminal { terminal_id, @@ -3352,25 +3949,446 @@ impl Sidebar { } }); - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.activate_terminal(terminal_id, true, window, cx); - }); - } - workspace.focus_panel::(window, cx); - }); + Self::load_agent_terminal_in_workspace(workspace, &metadata, true, window, cx); self.update_entries(cx); } - fn close_terminal( + fn open_workspace_and_activate_terminal( &mut self, - workspace: &Entity, - terminal_id: TerminalId, + metadata: TerminalThreadMetadata, + folder_paths: PathList, + project_group_key: &ProjectGroupKey, window: &mut Window, cx: &mut Context, ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + let host = project_group_key.host(); + let provisional_key = Some(project_group_key.clone()); + let active_workspace = multi_workspace.read(cx).workspace().clone(); + let modal_workspace = active_workspace.clone(); + + let open_task = multi_workspace.update(cx, |this, cx| { + this.find_or_create_workspace( + folder_paths, + host, + provisional_key, + |options, window, cx| connect_remote(active_workspace, options, window, cx), + &[], + None, + OpenMode::Activate, + window, + cx, + ) + }); + + cx.spawn_in(window, async move |this, cx| { + let result = open_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let workspace = result?; + this.update_in(cx, |this, window, cx| { + this.activate_terminal_in_workspace(&workspace, metadata, false, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn should_load_closed_workspace_for_archive( + &self, + folder_paths: &PathList, + project_group_key: &ProjectGroupKey, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + except_terminal_id: Option, + cx: &App, + ) -> bool { + if folder_paths.is_empty() || folder_paths == project_group_key.path_list() { + return false; + } + + let archive_workspaces = self.archive_workspaces(cx); + let thread_store = ThreadMetadataStore::global(cx); + let thread_store = thread_store.read(cx); + if folder_paths.ordered_paths().any(|path| { + Self::path_is_referenced_by_unarchived_threads_for_archive( + &thread_store, + except_thread_id, + path, + remote_connection, + &archive_workspaces, + cx, + ) + }) { + return false; + } + + TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { + let terminal_store = terminal_store.read(cx); + !folder_paths.ordered_paths().any(|path| { + terminal_store.path_is_referenced_by_terminal( + except_terminal_id, + path, + remote_connection, + ) + }) + }) + } + + fn path_is_referenced_by_unarchived_threads_for_archive( + thread_store: &ThreadMetadataStore, + except_thread_id: Option, + path: &Path, + remote_connection: Option<&RemoteConnectionOptions>, + archive_workspaces: &[Entity], + cx: &App, + ) -> bool { + thread_store.path_is_referenced_by_unarchived_threads_matching( + except_thread_id, + path, + remote_connection, + |thread| Self::thread_blocks_worktree_archive(thread, archive_workspaces, cx), + ) + } + + fn archive_workspaces(&self, cx: &App) -> Vec> { + let multi_workspace = self.multi_workspace.upgrade(); + thread_worktree_archive::workspaces_for_archive(multi_workspace.as_ref(), cx) + } + + fn count_threads_blocking_worktree_archive( + &self, + path_list: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + cx: &App, + ) -> usize { + let archive_workspaces = self.archive_workspaces(cx); + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(path_list, remote_connection) + .filter(|thread| Some(thread.thread_id) != except_thread_id) + .filter(|thread| Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx)) + .count() + } + + fn roots_to_archive_for_paths( + &self, + folder_paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + except_terminal_id: Option, + cx: &App, + ) -> Vec { + let workspaces = self.archive_workspaces(cx); + folder_paths + .ordered_paths() + .filter_map(|path| { + thread_worktree_archive::build_root_plan(path, remote_connection, &workspaces, cx) + }) + .filter(|plan| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + !Self::path_is_referenced_by_unarchived_threads_for_archive( + &store, + except_thread_id, + plan.root_path.as_path(), + remote_connection, + &workspaces, + cx, + ) + }) + .filter(|root| { + TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| { + !terminal_store.read(cx).path_is_referenced_by_terminal( + except_terminal_id, + root.root_path.as_path(), + remote_connection, + ) + }) + }) + .collect() + } + + fn linked_worktree_workspace_to_remove( + &self, + folder_paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + except_thread_id: Option, + except_terminal_id: Option, + roots_to_archive: &[thread_worktree_archive::RootPlan], + cx: &App, + ) -> Option> { + if folder_paths.is_empty() { + return None; + } + + let remaining = self.count_threads_blocking_worktree_archive( + folder_paths, + remote_connection, + except_thread_id, + cx, + ); + + if remaining > 0 { + return None; + } + + let multi_workspace = self.multi_workspace.upgrade()?; + let workspace = + multi_workspace + .read(cx) + .workspace_for_paths(folder_paths, remote_connection, cx)?; + + if workspace_has_terminal_metadata_except(&workspace, except_terminal_id, cx) { + return None; + } + + if !roots_to_archive.is_empty() { + let archive_paths: HashSet<&Path> = roots_to_archive + .iter() + .map(|root| root.root_path.as_path()) + .collect(); + let project = workspace.read(cx).project().clone(); + let visible_worktree_paths = project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path()) + .collect::>(); + return (!visible_worktree_paths.is_empty() + && visible_worktree_paths + .iter() + .all(|path| archive_paths.contains(path.as_ref()))) + .then_some(workspace); + } + + let group_key = workspace.read(cx).project_group_key(cx); + (group_key.path_list() != folder_paths).then_some(workspace) + } + + fn delete_empty_drafts_for_archive_roots( + &self, + roots: &[thread_worktree_archive::RootPlan], + cx: &mut Context, + ) { + self.delete_empty_drafts_for_archive_targets( + roots + .iter() + .map(|root| (root.root_path.as_path(), root.remote_connection.as_ref())), + cx, + ); + } + + fn delete_empty_drafts_for_archive_paths( + &self, + paths: &PathList, + remote_connection: Option<&RemoteConnectionOptions>, + cx: &mut Context, + ) { + self.delete_empty_drafts_for_archive_targets( + paths + .ordered_paths() + .map(|path| (path.as_path(), remote_connection)), + cx, + ); + } + + fn delete_empty_drafts_for_archive_targets<'a>( + &self, + targets: impl IntoIterator)>, + cx: &mut Context, + ) { + let targets = targets.into_iter().collect::>(); + if targets.is_empty() { + return; + } + + let archive_workspaces = self.archive_workspaces(cx); + let draft_thread_ids = ThreadMetadataStore::global(cx) + .read(cx) + .unarchived_draft_ids_matching(|thread| { + targets.iter().any(|(path, remote_connection)| { + thread.matches_remote_connection(*remote_connection) + && thread.references_folder_path(path) + }) && !Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx) + }); + if draft_thread_ids.is_empty() { + return; + } + + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.delete_all(draft_thread_ids, cx); + }); + } + + fn thread_blocks_worktree_archive( + thread: &ThreadMetadata, + archive_workspaces: &[Entity], + cx: &App, + ) -> bool { + if !thread.is_draft() { + return true; + } + + agent_ui::draft_prompt_store::draft_has_user_content( + thread.thread_id, + archive_workspaces, + cx, + ) + } + + async fn wait_for_archive_workspace_metadata( + workspace: &Entity, + cx: &mut gpui::AsyncApp, + ) { + let scans_complete = + workspace.read_with(cx, |workspace, cx| workspace.worktree_scans_complete(cx)); + scans_complete.await; + + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + let barriers = project.update(cx, |project, cx| { + let repositories = project + .repositories(cx) + .values() + .cloned() + .collect::>(); + repositories + .into_iter() + .map(|repository| repository.update(cx, |repository, _| repository.barrier())) + .collect::>() + }); + for barrier in barriers { + let result: anyhow::Result<()> = barrier.await.map_err(|_| { + anyhow::anyhow!("git repository barrier canceled while archiving worktree") + }); + result.log_err(); + } + } + + fn open_workspace_for_archive( + &mut self, + folder_paths: PathList, + project_group_key: ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) -> Option<(Task>>, Entity)> { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return None; + }; + + let host = project_group_key.host(); + let active_workspace = multi_workspace.read(cx).workspace().clone(); + let modal_workspace = active_workspace.clone(); + + let open_task = multi_workspace.update(cx, |this, cx| { + this.find_or_create_workspace( + folder_paths, + host, + Some(project_group_key), + |options, window, cx| connect_remote(active_workspace, options, window, cx), + &[], + None, + OpenMode::Add, + window, + cx, + ) + }); + + Some((open_task, modal_workspace)) + } + + fn open_workspace_and_archive_thread( + &mut self, + session_id: acp::SessionId, + folder_paths: PathList, + project_group_key: ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let Some((open_task, modal_workspace)) = + self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) + else { + return; + }; + + cx.spawn_in(window, async move |this, cx| { + let result = open_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let workspace = result?; + Self::wait_for_archive_workspace_metadata(&workspace, cx).await; + + this.update_in(cx, |this, window, cx| { + this.update_entries(cx); + this.archive_thread(&session_id, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn open_workspace_and_close_terminal( + &mut self, + metadata: TerminalThreadMetadata, + folder_paths: PathList, + project_group_key: ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let Some((open_task, modal_workspace)) = + self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) + else { + return; + }; + + cx.spawn_in(window, async move |this, cx| { + let result = open_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let workspace = result?; + Self::wait_for_archive_workspace_metadata(&workspace, cx).await; + + this.update_in(cx, |this, window, cx| { + let workspace = ThreadEntryWorkspace::Open(workspace); + this.close_terminal(&metadata, &workspace, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn close_terminal( + &mut self, + metadata: &TerminalThreadMetadata, + workspace: &ThreadEntryWorkspace, + window: &mut Window, + cx: &mut Context, + ) { + if let ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } = workspace + && self.should_load_closed_workspace_for_archive( + folder_paths, + project_group_key, + metadata.remote_connection.as_ref(), + None, + Some(metadata.terminal_id), + cx, + ) + { + self.open_workspace_and_close_terminal( + metadata.clone(), + folder_paths.clone(), + project_group_key.clone(), + window, + cx, + ); + return; + } + + let terminal_id = metadata.terminal_id; let is_active = self .active_entry .as_ref() @@ -3379,20 +4397,196 @@ impl Sidebar { .contents .entries .iter() - .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id)) - .and_then(|position| { - self.neighboring_activatable_entry(position) + .position(|entry| { + matches!( + entry, + ListEntry::Terminal(terminal) + if terminal.metadata.terminal_id == terminal_id + ) + }) + .and_then(|position| self.neighboring_activatable_entry(position)); + + let terminal_folder_paths = metadata.folder_paths().clone(); + let roots_to_archive = self.roots_to_archive_for_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + None, + Some(terminal_id), + cx, + ); + + let workspace_to_remove = self.linked_worktree_workspace_to_remove( + &terminal_folder_paths, + metadata.remote_connection.as_ref(), + None, + Some(terminal_id), + &roots_to_archive, + cx, + ); + + let mut workspaces_to_remove: Vec> = + workspace_to_remove.into_iter().collect(); + let close_item_tasks = self.close_items_for_archived_worktrees( + &roots_to_archive, + &mut workspaces_to_remove, + window, + cx, + ); + + if !workspaces_to_remove.is_empty() { + let multi_workspace = self.multi_workspace.upgrade().unwrap(); + let terminal_workspace_removed = matches!( + workspace, + ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace) + ); + let (fallback_paths, project_group_key) = neighbor + .as_ref() + .map(|neighbor| neighbor.project_location(cx)) + .unwrap_or_else(|| { + workspaces_to_remove + .first() + .map(|workspace| { + let key = workspace.read(cx).project_group_key(cx); + (key.path_list().clone(), key) + }) + .unwrap_or_default() + }); + + let excluded = workspaces_to_remove.clone(); + let remove_task = multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.remove( + workspaces_to_remove, + move |this, window, cx| { + let active_workspace = this.workspace().clone(); + this.find_or_create_workspace( + fallback_paths, + project_group_key.host(), + Some(project_group_key), + |options, window, cx| { + connect_remote(active_workspace, options, window, cx) + }, + &excluded, + None, + OpenMode::Activate, + window, + cx, + ) + }, + window, + cx, + ) }); + let metadata = metadata.clone(); + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + if !remove_task.await? { + return anyhow::Ok(()); + } + + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + if terminal_workspace_removed { + this.delete_empty_drafts_for_archive_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + cx, + ); + } + // If the terminal's workspace has already been removed, + // don't synthesize a fallback draft in the detached + // AgentPanel. + this.close_terminal_entry( + &metadata, + &workspace, + is_active, + neighbor.as_ref(), + !terminal_workspace_removed, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else if !close_item_tasks.is_empty() { + let metadata = metadata.clone(); + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + this.close_terminal_entry( + &metadata, + &workspace, + is_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else { + self.close_terminal_entry( + metadata, + workspace, + is_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + } + } + + fn close_terminal_entry( + &mut self, + metadata: &TerminalThreadMetadata, + workspace: &ThreadEntryWorkspace, + is_active: bool, + neighbor: Option<&ActivatableEntry>, + activate_panel_draft: bool, + roots_to_archive: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let terminal_id = metadata.terminal_id; + // Closing from the sidebar must not steal focus, since the row's // workspace may not be the active workspace. - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.close_terminal(terminal_id, window, cx); - }); - } - }); + if let ThreadEntryWorkspace::Open(workspace) = workspace { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + if activate_panel_draft { + panel.close_terminal(terminal_id, window, cx); + } else { + panel.close_terminal_without_activating_draft(terminal_id, window, cx); + } + }); + } + }); + } + if let Some(store) = TerminalThreadMetadataStore::try_global(cx) { + store.update(cx, |store, cx| { + store.delete(terminal_id, cx); + }); + } + + self.start_detached_archive_worktree_task(roots_to_archive, cx); if is_active { self.active_entry = None; @@ -3407,155 +4601,22 @@ impl Sidebar { self.update_entries(cx); } - fn archive_thread( - &mut self, - session_id: &acp::SessionId, + fn close_items_for_archived_worktrees( + &self, + roots_to_archive: &[thread_worktree_archive::RootPlan], + workspaces_to_remove: &mut Vec>, window: &mut Window, cx: &mut Context, - ) { - let store = ThreadMetadataStore::global(cx); - let metadata = store.read(cx).entry_by_session(session_id).cloned(); - let active_workspace = metadata.as_ref().and_then(|metadata| { - self.active_entry.as_ref().and_then(|entry| { - if entry.is_active_thread(&metadata.thread_id) { - Some(entry.workspace().clone()) - } else { - None - } - }) - }); - let thread_id = metadata.as_ref().map(|metadata| metadata.thread_id); - let thread_folder_paths = metadata - .as_ref() - .map(|metadata| metadata.folder_paths().clone()) - .or_else(|| { - active_workspace - .as_ref() - .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx))) - }); - - // Compute which linked worktree roots should be archived from disk if - // this thread is archived. This must happen before we remove any - // workspace from the MultiWorkspace, because `build_root_plan` needs - // the currently open workspaces in order to find the affected projects - // and repository handles for each linked worktree. - let roots_to_archive = metadata - .as_ref() - .map(|metadata| { - let mut workspaces = self - .multi_workspace - .upgrade() - .map(|multi_workspace| { - multi_workspace - .read(cx) - .workspaces() - .cloned() - .collect::>() - }) - .unwrap_or_default(); - for workspace in thread_worktree_archive::all_open_workspaces(cx) { - if !workspaces.contains(&workspace) { - workspaces.push(workspace); - } - } - metadata - .folder_paths() - .ordered_paths() - .filter_map(|path| { - thread_worktree_archive::build_root_plan( - path, - metadata.remote_connection.as_ref(), - &workspaces, - cx, - ) - }) - .filter(|plan| { - thread_id.map_or(true, |tid| { - !store - .read(cx) - .path_is_referenced_by_other_unarchived_threads( - tid, - &plan.root_path, - metadata.remote_connection.as_ref(), - ) - }) - }) - .filter(|root| { - !workspaces.iter().any(|workspace| { - workspace_has_agent_panel_terminals(workspace, cx) - && workspace_contains_worktree_path( - workspace, - root.root_path.as_path(), - cx, - ) - }) - }) - .collect::>() - }) - .unwrap_or_default(); - - let current_pos = self.contents.entries.iter().position(|entry| match entry { - ListEntry::Thread(thread) => thread_id.map_or_else( - || thread.metadata.session_id.as_ref() == Some(session_id), - |tid| thread.metadata.thread_id == tid, - ), - _ => false, - }); - let neighbor = - current_pos.and_then(|position| self.neighboring_activatable_entry(position)); - - // Check if archiving this thread would leave its worktree workspace - // with no threads, requiring workspace removal. - let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| { - if folder_paths.is_empty() { - return None; - } - - let thread_remote_connection = - metadata.as_ref().and_then(|m| m.remote_connection.as_ref()); - let remaining = ThreadMetadataStore::global(cx) - .read(cx) - .entries_for_path(folder_paths, thread_remote_connection) - .filter(|t| t.session_id.as_ref() != Some(session_id)) - .count(); - - if remaining > 0 { - return None; - } - - let multi_workspace = self.multi_workspace.upgrade()?; - let workspace = multi_workspace - .read(cx) - .workspace_for_paths(folder_paths, None, cx)?; - - if workspace_has_agent_panel_terminals(&workspace, cx) { - return None; - } - - let group_key = workspace.read(cx).project_group_key(cx); - let is_linked_worktree = group_key.path_list() != folder_paths; - - is_linked_worktree.then_some(workspace) - }); - - // Also find workspaces for root plans that aren't covered by - // workspace_to_remove. For workspaces that exclusively contain - // worktrees being archived, remove the whole workspace. For - // "mixed" workspaces (containing both archived and non-archived - // worktrees), close only the editor items referencing the - // archived worktrees so their Entity handles are - // dropped without destroying the user's workspace layout. - let mut workspaces_to_remove: Vec> = - workspace_to_remove.into_iter().collect(); - let mut close_item_tasks: Vec>> = Vec::new(); + ) -> Vec>> { + if roots_to_archive.is_empty() { + return Vec::new(); + } let archive_paths: HashSet<&Path> = roots_to_archive .iter() .map(|root| root.root_path.as_path()) .collect(); - // Classify workspaces into "exclusive" (all worktrees archived) - // and "mixed" (some worktrees archived, some not). let mut mixed_workspaces: Vec<(Entity, Vec)> = Vec::new(); if let Some(multi_workspace) = self.multi_workspace.upgrade() { @@ -3569,7 +4630,7 @@ impl Sidebar { let project = workspace.read(cx).project().read(cx); let visible_worktrees: Vec<_> = project .visible_worktrees(cx) - .map(|wt| (wt.read(cx).id(), wt.read(cx).abs_path())) + .map(|worktree| (worktree.read(cx).id(), worktree.read(cx).abs_path())) .collect(); let archived_worktree_ids: Vec = visible_worktrees @@ -3590,8 +4651,7 @@ impl Sidebar { } } - // For mixed workspaces, close only items belonging to the - // worktrees being archived. + let mut close_item_tasks = Vec::new(); for (workspace, archived_worktree_ids) in &mixed_workspaces { let panes: Vec<_> = workspace.read(cx).panes().to_vec(); for pane in panes { @@ -3616,6 +4676,141 @@ impl Sidebar { } } + close_item_tasks + } + + fn archive_thread( + &mut self, + session_id: &acp::SessionId, + window: &mut Window, + cx: &mut Context, + ) { + let store = ThreadMetadataStore::global(cx); + let metadata = store.read(cx).entry_by_session(session_id).cloned(); + let metadata_thread_id = metadata.as_ref().map(|metadata| metadata.thread_id); + let thread_entry = self.contents.entries.iter().find_map(|entry| match entry { + ListEntry::Thread(thread) => metadata_thread_id + .map_or_else( + || thread.metadata.session_id.as_ref() == Some(session_id), + |thread_id| thread.metadata.thread_id == thread_id, + ) + .then(|| thread.clone()), + _ => None, + }); + let thread_id = metadata_thread_id.or_else(|| { + thread_entry + .as_ref() + .map(|thread| thread.metadata.thread_id) + }); + let active_workspace = thread_id.and_then(|thread_id| { + self.active_entry.as_ref().and_then(|entry| { + if entry.is_active_thread(&thread_id) { + Some(entry.workspace().clone()) + } else { + None + } + }) + }); + let thread_folder_paths = metadata + .as_ref() + .map(|metadata| metadata.folder_paths().clone()) + .or_else(|| { + thread_entry + .as_ref() + .map(|thread| thread.metadata.folder_paths().clone()) + }) + .or_else(|| { + active_workspace + .as_ref() + .map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx))) + }); + let thread_entry_workspace = thread_entry.map(|thread| thread.workspace.clone()); + + if let ( + Some(metadata), + Some(ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + }), + ) = (metadata.as_ref(), thread_entry_workspace) + && self.should_load_closed_workspace_for_archive( + &folder_paths, + &project_group_key, + metadata.remote_connection.as_ref(), + Some(metadata.thread_id), + None, + cx, + ) + { + self.open_workspace_and_archive_thread( + session_id.clone(), + folder_paths, + project_group_key, + window, + cx, + ); + return; + } + + // Compute which linked worktree roots should be archived from disk if + // this thread is archived. This must happen before we remove any + // workspace from the MultiWorkspace, because `build_root_plan` needs + // the currently open workspaces in order to find the affected projects + // and repository handles for each linked worktree. + let roots_to_archive = metadata + .as_ref() + .map(|metadata| { + self.roots_to_archive_for_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + thread_id, + None, + cx, + ) + }) + .unwrap_or_default(); + + let current_pos = self.contents.entries.iter().position(|entry| match entry { + ListEntry::Thread(thread) => thread_id.map_or_else( + || thread.metadata.session_id.as_ref() == Some(session_id), + |tid| thread.metadata.thread_id == tid, + ), + _ => false, + }); + let neighbor = + current_pos.and_then(|position| self.neighboring_activatable_entry(position)); + + // Check if archiving this thread would leave its worktree workspace + // with no threads, requiring workspace removal. + let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| { + let thread_remote_connection = + metadata.as_ref().and_then(|m| m.remote_connection.as_ref()); + self.linked_worktree_workspace_to_remove( + folder_paths, + thread_remote_connection, + thread_id, + None, + &roots_to_archive, + cx, + ) + }); + + // Also find workspaces for root plans that aren't covered by + // workspace_to_remove. For workspaces that exclusively contain + // worktrees being archived, remove the whole workspace. For + // "mixed" workspaces (containing both archived and non-archived + // worktrees), close only the editor items referencing the + // archived worktrees so their Entity handles are + // dropped without destroying the user's workspace layout. + let mut workspaces_to_remove: Vec> = + workspace_to_remove.into_iter().collect(); + let close_item_tasks = self.close_items_for_archived_worktrees( + &roots_to_archive, + &mut workspaces_to_remove, + window, + cx, + ); + if !workspaces_to_remove.is_empty() { let multi_workspace = self.multi_workspace.upgrade().unwrap(); let session_id = session_id.clone(); @@ -3659,6 +4854,9 @@ impl Sidebar { }); let thread_folder_paths = thread_folder_paths.clone(); + let thread_remote_connection = metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.clone()); cx.spawn_in(window, async move |this, cx| { if !remove_task.await? { return anyhow::Ok(()); @@ -3670,6 +4868,13 @@ impl Sidebar { } this.update_in(cx, |this, window, cx| { + if let Some(thread_folder_paths) = thread_folder_paths.as_ref() { + this.delete_empty_drafts_for_archive_paths( + thread_folder_paths, + thread_remote_connection.as_ref(), + cx, + ); + } let in_flight = thread_id.and_then(|tid| { this.start_archive_worktree_task(tid, roots_to_archive, cx) }); @@ -3678,6 +4883,7 @@ impl Sidebar { thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), + thread_remote_connection.as_ref(), in_flight, window, cx, @@ -3689,6 +4895,9 @@ impl Sidebar { } else if !close_item_tasks.is_empty() { let session_id = session_id.clone(); let thread_folder_paths = thread_folder_paths.clone(); + let thread_remote_connection = metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.clone()); cx.spawn_in(window, async move |this, cx| { for task in close_item_tasks { let result: anyhow::Result<()> = task.await; @@ -3704,6 +4913,7 @@ impl Sidebar { thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), + thread_remote_connection.as_ref(), in_flight, window, cx, @@ -3720,6 +4930,9 @@ impl Sidebar { thread_id, neighbor.as_ref(), thread_folder_paths.as_ref(), + metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.as_ref()), in_flight, window, cx, @@ -3749,6 +4962,7 @@ impl Sidebar { thread_id: Option, neighbor: Option<&ActivatableEntry>, thread_folder_paths: Option<&PathList>, + thread_remote_connection: Option<&RemoteConnectionOptions>, in_flight_archive: Option<(Task<()>, async_channel::Sender<()>)>, window: &mut Window, cx: &mut Context, @@ -3773,11 +4987,10 @@ impl Sidebar { // archived thread from its workspace's panel so that switching // to that workspace later doesn't show a stale thread. if let Some(folder_paths) = thread_folder_paths { - if let Some(workspace) = self - .multi_workspace - .upgrade() - .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx)) - { + if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { + mw.read(cx) + .workspace_for_paths(folder_paths, thread_remote_connection, cx) + }) { if let Some(panel) = workspace.read(cx).panel::(cx) { let panel_shows_archived = panel .read(cx) @@ -3804,10 +5017,10 @@ impl Sidebar { // No neighbor or its workspace isn't open — just clear the // panel so the group is left empty. if let Some(folder_paths) = thread_folder_paths { - let workspace = self - .multi_workspace - .upgrade() - .and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx)); + let workspace = self.multi_workspace.upgrade().and_then(|mw| { + mw.read(cx) + .workspace_for_paths(folder_paths, thread_remote_connection, cx) + }); if let Some(workspace) = workspace { if let Some(panel) = workspace.read(cx).panel::(cx) { panel.update(cx, |panel, cx| { @@ -3828,6 +5041,8 @@ impl Sidebar { return None; } + self.delete_empty_drafts_for_archive_roots(&roots, cx); + let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1); let task = cx.spawn(async move |_this, cx| { match Self::archive_worktree_roots(roots, cancel_rx, cx).await { @@ -3853,6 +5068,31 @@ impl Sidebar { Some((task, cancel_tx)) } + fn start_detached_archive_worktree_task( + &self, + roots: Vec, + cx: &mut Context, + ) { + if roots.is_empty() { + return; + } + + self.delete_empty_drafts_for_archive_roots(&roots, cx); + + let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1); + cx.spawn(async move |_this, cx| { + let outcome = Self::archive_worktree_roots(roots, cancel_rx, cx).await; + drop(cancel_tx); + match outcome { + Ok(ArchiveWorktreeOutcome::Success | ArchiveWorktreeOutcome::Cancelled) => {} + Err(error) => { + log::error!("Failed to archive worktree after closing sidebar item: {error:#}"); + } + } + }) + .detach(); + } + async fn archive_worktree_roots( roots: Vec, cancel_rx: async_channel::Receiver<()>, @@ -3935,24 +5175,39 @@ impl Sidebar { AgentThreadStatus::Completed | AgentThreadStatus::Error => {} } if thread.is_draft { - if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace { - let workspace = workspace.clone(); - let draft_id = thread.metadata.thread_id; - self.remove_draft(draft_id, &workspace, window, cx); - } + let workspace = thread.workspace.clone(); + let draft_id = thread.metadata.thread_id; + self.remove_draft(draft_id, &workspace, window, cx); } else if let Some(session_id) = thread.metadata.session_id.clone() { self.archive_thread(&session_id, window, cx); } } Some(ListEntry::Terminal(terminal)) => { + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); - let terminal_id = terminal.id; - self.close_terminal(&workspace, terminal_id, window, cx); + self.close_terminal(&metadata, &workspace, window, cx); } _ => {} } } + fn rename_selected_thread( + &mut self, + _: &RenameSelectedThread, + window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { + return; + }; + let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { + return; + }; + let thread_id = thread.metadata.thread_id; + let title = thread.metadata.display_title(); + self.start_renaming_thread(ix, thread_id, title, window, cx); + } + fn record_thread_access(&mut self, id: &ThreadId) { self.thread_last_accessed.insert(*id, Utc::now()); } @@ -3975,14 +5230,14 @@ impl Sidebar { fn push_entries_by_display_time( entries: &mut Vec, terminals: Vec, - threads: Vec, + threads: Vec>, current_session_ids: &mut HashSet, current_thread_ids: &mut HashSet, ) { fn display_time(entry: &ListEntry) -> DateTime { match entry { ListEntry::Thread(thread) => Sidebar::thread_display_time(&thread.metadata), - ListEntry::Terminal(terminal) => terminal.created_at, + ListEntry::Terminal(terminal) => terminal.metadata.created_at, ListEntry::ProjectHeader { .. } => unreachable!(), } } @@ -4019,9 +5274,9 @@ impl Sidebar { .unwrap_or(entry.metadata.updated_at), ThreadSwitcherEntry::Terminal(entry) => self .terminal_last_accessed - .get(&entry.terminal_id) + .get(&entry.metadata.terminal_id) .copied() - .unwrap_or(entry.created_at), + .unwrap_or(entry.metadata.created_at), }; // .reverse() = most recent first @@ -4086,10 +5341,9 @@ impl Sidebar { } ListEntry::Terminal(terminal) => { let timestamp: SharedString = - format_history_entry_timestamp(terminal.created_at).into(); + format_history_entry_timestamp(terminal.metadata.created_at).into(); Some(ThreadSwitcherEntry::Terminal(ThreadSwitcherTerminalEntry { - terminal_id: terminal.id, - title: terminal.title.clone(), + metadata: terminal.metadata.clone(), workspace: terminal.workspace.clone(), project_name: current_header_label.clone(), worktrees: terminal @@ -4101,8 +5355,9 @@ impl Sidebar { wt }) .collect(), - created_at: terminal.created_at, - notified: self.contents.is_terminal_notified(terminal.id), + notified: self + .contents + .is_terminal_notified(terminal.metadata.terminal_id), timestamp, })) } @@ -4158,27 +5413,22 @@ impl Sidebar { Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx); } ThreadSwitcherSelection::Terminal { - terminal_id, + metadata, workspace, } => { - if let Some(multi_workspace) = self.multi_workspace.upgrade() { - multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), None, window, cx); - }); - } - self.active_entry = Some(ActiveEntry::Terminal { - terminal_id: *terminal_id, - workspace: workspace.clone(), - }); - self.update_entries(cx); - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.activate_terminal(*terminal_id, false, window, cx); + if let ThreadEntryWorkspace::Open(workspace) = workspace { + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); }); } - workspace.reveal_panel::(window, cx); - }); + self.active_entry = Some(ActiveEntry::Terminal { + terminal_id: metadata.terminal_id, + workspace: workspace.clone(), + }); + self.update_entries(cx); + Self::load_agent_terminal_in_workspace(workspace, metadata, false, window, cx); + } } } } @@ -4211,11 +5461,11 @@ impl Sidebar { Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx); } ThreadSwitcherSelection::Terminal { - terminal_id, + metadata, workspace, } => { self.dismiss_thread_switcher(cx); - self.activate_terminal(workspace, *terminal_id, true, window, cx); + self.activate_terminal_entry(metadata.clone(), workspace.clone(), true, window, cx); } } } @@ -4388,10 +5638,12 @@ impl Sidebar { thread.status, AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation ); + let is_renaming = self.renaming_thread_id == Some(thread.metadata.thread_id); let thread_id_for_actions = thread.metadata.thread_id; let session_id_for_delete = thread.metadata.session_id.clone(); let focus_handle = self.focus_handle.clone(); + let title_editor = self.thread_rename_editor.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); @@ -4415,7 +5667,7 @@ impl Sidebar { (thread.icon, thread.icon_from_external_svg.clone()) }; - ThreadItem::new(id, title) + ThreadItem::new(id, title.clone()) .base_bg(sidebar_bg) .icon(icon) .when(is_draft, |this| { @@ -4448,22 +5700,84 @@ impl Sidebar { } cx.notify(); })) - .when(is_hovered && is_running, |this| { - this.action_slot( + .when(is_renaming, |this| { + this.is_truncated(false).title_slot( + div() + .h_full() + .min_w_0() + .flex_1() + .capture_action(cx.listener( + |this, _: &editor::actions::Newline, window, cx| { + this.finish_thread_rename(window, cx); + }, + )) + .on_action(cx.listener(|this, _: &Confirm, window, cx| { + this.finish_thread_rename(window, cx); + })) + .on_action( + cx.listener(|this, _: &editor::actions::Cancel, window, cx| { + this.finish_thread_rename(window, cx); + }), + ) + .child(title_editor), + ) + }) + .when(is_hovered && !is_renaming, |this| { + let rename_button = IconButton::new(("rename-thread", ix), IconName::Pencil) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Rename Thread", + &RenameSelectedThread, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let title = title.clone(); + cx.listener(move |this, _, window, cx| { + this.start_renaming_thread( + ix, + thread_id_for_actions, + title.clone(), + window, + cx, + ); + }) + }); + + let contextual_action = if is_running { IconButton::new("stop-thread", IconName::Stop) .icon_size(IconSize::Small) .icon_color(Color::Error) .style(ButtonStyle::Tinted(TintColor::Error)) .tooltip(Tooltip::text("Stop Generation")) + .on_click(cx.listener(move |this, _, _window, cx| { + this.stop_thread(&thread_id_for_actions, cx); + })) + .into_any_element() + } else if is_draft { + IconButton::new("discard_thread", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Discard Draft")) .on_click({ - cx.listener(move |this, _, _window, cx| { - this.stop_thread(&thread_id_for_actions, cx); + let thread_workspace = thread_workspace.clone(); + cx.listener(move |this, _, window, cx| { + this.remove_draft( + thread_id_for_actions, + &thread_workspace, + window, + cx, + ); }) - }), - ) - }) - .when(is_hovered && !is_running && !is_draft, |this| { - this.action_slot( + }) + .into_any_element() + } else { IconButton::new("archive-thread", IconName::Archive) .icon_size(IconSize::Small) .icon_color(Color::Muted) @@ -4485,23 +5799,15 @@ impl Sidebar { this.archive_thread(session_id, window, cx); } }) - }), - ) - }) - .when(is_hovered && !is_running && is_draft, |this| { + }) + .into_any_element() + }; + this.action_slot( - IconButton::new("discard_thread", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Discard Draft")) - .on_click({ - let thread_workspace = thread_workspace.clone(); - cx.listener(move |this, _, window, cx| { - if let ThreadEntryWorkspace::Open(workspace) = &thread_workspace { - this.remove_draft(thread_id_for_actions, workspace, window, cx); - } - }) - }), + h_flex() + .gap_0p5() + .child(rename_button) + .child(contextual_action), ) }) .on_click({ @@ -4537,24 +5843,26 @@ impl Sidebar { is_focused: bool, cx: &mut Context, ) -> AnyElement { - let id = ElementId::from(format!("terminal-{}", terminal.id)); - let timestamp = format_history_entry_timestamp(terminal.created_at); + let id = ElementId::from(format!("terminal-{}", terminal.metadata.terminal_id)); + let timestamp = format_history_entry_timestamp(terminal.metadata.created_at); let is_hovered = self.hovered_thread_index == Some(ix); let color = cx.theme().colors(); let sidebar_bg = color .title_bar_background .blend(color.panel_background.opacity(0.25)); - let terminal_id = terminal.id; + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); let focus_handle = self.focus_handle.clone(); let worktrees = apply_worktree_label_mode( terminal.worktrees.clone(), cx.flag_value::(), ); + let is_remote = terminal.workspace.is_remote(cx); - ThreadItem::new(id, terminal.title.clone()) + ThreadItem::new(id, terminal.metadata.display_title()) .base_bg(sidebar_bg) .icon(IconName::Terminal) + .is_remote(is_remote) .worktrees(worktrees) .timestamp(timestamp) .notified(terminal.has_notification) @@ -4587,14 +5895,21 @@ impl Sidebar { } }) .on_click(cx.listener(move |this, _, window, cx| { - this.close_terminal(&workspace, terminal_id, window, cx); + this.close_terminal(&metadata, &workspace, window, cx); })), ) }) .on_click(cx.listener({ + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); move |this, _, window, cx| { - this.activate_terminal(&workspace, terminal_id, false, window, cx); + this.activate_terminal_entry( + metadata.clone(), + workspace.clone(), + false, + window, + cx, + ); } })) .into_any_element() @@ -4678,37 +5993,334 @@ impl Sidebar { if let Some(workspace) = self.workspace_for_group(&key, cx) { self.create_new_entry(&workspace, window, cx); } else { - self.open_workspace_and_create_entry(&key, window, cx); + self.open_workspace_and_create_entry( + &key, + NewEntryTarget::LastCreatedKind, + window, + cx, + ); } } else if let Some(workspace) = self.active_workspace(cx) { self.create_new_entry(&workspace, window, cx); } } - /// Deletes a parked draft thread (its metadata row, any kvp-stored - /// draft prompt) and promotes a sibling in the same group, if any, to - /// the active entry. - fn remove_draft( + fn new_terminal_thread( &mut self, - draft_id: ThreadId, - workspace: &Entity, + _: &NewTerminalThread, window: &mut Window, cx: &mut Context, ) { - workspace.update(cx, |ws, cx| { - if let Some(panel) = ws.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.remove_thread(draft_id, window, cx); - }); + cx.stop_propagation(); + + if let Some(key) = self.selected_group_key() { + self.set_group_expanded(&key, true, cx); + self.selection = None; + if let Some(workspace) = self.workspace_for_group(&key, cx) { + self.create_new_terminal(&workspace, window, cx); + } else { + self.open_workspace_and_create_entry(&key, NewEntryTarget::Terminal, window, cx); } - }); + } else if let Some(workspace) = self.active_workspace(cx) { + self.create_new_terminal(&workspace, window, cx); + } + } + + /// Closed linked-worktree drafts need an open workspace so archive root + /// planning can inspect repositories before deleting the worktree. + fn open_workspace_and_remove_draft( + &mut self, + draft_id: ThreadId, + folder_paths: PathList, + project_group_key: ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let Some((open_task, modal_workspace)) = + self.open_workspace_for_archive(folder_paths, project_group_key, window, cx) + else { + return; + }; + + cx.spawn_in(window, async move |this, cx| { + let result = open_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let workspace = result?; + Self::wait_for_archive_workspace_metadata(&workspace, cx).await; + + this.update_in(cx, |this, window, cx| { + let workspace = ThreadEntryWorkspace::Open(workspace); + this.remove_draft(draft_id, &workspace, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn remove_draft( + &mut self, + draft_id: ThreadId, + workspace: &ThreadEntryWorkspace, + window: &mut Window, + cx: &mut Context, + ) { + let metadata = ThreadMetadataStore::global(cx) + .read(cx) + .entry(draft_id) + .cloned(); + + if let ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } = workspace + && self.should_load_closed_workspace_for_archive( + folder_paths, + project_group_key, + metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.as_ref()), + Some(draft_id), + None, + cx, + ) + { + self.open_workspace_and_remove_draft( + draft_id, + folder_paths.clone(), + project_group_key.clone(), + window, + cx, + ); + return; + } + + let draft_folder_paths = metadata + .as_ref() + .map(|metadata| metadata.folder_paths().clone()) + .or_else(|| match workspace { + ThreadEntryWorkspace::Open(workspace) => { + Some(PathList::new(&workspace.read(cx).root_paths(cx))) + } + ThreadEntryWorkspace::Closed { folder_paths, .. } => Some(folder_paths.clone()), + }); + let draft_remote_connection = metadata + .as_ref() + .and_then(|metadata| metadata.remote_connection.clone()); + let roots_to_archive = metadata + .as_ref() + .map(|metadata| { + self.roots_to_archive_for_paths( + metadata.folder_paths(), + metadata.remote_connection.as_ref(), + Some(draft_id), + None, + cx, + ) + }) + .unwrap_or_default(); let was_active = self .active_entry .as_ref() - .is_some_and(|e| e.is_active_thread(&draft_id)); + .is_some_and(|entry| entry.is_active_thread(&draft_id)); + let neighbor = self + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id + ) + }) + .and_then(|position| self.neighboring_activatable_entry(position)); + + let workspace_to_remove = draft_folder_paths.as_ref().and_then(|folder_paths| { + self.linked_worktree_workspace_to_remove( + folder_paths, + draft_remote_connection.as_ref(), + Some(draft_id), + None, + &roots_to_archive, + cx, + ) + }); + let mut workspaces_to_remove: Vec> = + workspace_to_remove.into_iter().collect(); + let close_item_tasks = self.close_items_for_archived_worktrees( + &roots_to_archive, + &mut workspaces_to_remove, + window, + cx, + ); + + if !workspaces_to_remove.is_empty() { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + let draft_workspace_removed = matches!( + workspace, + ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace) + ); + let (fallback_paths, project_group_key) = neighbor + .as_ref() + .map(|neighbor| neighbor.project_location(cx)) + .unwrap_or_else(|| { + workspaces_to_remove + .first() + .map(|workspace| { + let key = workspace.read(cx).project_group_key(cx); + (key.path_list().clone(), key) + }) + .unwrap_or_default() + }); + + let excluded = workspaces_to_remove.clone(); + let remove_task = multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.remove( + workspaces_to_remove, + move |this, window, cx| { + let active_workspace = this.workspace().clone(); + this.find_or_create_workspace( + fallback_paths, + project_group_key.host(), + Some(project_group_key), + |options, window, cx| { + connect_remote(active_workspace, options, window, cx) + }, + &excluded, + None, + OpenMode::Activate, + window, + cx, + ) + }, + window, + cx, + ) + }); + + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + if !remove_task.await? { + return anyhow::Ok(()); + } + + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + if draft_workspace_removed { + if let Some(draft_folder_paths) = draft_folder_paths.as_ref() { + this.delete_empty_drafts_for_archive_paths( + draft_folder_paths, + draft_remote_connection.as_ref(), + cx, + ); + } + } + this.remove_draft_entry( + draft_id, + &workspace, + was_active, + neighbor.as_ref(), + !draft_workspace_removed, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else if !close_item_tasks.is_empty() { + let workspace = workspace.clone(); + cx.spawn_in(window, async move |this, cx| { + for task in close_item_tasks { + let result: anyhow::Result<()> = task.await; + result.log_err(); + } + + this.update_in(cx, |this, window, cx| { + this.remove_draft_entry( + draft_id, + &workspace, + was_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else { + self.remove_draft_entry( + draft_id, + workspace, + was_active, + neighbor.as_ref(), + true, + roots_to_archive, + window, + cx, + ); + } + } + + fn remove_draft_entry( + &mut self, + draft_id: ThreadId, + workspace: &ThreadEntryWorkspace, + was_active: bool, + neighbor: Option<&ActivatableEntry>, + activate_panel_draft: bool, + roots_to_archive: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let removed_from_panel = if let ThreadEntryWorkspace::Open(workspace) = workspace { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + if activate_panel_draft { + panel.remove_thread(draft_id, window, cx); + } else { + panel.remove_thread_without_activating_draft(draft_id, window, cx); + } + }); + true + } else { + false + } + }) + } else { + false + }; + + if !removed_from_panel { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.delete(draft_id, cx); + }); + } + + self.start_detached_archive_worktree_task(roots_to_archive, cx); + if was_active { self.active_entry = None; + if !activate_panel_draft { + if neighbor + .as_ref() + .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) + { + return; + } + self.sync_active_entry_from_active_workspace(cx); + } } self.update_entries(cx); @@ -4996,8 +6608,9 @@ impl Sidebar { } } ListEntry::Terminal(terminal) => { + let metadata = terminal.metadata.clone(); let workspace = terminal.workspace.clone(); - self.activate_terminal(&workspace, terminal.id, true, window, cx); + self.activate_terminal_entry(metadata, workspace, true, window, cx); } ListEntry::ProjectHeader { .. } => {} } @@ -5621,7 +7234,9 @@ impl Render for Sidebar { .on_action(cx.listener(Self::unfold_all)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::archive_selected_thread)) + .on_action(cx.listener(Self::rename_selected_thread)) .on_action(cx.listener(Self::new_thread_in_group)) + .on_action(cx.listener(Self::new_terminal_thread)) .on_action(cx.listener(Self::toggle_archive)) .on_action(cx.listener(Self::focus_sidebar_filter)) .on_action(cx.listener(Self::on_toggle_thread_switcher)) @@ -5693,29 +7308,6 @@ impl Render for Sidebar { } } -fn terminal_entries_for_workspace( - workspace: &Entity, - branch_by_path: &HashMap, - cx: &App, -) -> impl Iterator { - let Some(agent_panel) = workspace.read(cx).panel::(cx) else { - return None.into_iter().flatten(); - }; - let terminals = agent_panel.read(cx).terminals(cx).into_iter().map( - move |terminal: AgentPanelTerminalInfo| TerminalEntry { - id: terminal.id, - title: terminal.title, - workspace: workspace.clone(), - worktrees: terminal_worktree_info(workspace, branch_by_path, cx), - created_at: terminal.created_at, - has_notification: terminal.has_notification, - highlight_positions: Vec::new(), - }, - ); - - Some(terminals).into_iter().flatten() -} - fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 4817ab5ebc6..c1a06095333 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -3,8 +3,12 @@ use acp_thread::{AcpThread, PermissionOptions, StubAgentConnection}; use agent::ThreadStore; use agent_ui::{ ThreadId, + terminal_thread_metadata_store::{ + TerminalThreadMetadata, TerminalThreadMetadataStore, TestTerminalMetadataDbName, + }, test_support::{ - active_session_id, active_thread_id, open_thread_with_connection, send_message, + active_session_id, active_thread_id, open_thread_with_connection, + open_thread_with_custom_connection, send_message, }, thread_metadata_store::{ThreadMetadata, WorktreePaths}, }; @@ -28,6 +32,7 @@ fn init_test(cx: &mut TestAppContext) { editor::init(cx); ThreadStore::init_global(cx); ThreadMetadataStore::init_global(cx); + TerminalThreadMetadataStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); prompt_store::init(cx); }); @@ -90,6 +95,34 @@ fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id))) } +#[track_caller] +fn assert_project_header_has_threads( + sidebar: &Entity, + project_name: &str, + expected_has_threads: bool, + cx: &mut gpui::VisualTestContext, +) { + sidebar.read_with(cx, |sidebar, _cx| { + let has_threads = sidebar.contents.entries.iter().find_map(|entry| { + if let ListEntry::ProjectHeader { + label, has_threads, .. + } = entry + && label.as_ref() == project_name + { + Some(*has_threads) + } else { + None + } + }); + + assert_eq!( + has_threads, + Some(expected_has_threads), + "expected project header `{project_name}` to have has_threads={expected_has_threads}, got {has_threads:?}" + ); + }); +} + #[track_caller] fn assert_remote_project_integration_sidebar_state( sidebar: &mut Sidebar, @@ -148,7 +181,7 @@ fn assert_remote_project_integration_sidebar_state( ListEntry::Terminal(terminal) => { panic!( "unexpected sidebar terminal while simulating remote project integration flicker: title=`{}`", - terminal.title + terminal.metadata.title ); } } @@ -421,6 +454,34 @@ fn save_thread_metadata_with_main_paths( cx.run_until_parked(); } +fn save_draft_metadata_with_main_paths( + title: Option, + folder_paths: PathList, + main_worktree_paths: PathList, + updated_at: DateTime, + cx: &mut TestAppContext, +) -> ThreadId { + let thread_id = ThreadId::new(); + let metadata = ThreadMetadata { + thread_id, + session_id: None, + agent_id: agent::ZED_AGENT_ID.clone(), + title, + title_override: None, + updated_at, + created_at: None, + interacted_at: None, + worktree_paths: WorktreePaths::from_path_lists(main_worktree_paths, folder_paths).unwrap(), + archived: false, + remote_connection: None, + }; + cx.update(|cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + cx.run_until_parked(); + thread_id +} + fn focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { sidebar.update_in(cx, |_, window, cx| { cx.focus_self(window); @@ -534,7 +595,7 @@ fn visible_entries_as_strings( } } ListEntry::Terminal(terminal) => { - let title = &terminal.title; + let title = terminal.metadata.display_title(); let worktree = format_linked_worktree_chips(&terminal.worktrees); format!(" {title}{worktree}{selected}") } @@ -544,6 +605,153 @@ fn visible_entries_as_strings( }) } +#[gpui::test] +async fn test_thread_metadata_update_preserves_sticky_header_measurements(cx: &mut TestAppContext) { + let (fs, project_a) = init_multi_project_test(&["/project-a", "/project-b"], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + add_test_project("/project-b", &fs, &multi_workspace, cx).await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("project-a-thread")), + Some("Project A Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + None, + None, + &project_a, + cx, + ); + save_thread_metadata_with_main_paths( + "project-b-thread", + "Project B Thread", + PathList::new(&[PathBuf::from("/project-b")]), + PathList::new(&[PathBuf::from("/project-b")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + cx, + ); + + cx.draw( + gpui::point(px(0.), px(0.)), + gpui::size(px(400.), px(240.)), + |_, _| sidebar.clone().into_any_element(), + ); + cx.run_until_parked(); + + let next_header_ix = sidebar.read_with(cx, |sidebar, _| { + assert!( + sidebar.contents.project_header_indices.len() == 2, + "test setup should render exctly two project headers" + ); + sidebar.contents.project_header_indices[1] + }); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.list_state.scroll_to(gpui::ListOffset { + item_ix: next_header_ix - 1, + offset_in_item: px(24.), + }); + cx.notify(); + }); + cx.draw( + gpui::point(px(0.), px(0.)), + gpui::size(px(400.), px(240.)), + |_, _| sidebar.clone().into_any_element(), + ); + cx.run_until_parked(); + + let bounds_before = sidebar.read_with(cx, |sidebar, _| { + sidebar + .list_state + .bounds_for_item(next_header_ix) + .expect("next project header should be measured before metadata update") + }); + + save_thread_metadata( + acp::SessionId::new(Arc::from("project-a-thread")), + Some("Renamed Project A Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 1, 0).unwrap(), + None, + None, + &project_a, + cx, + ); + + let bounds_after = sidebar.read_with(cx, |sidebar, _| { + sidebar + .list_state + .bounds_for_item(next_header_ix) + .expect("same-shape metadata update should preserve next header measurements") + }); + assert_eq!(bounds_before, bounds_after); +} + +#[gpui::test] +async fn test_thread_status_update_does_not_reset_list_measurements(cx: &mut TestAppContext) { + // When a thread's status changes (e.g. Running -> Completed after sending a message), the + // shape sequence is unchanged, so `update_entries` should not reset the underlying + // `ListState`. Resetting throws away measured item bounds for one frame, which makes the + // sticky project header flicker between its pushed-off and fully-on-screen positions. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + save_n_test_threads(2, &project, cx).await; + cx.run_until_parked(); + + let before = sidebar.read_with(cx, |sidebar, app| { + sidebar + .entry_shapes(multi_workspace.read(app)) + .collect::>() + }); + sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + let after = sidebar.read_with(cx, |sidebar, app| { + sidebar + .entry_shapes(multi_workspace.read(app)) + .collect::>() + }); + + assert_eq!( + before, after, + "a no-op rebuild should produce an identical shape sequence" + ); +} + +#[gpui::test] +async fn test_collapse_changes_entry_shape(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + save_n_test_threads(2, &project, cx).await; + cx.run_until_parked(); + + let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx)); + + let before = sidebar.read_with(cx, |sidebar, app| { + sidebar + .entry_shapes(multi_workspace.read(app)) + .collect::>() + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.toggle_collapse(&project_group_key, window, cx); + }); + cx.run_until_parked(); + let after = sidebar.read_with(cx, |sidebar, app| { + sidebar + .entry_shapes(multi_workspace.read(app)) + .collect::>() + }); + + assert_ne!( + before, after, + "collapsing the project group should change the shape sequence so the list resets" + ); +} + #[gpui::test] async fn test_serialization_round_trip(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; @@ -877,10 +1085,11 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { highlight_positions: Vec::new(), has_running_threads: false, waiting_thread_count: 0, + has_notifications: false, is_active: true, has_threads: true, }, - ListEntry::Thread(ThreadEntry { + ListEntry::Thread(Arc::new(ThreadEntry { metadata: ThreadMetadata { thread_id: ThreadId::new(), session_id: Some(acp::SessionId::new(Arc::from("t-1"))), @@ -905,9 +1114,9 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), - }), + })), // Active thread with Running status - ListEntry::Thread(ThreadEntry { + ListEntry::Thread(Arc::new(ThreadEntry { metadata: ThreadMetadata { thread_id: ThreadId::new(), session_id: Some(acp::SessionId::new(Arc::from("t-2"))), @@ -932,9 +1141,9 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), - }), + })), // Active thread with Error status - ListEntry::Thread(ThreadEntry { + ListEntry::Thread(Arc::new(ThreadEntry { metadata: ThreadMetadata { thread_id: ThreadId::new(), session_id: Some(acp::SessionId::new(Arc::from("t-3"))), @@ -959,10 +1168,10 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), - }), + })), // Thread with WaitingForConfirmation status, not active // remote_connection: None, - ListEntry::Thread(ThreadEntry { + ListEntry::Thread(Arc::new(ThreadEntry { metadata: ThreadMetadata { thread_id: ThreadId::new(), session_id: Some(acp::SessionId::new(Arc::from("t-4"))), @@ -987,10 +1196,10 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), - }), + })), // Background thread that completed (should show notification) // remote_connection: None, - ListEntry::Thread(ThreadEntry { + ListEntry::Thread(Arc::new(ThreadEntry { metadata: ThreadMetadata { thread_id: notified_thread_id, session_id: Some(acp::SessionId::new(Arc::from("t-5"))), @@ -1015,7 +1224,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), - }), + })), // Collapsed project header ListEntry::ProjectHeader { key: ProjectGroupKey::new(None, collapsed_path.clone()), @@ -1023,6 +1232,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { highlight_positions: Vec::new(), has_running_threads: false, waiting_thread_count: 0, + has_notifications: false, is_active: false, has_threads: false, }, @@ -1502,11 +1712,30 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp ); assert!( sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id && terminal.title.as_ref() == "Dev Server") + matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id && terminal.metadata.display_title().as_ref() == "Dev Server") }), "expected the inserted terminal to appear in sidebar contents", ); }); + sidebar.read_with(cx, |_sidebar, cx| { + let store = TerminalThreadMetadataStore::global(cx).read(cx); + let metadata = store + .entry(terminal_id) + .expect("terminal metadata should be persisted"); + assert_eq!(metadata.title.as_ref(), ""); + assert_eq!( + metadata.custom_title.as_ref().map(|title| title.as_ref()), + Some("Dev Server") + ); + assert_eq!(metadata.display_title().as_ref(), "Dev Server"); + assert!( + metadata + .folder_paths() + .paths() + .iter() + .any(|path| path.as_path() == Path::new("/my-project")) + ); + }); type_in_search(&sidebar, "server", cx); assert_eq!( @@ -1521,6 +1750,191 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp ); } +#[gpui::test] +async fn test_closing_last_agent_panel_terminal_restores_empty_header(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + assert_project_header_has_threads(&sidebar, "my-project", false, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_project_header_has_threads(&sidebar, "my-project", true, cx); + + let (terminal_metadata, terminal_workspace) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .find_map(|entry| match entry { + ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id => { + Some((terminal.metadata.clone(), terminal.workspace.clone())) + } + _ => None, + }) + .expect("terminal should be visible in sidebar") + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.close_terminal(&terminal_metadata, &terminal_workspace, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, cx| { + assert!(!panel.has_terminal(terminal_id)); + assert!( + panel.active_view_is_new_draft(cx), + "closing the active terminal should leave the panel on a hidden empty draft" + ); + }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]"] + ); + assert_project_header_has_threads(&sidebar, "my-project", false, cx); + + let project_group_key = multi_workspace.read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace().read(cx).project_group_key(cx) + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.toggle_collapse(&project_group_key, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + assert_project_header_has_threads(&sidebar, "my-project", false, cx); +} + +#[gpui::test] +async fn test_agent_panel_terminal_metadata_remains_visible_after_panel_is_removed( + cx: &mut TestAppContext, +) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.remove_panel(&panel, window, cx); + }); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + assert!(workspace.read_with(cx, |workspace, cx| { + workspace.panel::(cx).is_none() + })); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server"] + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!(sidebar.contents.entries.iter().any(|entry| { + matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id) + })); + }); +} + +#[gpui::test] +async fn test_terminal_metadata_is_deduped_across_project_groups(cx: &mut TestAppContext) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.set_global(agent_ui::MaxIdleRetainedThreads(1)); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + let workspace_a = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b, window, cx); + }); + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Original", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + workspace_a.update_in(cx, |workspace, window, cx| { + workspace.remove_panel(&panel, window, cx); + }); + let now = Utc::now(); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Dev Server".into(), + custom_title: None, + created_at: now, + worktree_paths: WorktreePaths::from_path_lists( + PathList::new(&[PathBuf::from("/project-a")]), + PathList::new(&[PathBuf::from("/project-b")]), + ) + .unwrap(), + remote_connection: None, + working_directory: None, + }; + + cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar + .contents + .entries + .iter() + .filter(|entry| { + matches!( + entry, + ListEntry::Terminal(terminal) + if terminal.metadata.terminal_id == terminal_id + ) + }) + .count(), + 1 + ); + }); +} + #[gpui::test] async fn test_agent_panel_terminal_shows_project_and_linked_worktree(cx: &mut TestAppContext) { agent_ui::test_support::init_test(cx); @@ -1586,6 +2000,907 @@ async fn test_agent_panel_terminal_shows_project_and_linked_worktree(cx: &mut Te ); } +#[gpui::test] +async fn test_terminal_close_event_on_archived_linked_worktree_removes_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test( + fs.clone(), + ["/worktrees/project/feature-a/project".as_ref()], + cx, + ) + .await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + let worktree_panel = add_agent_panel(&worktree_workspace, cx); + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + + let archived_session_id = acp::SessionId::new(Arc::from("archived-wt-thread")); + save_thread_metadata( + archived_session_id.clone(), + Some("Archived Worktree Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + None, + None, + &worktree_project, + cx, + ); + let archived_thread_id = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_by_session(&archived_session_id) + .expect("archived thread metadata should exist") + .thread_id + }); + cx.update(|_, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.archive(archived_thread_id, None, cx); + }); + }); + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + assert!( + agent_ui::draft_prompt_store::read(empty_draft_id, cx).is_none(), + "empty draft should not have persisted prompt content" + ); + }); + + let terminal_id = worktree_panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 2, + "should start with main and linked worktree workspaces" + ); + let entries_before = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_before + .iter() + .any(|entry| entry.contains("Dev Server") && entry.contains('{')), + "expected linked worktree terminal before closing, got: {entries_before:?}" + ); + + worktree_panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + for _ in 0..4 { + cx.run_until_parked(); + } + + let terminal_metadata_deleted = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none() + }); + assert!( + terminal_metadata_deleted, + "terminal metadata should be deleted after close" + ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted before archiving the linked worktree" + ); + let unarchived_worktree_threads = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(&worktree_folder_paths, None) + .count() + }); + assert_eq!( + unarchived_worktree_threads, 0, + "closing the terminal must not create a fallback draft for the removed worktree" + ); + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "linked worktree workspace should be removed after closing its last terminal" + ); + let entries_after = visible_entries_as_strings(&sidebar, cx); + assert!( + !entries_after.iter().any(|entry| entry.contains('{')), + "no sidebar entry should reference the archived worktree, got: {entries_after:?}" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after closing its last terminal" + ); +} + +#[gpui::test] +async fn test_terminal_close_event_deletes_empty_draft_when_linked_worktree_has_no_archive_root( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + fs.insert_branches(Path::new("/project/.git"), &["main", "feature-a"]); + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/external-worktree"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = + project::Project::test(fs.clone(), ["/external-worktree".as_ref()], cx).await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let _sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + let worktree_panel = add_agent_panel(&worktree_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = PathList::new(&[PathBuf::from("/external-worktree")]); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + + let terminal_id = worktree_panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + worktree_panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + for _ in 0..4 { + cx.run_until_parked(); + } + + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted when removing the linked worktree workspace" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should be removed after closing its last terminal" + ); + assert!( + fs.is_dir(Path::new("/external-worktree")).await, + "external linked worktree directory should remain on disk when no archive root is produced" + ); +} + +#[gpui::test] +async fn test_terminal_close_event_keeps_linked_worktree_workspace_with_live_editor_draft( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test( + fs.clone(), + ["/worktrees/project/feature-a/project".as_ref()], + cx, + ) + .await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let _sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + let worktree_panel = add_agent_panel(&worktree_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let draft_id = save_draft_metadata_with_main_paths( + Some("Worktree Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + + worktree_panel.update_in(cx, |panel, window, cx| { + panel.load_agent_thread( + Agent::Stub, + draft_id, + Some(worktree_folder_paths.clone()), + None, + false, + AgentThreadSource::AgentPanel, + window, + cx, + ); + }); + cx.run_until_parked(); + let editor_text = + worktree_panel.read_with(cx, |panel, cx| panel.editor_text_if_in_memory(draft_id, cx)); + assert_eq!( + editor_text, + Some(None), + "draft should be in memory with empty editor text before editing" + ); + + let message_editor = worktree_panel.read_with(cx, |panel, cx| { + panel + .active_thread_view(cx) + .expect("draft should be loaded in the agent panel") + .read(cx) + .message_editor + .clone() + }); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("keep this draft", window, cx); + }); + + let terminal_id = worktree_panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + let live_blocks = worktree_panel.read_with(cx, |panel, cx| { + panel.draft_prompt_blocks_if_in_memory(draft_id, cx) + }); + assert!( + matches!( + live_blocks.as_deref(), + Some([acp::ContentBlock::Text(text)]) if text.text == "keep this draft" + ), + "edited draft should still be readable from the panel after opening the terminal" + ); + + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 2, + "should start with main and linked worktree workspaces" + ); + + worktree_panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + for _ in 0..4 { + cx.run_until_parked(); + } + + let terminal_metadata_deleted = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none() + }); + assert!( + terminal_metadata_deleted, + "terminal metadata should be deleted after close" + ); + let unarchived_worktree_threads = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entries_for_path(&worktree_folder_paths, None) + .count() + }); + assert_eq!( + unarchived_worktree_threads, 1, + "edited draft should remain as a worktree thread reference" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_some(), + "linked worktree workspace should stay open while an edited draft references it" + ); + assert!( + fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should remain on disk while an edited draft references it" + ); +} + +#[gpui::test] +async fn test_archive_selected_draft_archives_linked_worktree_after_last_draft( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test( + fs.clone(), + ["/worktrees/project/feature-a/project".as_ref()], + cx, + ) + .await; + + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(worktree_project.clone(), window, cx) + }); + add_agent_panel(&worktree_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let first_draft_id = save_draft_metadata_with_main_paths( + Some("First Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + let second_draft_id = save_draft_metadata_with_main_paths( + Some("Second Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 4, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + first_draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "first draft", + ))], + cx, + ) + }) + .await + .expect("first draft prompt should persist"); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + second_draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "second draft", + ))], + cx, + ) + }) + .await + .expect("second draft prompt should persist"); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let first_draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == first_draft_id + ) + }) + .expect("first draft should be visible in sidebar") + }); + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(first_draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..4 { + cx.run_until_parked(); + } + + let first_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(first_draft_id) + .is_none() + }); + assert!( + first_draft_metadata_deleted, + "first discarded draft metadata should be deleted" + ); + let second_draft_metadata_kept = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(second_draft_id) + .is_some() + }); + assert!( + second_draft_metadata_kept, + "remaining contentful draft should still block worktree archival" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_some(), + "linked worktree workspace should remain while another draft references it" + ); + assert!( + fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should remain while another draft references it" + ); + + let second_draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == second_draft_id + ) + }) + .expect("second draft should be visible in sidebar") + }); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(second_draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let second_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(second_draft_id) + .is_none() + }); + assert!( + second_draft_metadata_deleted, + "last discarded draft metadata should be deleted" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should be removed after closing its last draft" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after closing its last draft" + ); +} + +#[gpui::test] +async fn test_archive_selected_draft_archives_closed_linked_worktree(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let draft_id = save_draft_metadata_with_main_paths( + Some("Closed Worktree Draft".into()), + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "closed draft", + ))], + cx, + ) + }) + .await + .expect("draft prompt should persist"); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id + ) + }) + .expect("closed worktree draft should be visible in sidebar") + }); + sidebar.read_with(cx, |sidebar, _cx| { + match &sidebar.contents.entries[draft_index] { + ListEntry::Thread(thread) => match &thread.workspace { + ThreadEntryWorkspace::Closed { folder_paths, .. } => { + assert_eq!(folder_paths, &worktree_folder_paths); + } + ThreadEntryWorkspace::Open(_) => { + panic!("linked worktree draft should start closed") + } + }, + _ => panic!("expected draft row"), + } + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(draft_id) + .is_none() + }); + assert!( + draft_metadata_deleted, + "discarded closed worktree draft metadata should be deleted" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "temporary linked worktree workspace should be removed after discarding its last draft" + ); + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "discarding a closed linked worktree draft should leave only the main workspace" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after discarding its last draft" + ); +} + +#[gpui::test] +async fn test_terminal_close_event_closes_sidebar_terminal(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server"] + ); + + panel.update(cx, |panel, cx| { + panel.emit_test_terminal_close(terminal_id, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert!(!panel.has_terminal(terminal_id)); + }); + sidebar.read_with(cx, |sidebar, _cx| { + assert!(sidebar.contents.entries.iter().all(|entry| { + !matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id) + })); + }); + sidebar.read_with(cx, |_sidebar, cx| { + assert!( + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none(), + "terminal metadata should be deleted when the terminal requests close" + ); + }); +} + #[gpui::test] async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestAppContext) { let project = init_test_project_with_agent_panel("/my-project", cx).await; @@ -1618,7 +2933,7 @@ async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestApp assert!(sidebar.has_notifications(cx)); assert!(sidebar.contents.notified_terminals.contains(&build_terminal_id)); assert!(sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Terminal(terminal) if terminal.id == build_terminal_id && terminal.has_notification) + matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == build_terminal_id && terminal.has_notification) })); }); @@ -1712,6 +3027,680 @@ async fn test_thread_switcher_can_activate_agent_panel_terminal(cx: &mut TestApp }); } +#[gpui::test] +async fn test_thread_switcher_includes_terminal_metadata_for_open_project_group( + cx: &mut TestAppContext, +) { + let project = init_test_project_with_agent_panel("/project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Feature Terminal", true, window, cx) + }) + .expect("test terminal should be inserted"); + panel.update_in(cx, |panel, window, cx| { + panel.close_terminal(terminal_id, window, cx); + }); + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-newer")), + Some("Newer Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + None, + None, + &project, + cx, + ); + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-older")), + Some("Older Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &project, + cx, + ); + + let created_at = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Feature Terminal".into(), + custom_title: None, + created_at, + worktree_paths: WorktreePaths::from_path_lists( + PathList::new(&[PathBuf::from("/project")]), + PathList::new(&[PathBuf::from("/project-feature")]), + ) + .unwrap(), + remote_connection: None, + working_directory: None, + }; + cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + assert!( + switcher + .read(cx) + .entries() + .iter() + .any(|entry| entry.terminal_id() == Some(terminal_id)), + "terminal metadata row should be included like a closed thread row" + ); + }); +} + +#[gpui::test] +async fn test_thread_switcher_preserves_closed_terminal_linked_worktree_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Feature Terminal", true, window, cx) + }) + .expect("test terminal should be inserted"); + panel.update_in(cx, |panel, window, cx| { + panel.close_terminal(terminal_id, window, cx); + }); + let created_at = chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(); + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Feature Terminal".into(), + custom_title: None, + created_at, + worktree_paths: WorktreePaths::from_path_lists( + PathList::new(&[PathBuf::from("/project")]), + worktree_folder_paths.clone(), + ) + .unwrap(), + remote_connection: None, + working_directory: None, + }; + cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should start closed" + ); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + match switcher + .read(cx) + .selected_entry() + .expect("switcher should select the terminal row by default") + { + ThreadSwitcherEntry::Terminal(entry) => { + assert_eq!(entry.metadata.terminal_id, terminal_id); + match &entry.workspace { + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } => { + assert_eq!(folder_paths, &worktree_folder_paths); + assert_eq!( + project_group_key.path_list(), + &PathList::new(&[PathBuf::from("/project")]) + ); + } + ThreadEntryWorkspace::Open(_) => { + panic!("closed terminal row should retain its linked worktree target") + } + } + } + ThreadSwitcherEntry::Thread(_) => { + panic!("terminal row should be selected by default") + } + } + }); +} + +#[gpui::test] +async fn test_archive_selected_terminal_archives_closed_linked_worktree(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Feature Terminal", true, window, cx) + }) + .expect("test terminal should be inserted"); + panel.update_in(cx, |panel, window, cx| { + panel.close_terminal(terminal_id, window, cx); + }); + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + let metadata = TerminalThreadMetadata { + terminal_id, + title: "Feature Terminal".into(), + custom_title: None, + created_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + worktree_paths: WorktreePaths::from_path_lists( + PathList::new(&[PathBuf::from("/project")]), + worktree_folder_paths.clone(), + ) + .unwrap(), + remote_connection: None, + working_directory: None, + }; + cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + }); + }); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + assert!( + agent_ui::draft_prompt_store::read(empty_draft_id, cx).is_none(), + "empty draft should not have persisted prompt content" + ); + }); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let terminal_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id)) + .expect("terminal should be visible in sidebar") + }); + sidebar.read_with(cx, |sidebar, _cx| { + match &sidebar.contents.entries[terminal_index] { + ListEntry::Terminal(terminal) => match &terminal.workspace { + ThreadEntryWorkspace::Closed { folder_paths, .. } => { + assert_eq!(folder_paths, &worktree_folder_paths); + } + ThreadEntryWorkspace::Open(_) => { + panic!("linked worktree terminal should start closed") + } + }, + _ => panic!("expected terminal row"), + } + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(terminal_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let terminal_metadata_deleted = cx.update(|_, cx| { + TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .is_none() + }); + assert!( + terminal_metadata_deleted, + "terminal metadata should be deleted after closing from the sidebar" + ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted before archiving the linked worktree" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "temporary linked worktree workspace should be removed after archiving" + ); + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "closing a closed linked worktree terminal should leave only the main workspace" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after closing its terminal" + ); +} + +#[gpui::test] +async fn test_archive_selected_thread_archives_closed_linked_worktree(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/project/feature-a/project", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/project/feature-a/project"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let worktree_session_id = acp::SessionId::new(Arc::from("worktree-thread")); + let worktree_folder_paths = + PathList::new(&[PathBuf::from("/worktrees/project/feature-a/project")]); + save_thread_metadata_with_main_paths( + "worktree-thread", + "Worktree Thread", + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + cx, + ); + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + assert!( + agent_ui::draft_prompt_store::read(empty_draft_id, cx).is_none(), + "empty draft should not have persisted prompt content" + ); + }); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let thread_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&worktree_session_id))) + .expect("worktree thread should be visible in sidebar") + }); + sidebar.read_with(cx, |sidebar, _cx| { + match &sidebar.contents.entries[thread_index] { + ListEntry::Thread(thread) => match &thread.workspace { + ThreadEntryWorkspace::Closed { folder_paths, .. } => { + assert_eq!(folder_paths, &worktree_folder_paths); + } + ThreadEntryWorkspace::Open(_) => { + panic!("linked worktree thread should start closed") + } + }, + _ => panic!("expected thread row"), + } + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(thread_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let thread_archived = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_by_session(&worktree_session_id) + .map(|thread| thread.archived) + }); + assert_eq!( + thread_archived, + Some(true), + "thread metadata should remain archived after worktree archival" + ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted before archiving the linked worktree" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "temporary linked worktree workspace should be removed after archiving" + ); + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "archiving a closed linked worktree thread should leave only the main workspace" + ); + assert!( + !fs.is_dir(Path::new("/worktrees/project/feature-a/project")) + .await, + "linked worktree directory should be removed from disk after archiving its thread" + ); +} + +#[gpui::test] +async fn test_archive_selected_thread_deletes_empty_draft_when_linked_worktree_has_no_archive_root( + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + fs.insert_branches(Path::new("/project/.git"), &["main", "feature-a"]); + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/external-worktree"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let worktree_session_id = acp::SessionId::new(Arc::from("external-worktree-thread")); + let worktree_folder_paths = PathList::new(&[PathBuf::from("/external-worktree")]); + save_thread_metadata_with_main_paths( + "external-worktree-thread", + "External Worktree Thread", + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + cx, + ); + save_thread_metadata( + acp::SessionId::new(Arc::from("main-thread")), + Some("Main Thread".into()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + None, + None, + &main_project, + cx, + ); + let empty_draft_id = save_draft_metadata_with_main_paths( + None, + worktree_folder_paths.clone(), + PathList::new(&[PathBuf::from("/project")]), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + cx, + ); + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let thread_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Thread(thread) if thread.metadata.session_id.as_ref() == Some(&worktree_session_id))) + .expect("worktree thread should be visible in sidebar") + }); + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(thread_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + let thread_archived = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry_by_session(&worktree_session_id) + .map(|thread| thread.archived) + }); + assert_eq!( + thread_archived, + Some(true), + "thread metadata should remain archived after workspace removal" + ); + let empty_draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(empty_draft_id) + .is_none() + }); + assert!( + empty_draft_metadata_deleted, + "empty draft metadata should be deleted when removing the linked worktree workspace" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "linked worktree workspace should be removed after archiving its last thread" + ); + assert!( + fs.is_dir(Path::new("/external-worktree")).await, + "external linked worktree directory should remain on disk when no archive root is produced" + ); +} + #[gpui::test] async fn test_archive_selected_thread_closes_selected_agent_panel_terminal( cx: &mut TestAppContext, @@ -1734,7 +3723,7 @@ async fn test_archive_selected_thread_closes_selected_agent_panel_terminal( .contents .entries .iter() - .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id)) + .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id)) .expect("terminal should be visible in sidebar") }); sidebar.update_in(cx, |sidebar, _window, _cx| { @@ -1748,9 +3737,16 @@ async fn test_archive_selected_thread_closes_selected_agent_panel_terminal( }); sidebar.read_with(cx, |sidebar, _cx| { assert!(sidebar.contents.entries.iter().all(|entry| { - !matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id) + !matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id) })); }); + sidebar.read_with(cx, |_sidebar, cx| { + let store = TerminalThreadMetadataStore::global(cx).read(cx); + assert!( + store.entry(terminal_id).is_none(), + "terminal metadata should be deleted when closing from the sidebar" + ); + }); } #[gpui::test] @@ -1759,10 +3755,6 @@ async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut Te let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); - let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { - multi_workspace.workspace().clone() - }); - let build_terminal_id = panel .update_in(cx, |panel, window, cx| { panel.insert_test_terminal("Build", true, window, cx) @@ -1775,8 +3767,23 @@ async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut Te .expect("server test terminal should be inserted"); cx.run_until_parked(); + let (server_metadata, server_workspace) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .find_map(|entry| match entry { + ListEntry::Terminal(terminal) + if terminal.metadata.terminal_id == server_terminal_id => + { + Some((terminal.metadata.clone(), terminal.workspace.clone())) + } + _ => None, + }) + .expect("server terminal should be visible in sidebar") + }); sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.close_terminal(&workspace, server_terminal_id, window, cx); + sidebar.close_terminal(&server_metadata, &server_workspace, window, cx); }); cx.run_until_parked(); @@ -2884,6 +4891,203 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) ); } +#[gpui::test] +async fn test_rename_thread_from_sidebar_updates_title_override(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, &project, cx).await; + cx.run_until_parked(); + + let (entry_ix, thread_id, title) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .find_map(|(ix, entry)| match entry { + ListEntry::Thread(thread) => Some(( + ix, + thread.metadata.thread_id, + thread.metadata.display_title(), + )), + ListEntry::ProjectHeader { .. } | ListEntry::Terminal(_) => None, + }) + .expect("sidebar should have a thread entry") + }); + + let renamed_title = "abcdefghijklmnopqrstuvwxyé renamed"; + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.start_renaming_thread(entry_ix, thread_id, title, window, cx); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.thread_rename_editor.update(cx, |editor, cx| { + editor.set_text(renamed_title, window, cx); + }); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.finish_thread_rename(window, cx); + }); + cx.run_until_parked(); + + let metadata = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(thread_id) + .cloned() + .expect("thread metadata should exist") + }); + assert_eq!(metadata.title_override.as_deref(), Some(renamed_title)); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [my-project]", + " abcdefghijklmnopqrstuvwxyé renamed * <== selected", + ] + ); + + let active_thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); + assert_eq!( + active_thread.read_with(cx, |thread, _| thread.title()), + Some(renamed_title.into()) + ); + let active_thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let title_editor_text = + active_thread_view.read_with(cx, |view, cx| view.title_editor.read(cx).text(cx)); + assert_eq!(title_editor_text, renamed_title); + + active_thread.update(cx, |thread, cx| { + thread + .set_title("abcdefghijklmnopqrstuvwxyz0".into(), cx) + .detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [my-project]", + " abcdefghijklmnopqrstuvwxyé renamed * <== selected", + ] + ); + + type_in_search(&sidebar, "0", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); + + type_in_search(&sidebar, "é", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + // + "v [my-project]", + " abcdefghijklmnopqrstuvwxyé renamed * <== selected", + ] + ); + sidebar.read_with(cx, |sidebar, _cx| { + let thread = sidebar + .contents + .entries + .iter() + .find_map(|entry| match entry { + ListEntry::Thread(thread) => Some(thread), + ListEntry::ProjectHeader { .. } | ListEntry::Terminal(_) => None, + }) + .expect("renamed thread should match the search"); + let title = thread.metadata.display_title(); + assert!( + thread + .highlight_positions + .iter() + .all(|position| { title.is_char_boundary(*position) }) + ); + }); +} + +#[gpui::test] +async fn test_rename_selected_thread_action_renames_selected_thread(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, &project, cx).await; + cx.run_until_parked(); + + let (entry_ix, thread_id) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .find_map(|(ix, entry)| match entry { + ListEntry::Thread(thread) => Some((ix, thread.metadata.thread_id)), + ListEntry::ProjectHeader { .. } | ListEntry::Terminal(_) => None, + }) + .expect("sidebar should have a thread entry") + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(entry_ix); + }); + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.renaming_thread_id, + Some(thread_id), + "dispatching RenameSelectedThread should start renaming the selected thread" + ); + }); + + let renamed_title = "Renamed via action"; + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.thread_rename_editor.update(cx, |editor, cx| { + editor.set_text(renamed_title, window, cx); + }); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.finish_thread_rename(window, cx); + }); + cx.run_until_parked(); + + let metadata = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(thread_id) + .cloned() + .expect("thread metadata should exist") + }); + assert_eq!(metadata.title_override.as_deref(), Some(renamed_title)); +} + #[gpui::test] async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { let project_a = init_test_project_with_agent_panel("/project-a", cx).await; @@ -4788,7 +6992,7 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje ListEntry::Terminal(terminal) => { panic!( "unexpected sidebar terminal while opening linked worktree thread: title=`{}`", - terminal.title + terminal.metadata.title ); } } @@ -10073,15 +12277,24 @@ mod property_test { let panel = workspace.read_with(cx, |workspace, cx| workspace.panel::(cx)); if let Some(panel) = panel { - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![ - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( - "Done".into(), - )), - ]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); + let agent_id = AgentId::new(format!("prop-agent-{}", state.thread_counter)); + let connection = StubAgentConnection::new().with_agent_id(agent_id.clone()); + open_thread_with_custom_connection(&panel, connection.clone(), cx); + let thread_id = active_thread_id(&panel, cx); let session_id = active_session_id(&panel, cx); + // Make the thread non-draft without exercising the prompt + // send path; these invariants are about sidebar state, not + // git checkpointing during user prompts. + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Done".into(), + )), + cx, + ); + }); + cx.run_until_parked(); state.saved_thread_ids.push(session_id.clone()); let title: SharedString = format!("Thread {}", state.thread_counter).into(); @@ -10090,15 +12303,24 @@ mod property_test { chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0) .unwrap() + chrono::Duration::seconds(state.thread_counter as i64); - save_thread_metadata( - session_id, - Some(title), + let metadata = cx.update(|_, cx| ThreadMetadata { + thread_id, + session_id: Some(session_id), + agent_id, + title: Some(title), + title_override: None, updated_at, - None, - None, - &project, - cx, - ); + created_at: None, + interacted_at: None, + worktree_paths: project.read(cx).worktree_paths(cx), + archived: false, + remote_connection: project.read(cx).remote_connection_options(cx), + }); + cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.save(metadata, cx)) + }); + cx.run_until_parked(); } } Operation::SaveWorktreeThread { worktree_index } => { @@ -10564,11 +12786,12 @@ mod property_test { // content yet. let panel = active_workspace.read(cx).panel::(cx).unwrap(); let panel_has_content = panel.read(cx).active_thread_id(cx).is_some() - || panel.read(cx).active_conversation_view().is_some(); + || panel.read(cx).active_conversation_view().is_some() + || panel.read(cx).active_terminal_id().is_some(); let Some(entry) = sidebar.active_entry.as_ref() else { if panel_has_content { - anyhow::bail!("active_entry is None but panel has content (draft or thread)"); + anyhow::bail!("active_entry is None but panel has content"); } return Ok(()); }; @@ -10721,15 +12944,19 @@ mod property_test { use std::sync::atomic::{AtomicUsize, Ordering}; static NEXT_PROPTEST_DB: AtomicUsize = AtomicUsize::new(0); + let test_db_id = NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst); + cx.update(|cx| { + cx.set_global(TestTerminalMetadataDbName(format!( + "PROPTEST_TERMINAL_THREAD_METADATA_{test_db_id}" + ))); + }); + agent_ui::test_support::init_test(cx); cx.update(|cx| { cx.set_global(db::AppDatabase::test_new()); cx.set_global(agent_ui::MaxIdleRetainedThreads(1)); cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName( - format!( - "PROPTEST_THREAD_METADATA_{}", - NEXT_PROPTEST_DB.fetch_add(1, Ordering::SeqCst) - ), + format!("PROPTEST_THREAD_METADATA_{test_db_id}"), )); ThreadStore::init_global(cx); @@ -11480,6 +13707,215 @@ async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &m ); } +#[gpui::test] +async fn test_discard_mixed_workspace_draft_closes_only_archived_worktree_items( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/main-repo", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": { + "lib.rs": "pub fn hello() {}", + }, + }), + ) + .await; + + fs.insert_tree( + "/worktrees/main-repo/feature-b/main-repo", + serde_json::json!({ + ".git": "gitdir: /main-repo/.git/worktrees/feature-b", + "src": { + "main.rs": "fn main() { hello(); }", + }, + }), + ) + .await; + + fs.add_linked_worktree_for_repo( + Path::new("/main-repo/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/worktrees/main-repo/feature-b/main-repo"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "def".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let mixed_project = project::Project::test( + fs.clone(), + [ + "/main-repo".as_ref(), + "/worktrees/main-repo/feature-b/main-repo".as_ref(), + ], + cx, + ) + .await; + + mixed_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(mixed_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + + let worktree_ids: Vec<(WorktreeId, Arc)> = workspace.read_with(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .visible_worktrees(cx) + .map(|worktree| (worktree.read(cx).id(), worktree.read(cx).abs_path())) + .collect() + }); + + let main_repo_worktree_id = worktree_ids + .iter() + .find(|(_, path)| path.as_ref() == Path::new("/main-repo")) + .map(|(id, _)| *id) + .expect("should find main-repo worktree"); + + let feature_b_worktree_id = worktree_ids + .iter() + .find(|(_, path)| path.as_ref() == Path::new("/worktrees/main-repo/feature-b/main-repo")) + .map(|(id, _)| *id) + .expect("should find feature-b worktree"); + + let main_repo_path = project::ProjectPath { + worktree_id: main_repo_worktree_id, + path: Arc::from(rel_path("src/lib.rs")), + }; + let feature_b_path = project::ProjectPath { + worktree_id: feature_b_worktree_id, + path: Arc::from(rel_path("src/main.rs")), + }; + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(main_repo_path.clone(), None, true, window, cx) + }) + .await + .expect("should open main-repo file"); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(feature_b_path.clone(), None, true, window, cx) + }) + .await + .expect("should open feature-b file"); + + let folder_paths = PathList::new(&[ + PathBuf::from("/main-repo"), + PathBuf::from("/worktrees/main-repo/feature-b/main-repo"), + ]); + let main_worktree_paths = + PathList::new(&[PathBuf::from("/main-repo"), PathBuf::from("/main-repo")]); + let draft_id = save_draft_metadata_with_main_paths( + Some("Mixed Workspace Draft".into()), + folder_paths, + main_worktree_paths, + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + cx, + ); + cx.update(|_, cx| { + agent_ui::draft_prompt_store::write( + draft_id, + &[acp::ContentBlock::Text(acp::TextContent::new( + "mixed workspace draft", + ))], + cx, + ) + }) + .await + .expect("draft prompt should persist"); + + sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx)); + cx.run_until_parked(); + + let draft_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id + ) + }) + .expect("mixed workspace draft should be visible") + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(draft_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + for _ in 0..8 { + cx.run_until_parked(); + } + + assert_eq!( + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace + .workspaces() + .count()), + 1, + "mixed workspace should be preserved" + ); + + let open_paths_after: Vec = workspace.read_with(cx, |workspace, cx| { + workspace + .panes() + .iter() + .flat_map(|pane| { + pane.read(cx) + .items() + .filter_map(|item| item.project_path(cx)) + }) + .collect() + }); + assert!( + open_paths_after + .iter() + .any(|project_path| project_path.worktree_id == main_repo_worktree_id), + "main-repo file should still be open" + ); + assert!( + !open_paths_after + .iter() + .any(|project_path| project_path.worktree_id == feature_b_worktree_id), + "feature-b file should have been closed" + ); + + let draft_metadata_deleted = cx.update(|_, cx| { + ThreadMetadataStore::global(cx) + .read(cx) + .entry(draft_id) + .is_none() + }); + assert!( + draft_metadata_deleted, + "discarded draft metadata should be deleted" + ); +} + #[test] fn test_worktree_info_branch_names_for_main_worktrees() { let folder_paths = PathList::new(&[PathBuf::from("/projects/myapp")]); @@ -11723,6 +14159,164 @@ async fn test_remote_archive_thread_with_active_connection( ); } +#[gpui::test] +async fn test_remote_linked_worktree_workspace_to_remove_uses_remote_connection( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + init_test(cx); + + cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + + let app_state = cx.update(|cx| { + let app_state = workspace::AppState::test(cx); + workspace::init(app_state.clone(), cx); + app_state + }); + + let server_fs = FakeFs::new(server_cx.executor()); + server_fs + .insert_tree( + "/project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + server_fs + .insert_tree( + "/external-worktree", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + server_fs.set_branch_name(Path::new("/project/.git"), Some("main")); + server_fs.insert_branches(Path::new("/project/.git"), &["main", "feature-a"]); + server_fs + .add_linked_worktree_for_repo( + Path::new("/project/.git"), + false, + git::repository::Worktree { + path: PathBuf::from("/external-worktree"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "abc".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + let (worktree_project, _headless, remote_connection) = start_remote_project( + &server_fs, + Path::new("/external-worktree"), + &app_state, + None, + cx, + server_cx, + ) + .await; + worktree_project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + cx.run_until_parked(); + + cx.update(|cx| ::set_global(app_state.fs.clone(), cx)); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(worktree_project.clone(), window, cx) + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let worktree_session_id = acp::SessionId::new(Arc::from("remote-worktree-thread")); + let worktree_folder_paths = PathList::new(&[PathBuf::from("/external-worktree")]); + let main_folder_paths = PathList::new(&[PathBuf::from("/project")]); + let worktree_thread_id = ThreadId::new(); + cx.update(|_window, cx| { + let metadata = ThreadMetadata { + thread_id: worktree_thread_id, + session_id: Some(worktree_session_id.clone()), + agent_id: agent::ZED_AGENT_ID.clone(), + title: Some("Remote Worktree Thread".into()), + title_override: None, + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + created_at: None, + interacted_at: None, + worktree_paths: WorktreePaths::from_path_lists( + main_folder_paths, + worktree_folder_paths.clone(), + ) + .unwrap(), + archived: false, + remote_connection: Some(remote_connection.clone()), + }; + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + cx.run_until_parked(); + + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths( + &worktree_folder_paths, + Some(&remote_connection), + cx, + ) + }) + .is_some(), + "remote linked-worktree workspace should be open before archiving" + ); + assert!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace_for_paths(&worktree_folder_paths, None, cx) + }) + .is_none(), + "the test must exercise a remote-only workspace lookup" + ); + assert_ne!( + multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace().read(cx).project_group_key(cx) + }) + .path_list(), + &worktree_folder_paths, + "remote workspace must be classified as a linked worktree under the main project" + ); + + let workspace_to_remove = sidebar.read_with(cx, |sidebar, cx| { + sidebar + .linked_worktree_workspace_to_remove( + &worktree_folder_paths, + Some(&remote_connection), + Some(worktree_thread_id), + None, + &[], + cx, + ) + .map(|workspace| workspace.entity_id()) + }); + let active_workspace_id = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().entity_id() + }); + assert_eq!( + workspace_to_remove, + Some(active_workspace_id), + "archive helper should resolve the remote linked-worktree workspace" + ); + assert!( + server_fs.is_dir(Path::new("/external-worktree")).await, + "direct helper check should not remove the linked worktree from disk" + ); +} + #[gpui::test] async fn test_remote_archive_thread_with_disconnected_remote( cx: &mut TestAppContext, diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index a91ae5fe067..aa947416082 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/crates/sidebar/src/thread_switcher.rs @@ -1,5 +1,9 @@ use action_log::DiffStats; -use agent_ui::{TerminalId, thread_metadata_store::ThreadMetadata}; +#[cfg(test)] +use agent_ui::TerminalId; +use agent_ui::{ + terminal_thread_metadata_store::TerminalThreadMetadata, thread_metadata_store::ThreadMetadata, +}; use gpui::{ Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers, ModifiersChangedEvent, Render, ScrollHandle, SharedString, prelude::*, @@ -8,6 +12,8 @@ use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, WithScrollbar, p use workspace::{ModalView, Workspace}; use zed_actions::agents_sidebar::ToggleThreadSwitcher; +use super::ThreadEntryWorkspace; + #[derive(Clone)] pub(crate) struct ThreadSwitcherThreadEntry { pub title: SharedString, @@ -27,12 +33,10 @@ pub(crate) struct ThreadSwitcherThreadEntry { #[derive(Clone)] pub(crate) struct ThreadSwitcherTerminalEntry { - pub terminal_id: TerminalId, - pub title: SharedString, - pub workspace: Entity, + pub metadata: TerminalThreadMetadata, + pub(super) workspace: ThreadEntryWorkspace, pub project_name: Option, pub worktrees: Vec, - pub created_at: chrono::DateTime, pub notified: bool, pub timestamp: SharedString, } @@ -44,26 +48,26 @@ pub(crate) enum ThreadSwitcherEntry { } #[derive(Clone)] -pub(crate) enum ThreadSwitcherSelection { +pub(super) enum ThreadSwitcherSelection { Thread { metadata: ThreadMetadata, workspace: Entity, }, Terminal { - terminal_id: TerminalId, - workspace: Entity, + metadata: TerminalThreadMetadata, + workspace: ThreadEntryWorkspace, }, } impl ThreadSwitcherEntry { - pub(crate) fn selection(&self) -> ThreadSwitcherSelection { + pub(super) fn selection(&self) -> ThreadSwitcherSelection { match self { Self::Thread(entry) => ThreadSwitcherSelection::Thread { metadata: entry.metadata.clone(), workspace: entry.workspace.clone(), }, Self::Terminal(entry) => ThreadSwitcherSelection::Terminal { - terminal_id: entry.terminal_id, + metadata: entry.metadata.clone(), workspace: entry.workspace.clone(), }, } @@ -75,16 +79,17 @@ impl ThreadSwitcherEntry { "thread-switcher-thread-{:?}", entry.metadata.thread_id )), - Self::Terminal(entry) => { - SharedString::from(format!("thread-switcher-terminal-{}", entry.terminal_id)) - } + Self::Terminal(entry) => SharedString::from(format!( + "thread-switcher-terminal-{}", + entry.metadata.terminal_id + )), } } fn title(&self) -> SharedString { match self { Self::Thread(entry) => entry.title.clone(), - Self::Terminal(entry) => entry.title.clone(), + Self::Terminal(entry) => entry.metadata.display_title(), } } @@ -172,12 +177,12 @@ impl ThreadSwitcherEntry { pub fn terminal_id(&self) -> Option { match self { Self::Thread(_) => None, - Self::Terminal(entry) => Some(entry.terminal_id), + Self::Terminal(entry) => Some(entry.metadata.terminal_id), } } } -pub(crate) enum ThreadSwitcherEvent { +pub(super) enum ThreadSwitcherEvent { Preview(ThreadSwitcherSelection), Confirmed(ThreadSwitcherSelection), Dismissed, diff --git a/crates/rules_library/Cargo.toml b/crates/skill_creator/Cargo.toml similarity index 55% rename from crates/rules_library/Cargo.toml rename to crates/skill_creator/Cargo.toml index 352f86bd72f..8512c4d50b2 100644 --- a/crates/rules_library/Cargo.toml +++ b/crates/skill_creator/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "rules_library" +name = "skill_creator" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,28 +9,31 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/rules_library.rs" - +path = "src/skill_creator.rs" [dependencies] +agent_skills.workspace = true anyhow.workspace = true -collections.workspace = true editor.workspace = true +fs.workspace = true +futures.workspace = true gpui.workspace = true +http_client.workspace = true language.workspace = true -language_model.workspace = true -log.workspace = true menu.workspace = true -picker.workspace = true +notifications.workspace = true platform_title_bar.workspace = true -prompt_store.workspace = true release_channel.workspace = true -rope.workspace = true -serde.workspace = true +serde_yaml_ng.workspace = true settings.workspace = true theme_settings.workspace = true ui.workspace = true ui_input.workspace = true util.workspace = true workspace.workspace = true -zed_actions.workspace = true +worktree.workspace = true + +[dev-dependencies] +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +serde_json.workspace = true diff --git a/crates/skill_creator/LICENSE-GPL b/crates/skill_creator/LICENSE-GPL new file mode 120000 index 00000000000..89e542f750c --- /dev/null +++ b/crates/skill_creator/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/skill_creator/src/skill_creator.rs b/crates/skill_creator/src/skill_creator.rs new file mode 100644 index 00000000000..ffbe28801ad --- /dev/null +++ b/crates/skill_creator/src/skill_creator.rs @@ -0,0 +1,1993 @@ +use agent_skills::{ + AGENTS_DIR_NAME, GLOBAL_SKILLS_DIR_DISPLAY, MAX_SKILL_DESCRIPTION_LEN, MAX_SKILL_FILE_SIZE, + SKILL_FILE_NAME, SKILLS_DIR_NAME, SkillMetadata, global_skills_dir, parse_skill_file_content, + slugify_skill_name, validate_description, validate_name, +}; +use anyhow::{Context as _, Result, anyhow}; +use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle}; +use fs::Fs; +use futures::AsyncReadExt; +use gpui::{ + App, Bounds, Entity, FocusHandle, Focusable, ScrollHandle, Subscription, Task, TextStyle, + Tiling, TitlebarOptions, WeakEntity, WindowBounds, WindowHandle, WindowOptions, actions, point, +}; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request, StatusCode, Url}; +use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; +use notifications::status_toast::StatusToast; +use platform_title_bar::PlatformTitleBar; +use release_channel::ReleaseChannel; +use settings::{ActionSequence, Settings}; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; +use theme_settings::ThemeSettings; +use ui::{ + Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, Headline, HeadlineSize, SwitchField, + WithScrollbar, prelude::*, +}; +use ui_input::{ErasedEditorEvent, InputField}; +use util::ResultExt; +use workspace::{Workspace, WorkspaceSettings, client_side_decorations}; +use worktree::WorktreeId; + +actions!( + skill_creator, + [SaveSkill, Cancel, FocusNextField, FocusPreviousField,] +); + +const URL_FIELD_TAB_INDEX: isize = 1; +const NAME_FIELD_TAB_INDEX: isize = 2; +const DESCRIPTION_FIELD_TAB_INDEX: isize = 3; +const DISABLE_MODEL_INVOCATION_TAB_INDEX: isize = 4; +const SCOPE_FIELD_TAB_INDEX: isize = 5; +const BODY_FIELD_TAB_INDEX: isize = 6; +const CANCEL_BUTTON_TAB_INDEX: isize = 7; +const SAVE_BUTTON_TAB_INDEX: isize = 8; +const URL_IMPORT_DEBOUNCE: Duration = Duration::from_millis(100); +const URL_IMPORT_ERROR_BODY_MAX_LEN: usize = 2048; + +pub fn init(_cx: &mut App) {} + +#[derive(Clone, Debug, Default)] +pub enum SkillCreatorOpenMode { + #[default] + Form, + Url { + initial_url: Option, + }, + /// Review and install a skill whose full `SKILL.md` contents are + /// supplied inline, e.g. from a `zed://skill` share link. The form is + /// pre-filled with the parsed skill so the recipient can review it and + /// pick a scope before saving. + Install { + content: String, + }, +} + +#[derive(Clone, Debug)] +enum UrlImportStatus { + Idle, + Fetching, + Error(SharedString), +} + +#[derive(Debug)] +struct ImportedSkill { + name: String, + description: String, + body: String, + disable_model_invocation: bool, +} + +#[derive(Clone, Debug)] +enum ScopeChoice { + Global, + Project { + worktree_id: WorktreeId, + root_name: SharedString, + abs_path: Arc, + }, +} + +impl ScopeChoice { + fn key(&self) -> SharedString { + match self { + ScopeChoice::Global => "global".into(), + ScopeChoice::Project { worktree_id, .. } => { + SharedString::from(format!("project-{}", worktree_id.to_usize())) + } + } + } + + /// Absolute path of the `.agents/skills` directory this scope writes to. + fn skills_dir(&self) -> PathBuf { + match self { + ScopeChoice::Global => global_skills_dir(), + ScopeChoice::Project { abs_path, .. } => { + abs_path.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME) + } + } + } +} + +/// Collect the user-visible worktrees from the originating workspace and +/// turn them into project-scope choices. Returns an empty `Vec` if the +/// workspace can't be read (e.g. it was already dropped). +fn project_scopes_from_workspace( + workspace: &Option>, + cx: &App, +) -> Vec { + let Some(workspace) = workspace.as_ref().and_then(|w| w.upgrade()) else { + return Vec::new(); + }; + let workspace = workspace.read(cx); + let root_paths = workspace.root_paths(cx); + workspace + .visible_worktrees(cx) + .zip(root_paths) + .map(|(worktree, abs_path)| { + let worktree = worktree.read(cx); + ScopeChoice::Project { + worktree_id: worktree.id(), + root_name: SharedString::from(worktree.root_name_str().to_string()), + abs_path, + } + }) + .collect() +} + +/// Open the skills library window. If one is already open, brings it to the +/// foreground. +pub fn open_skill_creator( + workspace: Option>, + language_registry: Arc, + fs: Arc, + open_mode: SkillCreatorOpenMode, + on_saved: Option>, + cx: &mut App, +) -> Task>> { + cx.spawn(async move |cx| { + let open_mode_for_existing = open_mode.clone(); + let on_saved_for_existing = on_saved.clone(); + let existing = cx.update(|cx| { + let handle = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()); + if let Some(handle) = handle { + handle + .update(cx, |this, window, cx| { + window.activate_window(); + this.on_saved = on_saved_for_existing.clone(); + this.apply_open_mode(open_mode_for_existing.clone(), window, cx); + }) + .ok(); + Some(handle) + } else { + None + } + }); + if let Some(window) = existing { + return Ok(window); + } + + let window_size = gpui::size(px(900.), px(1050.)); + // Allow the window to be resized noticeably smaller than the + // default so that the form scrolls inside the available space. + let window_min_size = gpui::size(px(500.), px(420.)); + + cx.update(|cx| { + let app_id = ReleaseChannel::global(cx).app_id(); + let http_client = cx.http_client(); + let bounds = Bounds::centered(None, window_size, cx); + let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") { + Ok(val) if val == "server" => gpui::WindowDecorations::Server, + Ok(val) if val == "client" => gpui::WindowDecorations::Client, + _ => match WorkspaceSettings::get_global(cx).window_decorations { + settings::WindowDecorations::Server => gpui::WindowDecorations::Server, + settings::WindowDecorations::Client => gpui::WindowDecorations::Client, + }, + }; + cx.open_window( + WindowOptions { + titlebar: Some(TitlebarOptions { + title: Some("New Skill".into()), + appears_transparent: true, + traffic_light_position: Some(point(px(12.0), px(12.0))), + }), + app_id: Some(app_id.to_owned()), + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_background: cx.theme().window_background_appearance(), + window_decorations: Some(window_decorations), + window_min_size: Some(window_min_size), + kind: gpui::WindowKind::Floating, + ..Default::default() + }, + |window, cx| { + let skill_creator = cx.new(|cx| { + SkillCreator::new( + workspace, + language_registry, + fs, + http_client, + on_saved, + window, + cx, + ) + }); + skill_creator.update(cx, |this, cx| { + this.apply_open_mode(open_mode, window, cx); + }); + skill_creator + }, + ) + }) + }) +} + +pub struct SkillCreator { + focus_handle: FocusHandle, + title_bar: Option>, + workspace: Option>, + fs: Arc, + http_client: Arc, + on_saved: Option>, + url_editor: Entity, + name_editor: Entity, + description_editor: Entity, + body_editor: Entity, + description_length: usize, + scopes: Vec, + selected_scope_key: SharedString, + disable_model_invocation: bool, + name_error: Option<&'static str>, + description_error: Option<&'static str>, + body_error: Option<&'static str>, + save_error: Option, + url_import_status: UrlImportStatus, + saving: bool, + // Held so that dropping the entity (e.g. the window closing) cancels + // an in-flight save. Detaching the task instead would let + // `write_skill_to_disk` complete after the UI is gone, silently + // creating a SKILL.md on disk with no toast and no error feedback. + save_task: Option>, + // Held so replacing it or switching back to the form cancels a pending debounced import. + url_import_debounce_task: Option>, + // Held so replacing it or switching back to the form cancels an in-flight import. + url_import_task: Option>, + scroll_handle: ScrollHandle, + cancel_button_focus_handle: FocusHandle, + save_button_focus_handle: FocusHandle, + _subscriptions: Vec, +} + +impl SkillCreator { + fn new( + workspace: Option>, + language_registry: Arc, + fs: Arc, + http_client: Arc, + on_saved: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + let project_scopes = project_scopes_from_workspace(&workspace, cx); + + // Default to first project scope (project-level) when available; + // otherwise fall back to Global. + let mut scopes: Vec = Vec::with_capacity(project_scopes.len() + 1); + scopes.push(ScopeChoice::Global); + scopes.extend(project_scopes); + let selected_scope_key = scopes + .iter() + .find(|scope| matches!(scope, ScopeChoice::Project { .. })) + .map(|scope| scope.key()) + .unwrap_or_else(|| ScopeChoice::Global.key()); + + let url_editor = cx.new(|cx| { + InputField::new( + window, + cx, + "https://github.com/owner/repo/blob/main/path/to/SKILL.md", + ) + .tab_index(URL_FIELD_TAB_INDEX) + .tab_stop(true) + }); + + let name_editor = cx.new(|cx| { + InputField::new(window, cx, "my-new-skill") + .label("Name") + .tab_index(NAME_FIELD_TAB_INDEX) + .tab_stop(true) + }); + // Focus the name field on open. Without this, no element inside + // the window has focus, so dispatching the `Cancel` action from + // the Cancel button (which walks the focused element's dispatch + // path looking for `on_action` handlers) silently does nothing + // until the user manually clicks into one of the editors. The + // name editor is also the natural first field to type into. + window.focus(&name_editor.focus_handle(cx), cx); + + let description_editor = cx.new(|cx| { + InputField::new( + window, + cx, + "e.g., Fill the PR description following this template.", + ) + .label("Description") + .tab_index(DESCRIPTION_FIELD_TAB_INDEX) + .tab_stop(true) + }); + + let body_editor = cx.new(|cx| { + let buffer = cx.new(|cx| { + let buffer = Buffer::local(String::new(), cx); + buffer.set_language_registry(language_registry.clone()); + buffer + }); + let mut editor = Editor::for_buffer(buffer, None, window, cx); + editor.set_placeholder_text("Add skill content…", window, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor.set_show_gutter(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_use_modal_editing(true); + editor.set_current_line_highlight(Some(CurrentLineHighlight::None)); + editor + }); + + // Attach Markdown language to the body editor asynchronously, since + // `language_for_name` returns a Task. + cx.spawn_in(window, { + let body_editor = body_editor.downgrade(); + let language_registry = language_registry.clone(); + async move |_this, cx| { + let markdown = language_registry.language_for_name("Markdown").await.ok(); + if let Some(markdown) = markdown { + body_editor + .update(cx, |editor, cx| { + editor.buffer().update(cx, |multi_buffer, cx| { + if let Some(buffer) = multi_buffer.as_singleton() { + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown), cx) + }); + } + }); + }) + .ok(); + } + } + }) + .detach(); + + let url_input_editor = url_editor.read(cx).editor().clone(); + let name_input_editor = name_editor.read(cx).editor().clone(); + let description_input_editor = description_editor.read(cx).editor().clone(); + let weak = cx.weak_entity(); + let url_subscription = url_input_editor.subscribe( + Box::new(move |event, window, cx| { + weak.update(cx, |this, cx| { + this.handle_url_input_event(&event, window, cx); + }) + .ok(); + }), + window, + cx, + ); + let weak = cx.weak_entity(); + let name_subscription = name_input_editor.subscribe( + Box::new(move |event, window, cx| { + weak.update(cx, |this, cx| { + this.handle_name_input_event(&event, window, cx); + }) + .ok(); + }), + window, + cx, + ); + let weak = cx.weak_entity(); + let description_subscription = description_input_editor.subscribe( + Box::new(move |event, window, cx| { + weak.update(cx, |this, cx| { + this.handle_description_input_event(&event, window, cx); + }) + .ok(); + }), + window, + cx, + ); + + let subscriptions = vec![ + url_subscription, + name_subscription, + description_subscription, + cx.subscribe_in(&body_editor, window, Self::handle_body_editor_event), + ]; + + Self { + focus_handle, + title_bar: if !cfg!(target_os = "macos") { + Some(cx.new(|cx| PlatformTitleBar::new("skill-creator-title-bar", cx))) + } else { + None + }, + workspace, + fs, + http_client, + on_saved, + url_editor, + name_editor, + description_editor, + body_editor, + description_length: 0, + scopes, + selected_scope_key, + disable_model_invocation: false, + name_error: None, + description_error: None, + body_error: None, + save_error: None, + url_import_status: UrlImportStatus::Idle, + saving: false, + save_task: None, + url_import_debounce_task: None, + url_import_task: None, + scroll_handle: ScrollHandle::new(), + cancel_button_focus_handle: cx.focus_handle(), + save_button_focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + } + } + + fn handle_url_input_event( + &mut self, + event: &ErasedEditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + if !matches!(event, ErasedEditorEvent::BufferEdited) { + return; + } + + // Convention from `thread_view::handle_title_editor_event` and + // `agent_panel::handle_terminal_title_editor_event`: programmatic + // `set_text` is performed while the editor is unfocused, so the + // focus check filters synthesized `BufferEdited` events out of + // the user-edit path without needing a one-shot suppression flag. + if !self.url_editor.focus_handle(cx).is_focused(window) { + return; + } + + self.save_error = None; + self.schedule_url_import(window, cx); + } + + fn handle_name_input_event( + &mut self, + event: &ErasedEditorEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if matches!(event, ErasedEditorEvent::BufferEdited) { + self.recompute_name_error(cx); + self.save_error = None; + cx.notify(); + } + } + + fn handle_description_input_event( + &mut self, + event: &ErasedEditorEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if matches!(event, ErasedEditorEvent::BufferEdited) { + self.recompute_description_error(cx); + self.save_error = None; + cx.notify(); + } + } + + fn handle_body_editor_event( + &mut self, + _: &Entity, + event: &EditorEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if matches!(event, EditorEvent::BufferEdited) { + self.recompute_body_error(cx); + self.save_error = None; + cx.notify(); + } + } + + fn current_name(&self, cx: &App) -> String { + self.name_editor.read(cx).text(cx) + } + + fn current_description(&self, cx: &App) -> String { + self.description_editor.read(cx).text(cx) + } + + fn current_body(&self, cx: &App) -> String { + self.body_editor.read(cx).text(cx) + } + + fn current_url(&self, cx: &App) -> String { + self.url_editor.read(cx).text(cx) + } + + fn recompute_name_error(&mut self, cx: &App) { + let name = self.current_name(cx); + self.name_error = validate_name(&name).err(); + } + + fn recompute_description_error(&mut self, cx: &App) { + let description = self.current_description(cx); + self.description_length = description.len(); + self.description_error = validate_description(&description).err(); + } + + fn recompute_body_error(&mut self, cx: &App) { + let body = self.current_body(cx); + self.body_error = if body.trim().is_empty() { + Some("Body is required.") + } else { + None + }; + } + + fn is_valid(&self, cx: &App) -> bool { + validate_name(&self.current_name(cx)).is_ok() + && validate_description(&self.current_description(cx)).is_ok() + && !self.current_body(cx).trim().is_empty() + && self.selected_scope().is_some() + } + + fn selected_scope(&self) -> Option<&ScopeChoice> { + self.scopes + .iter() + .find(|scope| scope.key() == self.selected_scope_key) + } + + fn apply_open_mode( + &mut self, + open_mode: SkillCreatorOpenMode, + window: &mut Window, + cx: &mut Context, + ) { + match open_mode { + SkillCreatorOpenMode::Form => {} + SkillCreatorOpenMode::Url { initial_url } => { + self.open_url_import(initial_url, window, cx); + } + SkillCreatorOpenMode::Install { content } => { + self.open_install_review(content, window, cx); + } + } + } + + /// Pre-fill the form with a skill supplied inline (from a share link) so + /// the recipient can review it before saving. Unlike URL import, this + /// doesn't touch the URL editor or perform any network request. + fn open_install_review( + &mut self, + content: String, + window: &mut Window, + cx: &mut Context, + ) { + self.url_import_debounce_task = None; + self.url_import_task = None; + self.url_import_status = UrlImportStatus::Idle; + + match parse_imported_skill(&content, "") { + Ok(imported) => self.apply_imported_skill(imported, window, cx), + Err(err) => { + self.save_error = Some(SharedString::from(format!( + "Couldn't read shared skill: {err}" + ))); + cx.notify(); + } + } + } + + fn open_url_import( + &mut self, + initial_url: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.save_error = None; + self.url_import_debounce_task = None; + self.url_import_task = None; + self.url_import_status = UrlImportStatus::Idle; + + let text = initial_url.unwrap_or_default(); + let should_fetch = !text.is_empty(); + let needs_set_text = should_fetch || !self.current_url(cx).is_empty(); + if !needs_set_text { + // No text to write and nothing to clear: just move focus. + window.focus(&self.url_editor.focus_handle(cx), cx); + cx.notify(); + return; + } + + // Defer so the programmatic `set_text` runs before we move focus + // to the URL editor. `handle_url_input_event` uses + // `url_editor.is_focused(window)` to distinguish user edits from + // programmatic ones, so writing while unfocused is what keeps the + // synthesized `BufferEdited` from being treated as a user edit. + let skill_creator = cx.weak_entity(); + let url_editor = self.url_editor.clone(); + window.defer(cx, move |window, cx| { + url_editor.update(cx, |input, cx| { + input.set_text(&text, window, cx); + }); + window.focus(&url_editor.focus_handle(cx), cx); + if should_fetch { + skill_creator + .update(cx, |this, cx| { + this.start_url_import(window, cx); + }) + .log_err(); + } + }); + cx.notify(); + } + + fn schedule_url_import(&mut self, window: &mut Window, cx: &mut Context) { + self.url_import_debounce_task = None; + self.url_import_task = None; + + let url = self.current_url(cx).trim().to_string(); + if url.is_empty() { + self.url_import_status = UrlImportStatus::Idle; + cx.notify(); + return; + } + + self.url_import_status = UrlImportStatus::Idle; + let task = cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(URL_IMPORT_DEBOUNCE).await; + this.update_in(cx, |this, window, cx| { + this.start_url_import(window, cx); + }) + .log_err(); + }); + self.url_import_debounce_task = Some(task); + cx.notify(); + } + + fn start_url_import(&mut self, window: &mut Window, cx: &mut Context) { + // Cancel any pending debounce so the explicit start supersedes it, + // instead of racing with a timer that's about to fire. + self.url_import_debounce_task = None; + self.url_import_task = None; + + let url = self.current_url(cx).trim().to_string(); + if url.is_empty() { + self.url_import_status = UrlImportStatus::Idle; + cx.notify(); + return; + } + + if let Err(err) = github_raw_url(&url) { + self.url_import_status = UrlImportStatus::Error(SharedString::from(err.to_string())); + cx.notify(); + return; + } + + self.url_import_status = UrlImportStatus::Fetching; + let http_client = self.http_client.clone(); + let fetch_task = cx.background_spawn(fetch_imported_skill_from_url(http_client, url)); + let task = cx.spawn_in(window, async move |this, cx| { + let result = fetch_task.await; + this.update_in(cx, |this, window, cx| { + this.url_import_debounce_task = None; + this.url_import_task = None; + match result { + Ok(imported) => { + this.apply_imported_skill(imported, window, cx); + } + Err(err) => { + this.url_import_status = + UrlImportStatus::Error(SharedString::from(err.to_string())); + cx.notify(); + } + } + }) + .log_err(); + }); + self.url_import_task = Some(task); + cx.notify(); + } + + /// Populate the form fields from a parsed skill (shared by URL import and + /// share-link install). Deferred so the programmatic `set_text` calls run + /// before focus moves to the name field. + fn apply_imported_skill( + &mut self, + imported: ImportedSkill, + window: &mut Window, + cx: &mut Context, + ) { + self.url_import_status = UrlImportStatus::Idle; + self.save_error = None; + + let name_editor = self.name_editor.clone(); + let description_editor = self.description_editor.clone(); + let body_editor = self.body_editor.clone(); + let skill_creator = cx.weak_entity(); + window.defer(cx, move |window, cx| { + name_editor.update(cx, |input, cx| { + input.set_text(&imported.name, window, cx); + }); + description_editor.update(cx, |input, cx| { + input.set_text(&imported.description, window, cx); + }); + body_editor.update(cx, |editor, cx| { + editor.set_text(imported.body.clone(), window, cx); + }); + skill_creator + .update(cx, |this, cx| { + this.disable_model_invocation = imported.disable_model_invocation; + this.url_import_status = UrlImportStatus::Idle; + this.url_import_debounce_task = None; + this.url_import_task = None; + this.save_error = None; + this.recompute_name_error(cx); + this.recompute_description_error(cx); + this.recompute_body_error(cx); + cx.notify(); + }) + .log_err(); + window.focus(&name_editor.focus_handle(cx), cx); + }); + } + + fn save_skill(&mut self, _: &SaveSkill, window: &mut Window, cx: &mut Context) { + // Surface any field-level errors before attempting to save. + self.recompute_name_error(cx); + self.recompute_description_error(cx); + self.recompute_body_error(cx); + + if !self.is_valid(cx) || self.saving { + cx.notify(); + return; + } + + let Some(scope) = self.selected_scope().cloned() else { + self.save_error = Some("Select a scope to save this skill to.".into()); + cx.notify(); + return; + }; + + let name = self.current_name(cx); + let description = self.current_description(cx); + let body = self.current_body(cx); + let disable_model_invocation = self.disable_model_invocation; + let fs = self.fs.clone(); + let workspace = self.workspace.clone(); + let scope_description: SharedString = match &scope { + ScopeChoice::Global => "your global skills".into(), + ScopeChoice::Project { root_name, .. } => root_name.clone(), + }; + + self.saving = true; + self.save_error = None; + cx.notify(); + + let task = cx.spawn_in(window, async move |this, cx| { + let result = write_skill_to_disk( + fs.as_ref(), + &scope.skills_dir(), + &name, + &description, + &body, + disable_model_invocation, + ) + .await; + + this.update_in(cx, |this, window, cx| { + this.saving = false; + this.save_task = None; + match result { + Ok(_) => { + if let Some(on_saved) = &this.on_saved { + on_saved(cx); + } + if let Some(workspace) = workspace.as_ref().and_then(|w| w.upgrade()) { + workspace.update(cx, |workspace, cx| { + let message = + format!("Saved skill \"{name}\" to {scope_description}"); + let status_toast = StatusToast::new(message, cx, |this, _cx| { + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + .dismiss_button(true) + }); + workspace.toggle_status_toast(status_toast, cx); + }); + } + window.remove_window(); + } + Err(err) => { + this.save_error = Some(SharedString::from(err.to_string())); + cx.notify(); + } + } + }) + .log_err(); + }); + self.save_task = Some(task); + } + + fn cancel(&mut self, _: &Cancel, window: &mut Window, _cx: &mut Context) { + // Block dismissal while a save is in flight. Otherwise the + // detached I/O could complete after the window is gone, leaving + // a SKILL.md on disk with no success or error feedback. The + // user can still force-close the window via the platform + // chrome, in which case dropping `self.save_task` cancels the + // pending write. + if self.saving { + return; + } + window.remove_window(); + } + + fn select_scope(&mut self, key: SharedString, cx: &mut Context) { + if self.scopes.iter().any(|scope| scope.key() == key) { + self.selected_scope_key = key; + self.save_error = None; + cx.notify(); + } + } + + fn toggle_disable_model_invocation(&mut self, cx: &mut Context) { + self.disable_model_invocation = !self.disable_model_invocation; + cx.notify(); + } + + fn render_url_import(&self) -> impl IntoElement { + v_flex() + .flex_shrink_0() + .gap_2() + .child( + h_flex() + .gap_1() + .child(Label::new("Import from URL")) + .child(Label::new("(optional)").color(Color::Muted)), + ) + .child(self.url_editor.clone()) + .child(match &self.url_import_status { + UrlImportStatus::Idle => Label::new( + "Paste a GitHub .md URL. Zed will fetch it and fill out the skill form.", + ) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element(), + UrlImportStatus::Fetching => { + LoadingLabel::new("Fetching and parsing…").into_any_element() + } + UrlImportStatus::Error(error) => h_flex() + .gap_1() + .child( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ) + .child( + Label::new(error.clone()) + .size(LabelSize::Small) + .color(Color::Error), + ) + .into_any_element(), + }) + } + + fn render_form_fields(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // `flex_grow` lets the form fields absorb extra vertical space when + // the window is tall; `flex_shrink_0` keeps them at their natural + // (content + body min-height) size when the window is short, which + // causes the surrounding scroll container to start scrolling rather + // than squeezing the body editor below its minimum height. + v_flex() + .id("skill-creator-form-fields") + .flex_grow() + .flex_shrink_0() + .gap_4() + .child( + v_flex() + .gap_2() + .child(Label::new("Front-matter")) + .child(self.name_editor.clone()) + .child(self.description_editor.clone()), + ) + .child(self.render_optional_params(cx)) + .child(Divider::horizontal()) + .child(self.render_scope_field(window, cx)) + .child(Divider::horizontal()) + .child( + v_flex() + .flex_grow() + .flex_shrink_0() + .gap_2() + .child(Label::new("Skill Content")) + .child(self.render_body_field(window, cx)), + ) + } + + fn render_scope_field(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let scopes = self.scopes.clone(); + let selected = self.selected_scope().cloned(); + let selected_label: SharedString = match selected.as_ref() { + Some(ScopeChoice::Global) => "Global".into(), + Some(ScopeChoice::Project { root_name, .. }) => { + SharedString::from(format!("{root_name} (project)")) + } + None => "Select a scope\u{2026}".into(), + }; + let sep = std::path::MAIN_SEPARATOR; + let scope_hint: SharedString = match selected.as_ref() { + Some(ScopeChoice::Global) => SharedString::from(format!( + "Available across every Zed project. \ + Saved to {GLOBAL_SKILLS_DIR_DISPLAY}{sep}\u{2039}name\u{203A}{sep}{SKILL_FILE_NAME}." + )), + Some(ScopeChoice::Project { root_name, .. }) => SharedString::from(format!( + "Only available when this project is open. \ + Saved to {root_name}{sep}{AGENTS_DIR_NAME}{sep}{SKILLS_DIR_NAME}{sep}\u{2039}name\u{203A}{sep}{SKILL_FILE_NAME}." + )), + None => "Choose where this skill should live.".into(), + }; + + let selected_label = h_flex() + .child(Label::new(selected_label)) + .into_any_element(); + + let weak = cx.weak_entity(); + + let menu = ContextMenu::build(window, cx, move |mut menu, _window, _cx| { + for scope in &scopes { + let key = scope.key(); + let weak = weak.clone(); + let entry_label: SharedString = match scope { + ScopeChoice::Global => "Global".into(), + ScopeChoice::Project { root_name, .. } => { + SharedString::from(format!("{root_name} (project)")) + } + }; + menu = menu.entry(entry_label, None, move |_window, cx| { + weak.update(cx, |this, cx| { + this.select_scope(key.clone(), cx); + }) + .log_err(); + }); + } + menu + }); + + h_flex() + .min_w_0() + .w_full() + .gap_6() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .child(Label::new("Scope")) + .child(Label::new(scope_hint).color(Color::Muted)), + ) + .child( + DropdownMenu::new_with_element("skill-scope-dropdown", selected_label, menu) + .tab_index(SCOPE_FIELD_TAB_INDEX) + .style(DropdownStyle::Outlined) + .trigger_size(ButtonSize::Medium) + .full_width(false), + ) + } + + fn render_optional_params(&self, cx: &mut Context) -> impl IntoElement { + let toggle_state: ToggleState = self.disable_model_invocation.into(); + + SwitchField::new( + "disable-model-invocation", + Some("Disable model invocation"), + Some( + "Hide this skill from the model's catalog. It can still be invoked via slash command." + .into(), + ), + toggle_state, + cx.listener(|this, _state: &ToggleState, _window, cx| { + this.toggle_disable_model_invocation(cx); + }), + ) + .tab_index(DISABLE_MODEL_INVOCATION_TAB_INDEX) + .into_any_element() + } + + fn render_body_field(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let theme = cx.theme().clone(); + + let has_error = self.body_error.is_some(); + + let focus_handle = self + .body_editor + .focus_handle(cx) + .tab_index(BODY_FIELD_TAB_INDEX) + .tab_stop(true); + + let border_color = if has_error { + theme.status().error_border + } else if focus_handle.contains_focused(window, cx) { + theme.colors().border_focused + } else { + theme.colors().border + }; + + div() + .w_full() + .flex_1() + .min_h(px(160.)) + .p_2p5() + .rounded_md() + .border_1() + .border_color(border_color) + .bg(theme.colors().editor_background) + .track_focus(&focus_handle) + .overflow_hidden() + .child(EditorElement::new( + &self.body_editor, + EditorStyle { + local_player: theme.players().local(), + text: TextStyle { + color: theme.colors().text, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: rems(0.875).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + syntax: theme.syntax().clone(), + inlay_hints_style: editor::make_inlay_hints_style(cx), + edit_prediction_styles: editor::make_suggestion_styles(cx), + ..EditorStyle::default() + }, + )) + } + + fn render_footer(&self, window: &Window, cx: &mut Context) -> impl IntoElement { + let valid = self.is_valid(cx); + let saving = self.saving; + let main_action = if saving { "Saving…" } else { "Save Skill" }; + + // Draw a faint outline around whichever button currently holds + // keyboard focus, so tabbing to Cancel/Save is clearly visible. The + // ring border is always present (transparent when unfocused) so + // focusing a button never shifts the surrounding layout. + let focus_ring = |focus_handle: &FocusHandle| { + let focused = focus_handle.is_focused(window) && window.last_input_was_keyboard(); + let border_color = if focused { + cx.theme().colors().border_focused + } else { + cx.theme().colors().border_transparent + }; + div().rounded_sm().border_1().border_color(border_color) + }; + + v_flex() + .w_full() + .p_2p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + .when(self.save_error.is_some(), |this| { + this.gap_2().child( + Banner::new() + .severity(Severity::Error) + .children(self.save_error.clone().map(|err| Label::new(err))), + ) + }) + .child( + h_flex() + .w_full() + .gap_1() + .justify_end() + .child( + focus_ring(&self.cancel_button_focus_handle).child( + Button::new("cancel-skill", "Cancel") + .track_focus( + &self + .cancel_button_focus_handle + .clone() + .tab_index(CANCEL_BUTTON_TAB_INDEX) + .tab_stop(true), + ) + .disabled(saving) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Cancel), cx); + }), + ), + ) + .child( + focus_ring(&self.save_button_focus_handle).child( + Button::new("save-skill", main_action) + .track_focus( + &self + .save_button_focus_handle + .clone() + .tab_index(SAVE_BUTTON_TAB_INDEX) + .tab_stop(true), + ) + .style(ButtonStyle::Filled) + .layer(ui::ElevationIndex::ModalSurface) + .disabled(!valid || saving) + .loading(saving) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(SaveSkill), cx); + }), + ), + ), + ) + } + + fn render_header(&self, _window: &Window, cx: &mut Context) -> impl IntoElement { + let needs_traffic_light_clearance = cfg!(target_os = "macos"); + + h_flex() + .w_full() + .h_11() + .px_4() + .when(needs_traffic_light_clearance, |this| this.pl(px(84.))) + .border_b_1() + .border_color(cx.theme().colors().border) + .child(Headline::new("Skill Creator").size(HeadlineSize::XSmall)) + } + + fn focus_next_field( + &mut self, + _: &FocusNextField, + window: &mut Window, + cx: &mut Context, + ) { + window.focus_next(cx); + } + + fn focus_previous_field( + &mut self, + _: &FocusPreviousField, + window: &mut Window, + cx: &mut Context, + ) { + window.focus_prev(cx); + } + + // When focus is on a non-editor tab stop (dropdown button, switch), + // Tab dispatches the global `menu::SelectNext` rather than our + // custom `FocusNextField`. Catching it here keeps the cycle moving. + fn on_menu_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); + } + + fn on_menu_prev( + &mut self, + _: &menu::SelectPrevious, + window: &mut Window, + cx: &mut Context, + ) { + window.focus_prev(cx); + } +} + +impl Focusable for SkillCreator { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for SkillCreator { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font = theme_settings::setup_ui_font(window, cx); + let theme = cx.theme().clone(); + + client_side_decorations( + v_flex() + .id("skill-creator") + .key_context("SkillCreator") + .track_focus(&self.focus_handle) + .on_action( + |action_sequence: &ActionSequence, window: &mut Window, cx: &mut App| { + for action in &action_sequence.0 { + window.dispatch_action(action.boxed_clone(), cx); + } + }, + ) + .on_action(cx.listener(Self::save_skill)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::focus_next_field)) + .on_action(cx.listener(Self::focus_previous_field)) + .on_action(cx.listener(Self::on_menu_next)) + .on_action(cx.listener(Self::on_menu_prev)) + .size_full() + .overflow_hidden() + .font(ui_font) + .text_color(theme.colors().text) + .bg(theme.colors().panel_background) + .children(self.title_bar.clone()) + .child(self.render_header(window, cx)) + .child( + div() + .flex_1() + .min_h_0() + .w_full() + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + .child( + v_flex() + .id("skill-creator-form") + .tab_index(0) + .tab_group() + .tab_stop(false) + .size_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .gap_4() + .p_4() + .child(self.render_url_import()) + .child(Divider::horizontal()) + .child(self.render_form_fields(window, cx)), + ), + ) + .child(self.render_footer(window, cx)), + window, + cx, + Tiling::default(), + ) + } +} + +async fn fetch_imported_skill_from_url( + http_client: Arc, + url: String, +) -> Result { + let github_token = std::env::var("GITHUB_TOKEN").ok().and_then(|token| { + let token = token.trim().to_string(); + (!token.is_empty()).then_some(token) + }); + fetch_imported_skill_from_url_with_github_token(http_client, url, github_token).await +} + +async fn fetch_imported_skill_from_url_with_github_token( + http_client: Arc, + url: String, + github_token: Option, +) -> Result { + let raw_url = github_raw_url(&url)?; + let (mut status, mut body) = + fetch_skill_url(http_client.clone(), raw_url.as_str(), None).await?; + + if status == StatusCode::NOT_FOUND + && let Some(github_token) = github_token.as_deref() + { + (status, body) = fetch_skill_url(http_client, raw_url.as_str(), Some(github_token)).await?; + } + + if !status.is_success() { + return Err(github_fetch_error(status, &body)); + } + + if body.len() > MAX_SKILL_FILE_SIZE { + anyhow::bail!( + "SKILL.md file exceeds maximum size of {}KB", + MAX_SKILL_FILE_SIZE / 1024 + ); + } + + let content = String::from_utf8(body).context("GitHub response was not valid UTF-8")?; + parse_imported_skill(&content, raw_url.as_str()) +} + +async fn fetch_skill_url( + http_client: Arc, + raw_url: &str, + github_token: Option<&str>, +) -> Result<(StatusCode, Vec)> { + let request = Request::get(raw_url) + .follow_redirects(http_client::RedirectPolicy::FollowAll) + .when_some(github_token, |builder, token| { + builder.header("Authorization", format!("Bearer {token}")) + }) + .body(AsyncBody::default())?; + + let mut response = http_client + .send(request) + .await + .with_context(|| format!("failed to fetch {raw_url}"))?; + + let status = response.status(); + let mut body = Vec::new(); + response + .body_mut() + .take(MAX_SKILL_FILE_SIZE as u64 + 1) + .read_to_end(&mut body) + .await + .context("failed to read response body")?; + + Ok((status, body)) +} + +fn github_fetch_error(status: StatusCode, body: &[u8]) -> anyhow::Error { + let mut message = if status == StatusCode::NOT_FOUND { + "GitHub returned 404 while fetching the skill; no repository exists at this URL, or it is private" + .to_string() + } else { + format!( + "GitHub returned {} while fetching the skill", + status.as_u16() + ) + }; + + let response_text = truncated_response_body_for_error(body); + if !response_text.is_empty() { + message.push_str(": "); + message.push_str(&response_text); + } + + anyhow!(message) +} + +pub fn is_supported_skill_url(input: &str) -> bool { + github_raw_url(input).is_ok() +} + +fn github_raw_url(input: &str) -> Result { + let url = Url::parse(input.trim()).context("Enter a valid GitHub URL")?; + if url.scheme() != "https" { + anyhow::bail!("GitHub skill URLs must use https://"); + } + + let host = url + .host_str() + .ok_or_else(|| anyhow!("Enter a valid GitHub URL"))?; + let path_segments = url + .path_segments() + .ok_or_else(|| anyhow!("Enter a valid GitHub URL"))? + .collect::>(); + + match host { + "github.com" => github_blob_raw_url(&path_segments), + "raw.githubusercontent.com" => { + ensure_markdown_path(&path_segments)?; + Ok(url.into()) + } + _ => anyhow::bail!("Paste a GitHub .md URL"), + } +} + +fn github_blob_raw_url(path_segments: &[&str]) -> Result { + let [owner, repo, kind, reference, file_path @ ..] = path_segments else { + anyhow::bail!("Paste a GitHub blob URL that points to a .md file"); + }; + + if *kind != "blob" { + anyhow::bail!("Paste a GitHub blob URL that points to a .md file"); + } + + ensure_markdown_path(file_path)?; + Ok(format!( + "https://raw.githubusercontent.com/{owner}/{repo}/{reference}/{}", + file_path.join("/") + )) +} + +fn ensure_markdown_path(path_segments: &[&str]) -> Result<()> { + let Some(file_name) = path_segments.last() else { + anyhow::bail!("Paste a GitHub .md URL"); + }; + + if !file_name.to_ascii_lowercase().ends_with(".md") { + anyhow::bail!("Paste a GitHub URL that points to a .md file"); + } + + Ok(()) +} + +fn parse_imported_skill(content: &str, source_url: &str) -> Result { + if content.trim_start().starts_with("---") { + let (metadata, body) = parse_skill_file_content(content)?; + return Ok(ImportedSkill { + name: metadata.name, + description: metadata.description, + body: body.trim().to_string(), + disable_model_invocation: metadata.disable_model_invocation, + }); + } + + Ok(ImportedSkill { + name: derived_skill_name_from_url(source_url).unwrap_or_else(|| "imported-skill".into()), + description: derived_description_from_markdown(content).unwrap_or_default(), + body: content.trim().to_string(), + disable_model_invocation: false, + }) +} + +fn derived_skill_name_from_url(source_url: &str) -> Option { + let url = Url::parse(source_url).ok()?; + let file_name = url.path_segments()?.next_back()?; + let stem = file_name + .rsplit_once('.') + .and_then(|(stem, extension)| extension.eq_ignore_ascii_case("md").then_some(stem)) + .unwrap_or(file_name); + slugify_skill_name(stem) +} + +fn truncated_response_body_for_error(body: &[u8]) -> String { + let text = String::from_utf8_lossy(body); + let text = text.trim(); + if text.len() <= URL_IMPORT_ERROR_BODY_MAX_LEN { + return text.to_string(); + } + + let mut end = URL_IMPORT_ERROR_BODY_MAX_LEN; + while !text.is_char_boundary(end) { + end -= 1; + } + format!("{}…", text[..end].trim_end()) +} + +fn derived_description_from_markdown(content: &str) -> Option { + content.lines().find_map(|line| { + let line = line.trim(); + if line.is_empty() || line == "---" { + return None; + } + + let text = line.trim_start_matches('#').trim(); + if text.is_empty() { + None + } else { + Some(truncate_description(text)) + } + }) +} + +fn truncate_description(description: &str) -> String { + if description.len() <= MAX_SKILL_DESCRIPTION_LEN { + return description.to_string(); + } + + let mut end = MAX_SKILL_DESCRIPTION_LEN; + while !description.is_char_boundary(end) { + end -= 1; + } + description[..end].trim().to_string() +} + +/// Serialize the SKILL.md file to disk at `//SKILL.md`. +/// +/// Refuses to overwrite an existing directory at `/`. The +/// caller surfaces the resulting error to the user, who picks a different +/// name. +async fn write_skill_to_disk( + fs: &dyn Fs, + skills_dir: &std::path::Path, + name: &str, + description: &str, + body: &str, + disable_model_invocation: bool, +) -> Result { + let skill_dir = skills_dir.join(name); + match fs.metadata(&skill_dir).await { + Ok(Some(metadata)) if metadata.is_dir => { + anyhow::bail!( + "A skill named \"{name}\" already exists at {}. Pick a different name.", + skill_dir.display() + ); + } + Ok(Some(_)) => { + // Something exists at this path, but it isn't a directory — e.g. + // a stray file the user (or another tool) left there. Without + // this branch we'd fall through to `create_dir`, which on the + // real fs returns a generic "File exists" IO error that gives + // the user no idea what's wrong or how to recover. + anyhow::bail!( + "A file (not a skill directory) already exists at {}. \ + Delete it or pick a different skill name.", + skill_dir.display() + ); + } + Ok(None) => {} + Err(err) => { + return Err(err).with_context(|| { + format!( + "failed to check whether {} already exists", + skill_dir.display() + ) + }); + } + } + + let content = format_skill_file(name, description, body, disable_model_invocation)?; + + fs.create_dir(&skill_dir) + .await + .with_context(|| format!("failed to create skill directory {}", skill_dir.display()))?; + let skill_file_path = skill_dir.join(SKILL_FILE_NAME); + fs.write(&skill_file_path, content.as_bytes()) + .await + .with_context(|| format!("failed to write {}", skill_file_path.display()))?; + + Ok(skill_file_path) +} + +fn format_skill_file( + name: &str, + description: &str, + body: &str, + disable_model_invocation: bool, +) -> Result { + let metadata = SkillMetadata { + name: name.to_string(), + description: description.to_string(), + disable_model_invocation, + }; + let frontmatter = serde_yaml_ng::to_string(&metadata) + .context("failed to serialize skill frontmatter as YAML")?; + + let mut content = String::with_capacity(frontmatter.len() + body.len() + 16); + content.push_str("---\n"); + content.push_str(&frontmatter); + content.push_str("---\n"); + let trimmed_body = body.trim(); + if !trimmed_body.is_empty() { + content.push('\n'); + content.push_str(trimmed_body); + content.push('\n'); + } + Ok(content) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_skills::{SkillSource, parse_skill_frontmatter}; + use fs::FakeFs; + use std::{ + collections::VecDeque, + io, + path::Path, + pin::Pin, + sync::{Arc, Mutex}, + task::{self, Poll}, + }; + + struct TestHttpClient { + responses: Mutex>, + authorization_headers: Mutex>>, + } + + impl TestHttpClient { + fn new(status: u16, body: AsyncBody) -> Arc { + Self::new_sequence(vec![(status, body)]) + } + + fn new_sequence(responses: Vec<(u16, AsyncBody)>) -> Arc { + Arc::new(Self { + responses: Mutex::new( + responses + .into_iter() + .map(|(status, body)| { + ( + StatusCode::from_u16(status) + .expect("test status code should be valid"), + body, + ) + }) + .collect(), + ), + authorization_headers: Mutex::new(Vec::new()), + }) + } + + fn authorization_headers(&self) -> Vec> { + self.authorization_headers + .lock() + .expect("authorization header mutex should not be poisoned") + .clone() + } + } + + impl HttpClient for TestHttpClient { + fn user_agent(&self) -> Option<&http_client::http::HeaderValue> { + None + } + + fn proxy(&self) -> Option<&Url> { + None + } + + fn send( + &self, + req: http_client::Request, + ) -> futures::future::BoxFuture<'static, Result>> { + let authorization_header = req + .headers() + .get("Authorization") + .and_then(|header| header.to_str().ok()) + .map(ToString::to_string); + + match self.authorization_headers.lock() { + Ok(mut authorization_headers) => authorization_headers.push(authorization_header), + Err(_) => { + return Box::pin(async { + Err(anyhow::anyhow!( + "test authorization header mutex was poisoned" + )) + }); + } + } + + let response = match self.responses.lock() { + Ok(mut responses) => responses.pop_front(), + Err(_) => { + return Box::pin(async { + Err(anyhow::anyhow!("test response body mutex was poisoned")) + }); + } + }; + let Some((status, body)) = response else { + return Box::pin(async { + Err(anyhow::anyhow!("test response body was already consumed")) + }); + }; + + Box::pin(async move { + http_client::Response::builder() + .status(status) + .body(body) + .map_err(anyhow::Error::new) + }) + } + } + + struct FailsAfterLimitReader { + bytes_read: usize, + limit: usize, + } + + impl futures::AsyncRead for FailsAfterLimitReader { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut task::Context<'_>, + buffer: &mut [u8], + ) -> Poll> { + if self.bytes_read >= self.limit { + return Poll::Ready(Err(io::Error::other("read past limit"))); + } + + let byte_count = buffer.len().min(self.limit - self.bytes_read); + buffer[..byte_count].fill(b'a'); + self.bytes_read += byte_count; + Poll::Ready(Ok(byte_count)) + } + } + + // Name and description validation rules are unit-tested in + // `agent_skills`, which owns `validate_name` / `validate_description` + // / `MAX_SKILL_DESCRIPTION_LEN`. The tests below cover this crate's + // own surface area: SKILL.md formatting and disk-writing. + + #[test] + fn format_skill_file_round_trips_through_parser() { + let content = + format_skill_file("draft-pr", "Push a draft PR", "Do the thing.", false).unwrap(); + let skill = parse_skill_frontmatter( + Path::new("/skills/draft-pr/SKILL.md"), + &content, + SkillSource::Global, + ) + .expect("generated frontmatter must round-trip through parse_skill_frontmatter"); + assert_eq!(skill.name, "draft-pr"); + assert_eq!(skill.description, "Push a draft PR"); + assert!(!skill.disable_model_invocation); + } + + #[test] + fn format_skill_file_writes_disable_model_invocation_true() { + let content = format_skill_file("my-skill", "description", "body", true).unwrap(); + assert!(content.contains("disable-model-invocation: true")); + } + + #[test] + fn format_skill_file_omits_body_when_empty() { + let content = format_skill_file("my-skill", "description", " ", false).unwrap(); + // The trailing closing-delimiter newline is the last byte. + assert!(content.ends_with("---\n")); + } + + #[test] + fn format_skill_file_escapes_yaml_specials_in_description() { + // serde_yaml_ng must quote/escape descriptions that contain YAML + // specials so the file round-trips. If we ever swap formatters, + // this test will catch a regression. + let tricky = "contains: a colon, # a hash, and a \"quote\""; + let content = format_skill_file("weird-skill", tricky, "body", false).unwrap(); + let skill = parse_skill_frontmatter( + Path::new("/skills/weird-skill/SKILL.md"), + &content, + SkillSource::Global, + ) + .expect("YAML-special characters must round-trip"); + assert_eq!(skill.description, tricky); + } + + #[test] + fn github_blob_url_converts_to_raw_url() { + let source_url = "https://github.com/cursor/plugins/blob/3347cbab5b54136f6fba0994c3a01a56f7fb7fca/cursor-team-kit/agents/thermo-nuclear-code-quality-review.md"; + let raw_url = github_raw_url(source_url).expect("GitHub blob URLs should be importable"); + + assert_eq!( + raw_url, + "https://raw.githubusercontent.com/cursor/plugins/3347cbab5b54136f6fba0994c3a01a56f7fb7fca/cursor-team-kit/agents/thermo-nuclear-code-quality-review.md" + ); + assert!(is_supported_skill_url(source_url)); + assert!(!is_supported_skill_url( + "https://example.com/not-a-skill.md" + )); + } + + #[test] + fn derived_skill_name_strips_markdown_extension_case_insensitively() { + let name = derived_skill_name_from_url( + "https://raw.githubusercontent.com/owner/repo/main/README.MD", + ) + .expect("name should be derived from Markdown URL"); + + assert_eq!(name, "readme"); + } + + #[test] + fn parse_imported_skill_reads_frontmatter_and_body() { + let imported = parse_imported_skill( + "---\nname: imported-skill\ndescription: Imported from GitHub.\ndisable-model-invocation: true\n---\n\n# Instructions\n\nDo the thing.\n", + "https://raw.githubusercontent.com/owner/repo/main/imported-skill.md", + ) + .expect("valid skill frontmatter should parse"); + + assert_eq!(imported.name, "imported-skill"); + assert_eq!(imported.description, "Imported from GitHub."); + assert_eq!(imported.body, "# Instructions\n\nDo the thing."); + assert!(imported.disable_model_invocation); + } + + #[test] + fn parse_imported_skill_falls_back_to_markdown_when_frontmatter_is_missing() { + let imported = parse_imported_skill( + "# Code Review\n\nReview code for maintainability.", + "https://raw.githubusercontent.com/owner/repo/main/code-review.md", + ) + .expect("plain markdown should still import"); + + assert_eq!(imported.name, "code-review"); + assert_eq!(imported.description, "Code Review"); + assert_eq!( + imported.body, + "# Code Review\n\nReview code for maintainability." + ); + assert!(!imported.disable_model_invocation); + } + + #[test] + fn parse_imported_skill_reuses_skill_metadata_validation() { + let error = parse_imported_skill( + "---\nname: Imported Skill\ndescription: Imported from GitHub.\n---\n\n# Instructions\n", + "https://raw.githubusercontent.com/owner/repo/main/imported-skill.md", + ) + .expect_err("invalid skill metadata should be rejected instead of imported"); + let message = error.to_string(); + + assert!( + message.contains("Skill name must contain only lowercase letters"), + "error should come from shared skill metadata validation, got: {message}" + ); + } + + #[gpui::test] + async fn fetch_imported_skill_retries_404_with_github_token(_cx: &mut gpui::TestAppContext) { + let client = TestHttpClient::new_sequence(vec![ + (404, AsyncBody::from("Not Found")), + (200, AsyncBody::from("# Imported Skill\n\nDo the thing.")), + ]); + + let imported = fetch_imported_skill_from_url_with_github_token( + client.clone(), + "https://github.com/owner/repo/blob/main/skill.md".to_string(), + Some("secret-token".to_string()), + ) + .await + .expect("private repo fallback should retry with the GitHub token"); + + assert_eq!(imported.name, "skill"); + assert_eq!(imported.description, "Imported Skill"); + assert_eq!( + client.authorization_headers(), + vec![None, Some("Bearer secret-token".to_string())] + ); + } + + #[gpui::test] + async fn fetch_imported_skill_reports_private_or_missing_for_404( + _cx: &mut gpui::TestAppContext, + ) { + let client = TestHttpClient::new_sequence(vec![(404, AsyncBody::from("Not Found"))]); + + let error = fetch_imported_skill_from_url_with_github_token( + client.clone(), + "https://github.com/owner/repo/blob/main/skill.md".to_string(), + None, + ) + .await + .expect_err("404 without a GitHub token should fail"); + let message = error.to_string(); + + assert!( + message.contains("no repository exists at this URL, or it is private"), + "404 error should mention private repositories, got: {message}" + ); + assert_eq!(client.authorization_headers(), vec![None]); + } + + #[gpui::test] + async fn fetch_imported_skill_stops_reading_after_size_limit(_cx: &mut gpui::TestAppContext) { + let client = TestHttpClient::new( + 200, + AsyncBody::from_reader(FailsAfterLimitReader { + bytes_read: 0, + limit: MAX_SKILL_FILE_SIZE + 1, + }), + ); + + let error = fetch_imported_skill_from_url( + client, + "https://github.com/owner/repo/blob/main/skill.md".to_string(), + ) + .await + .expect_err("oversized responses should be rejected"); + let message = error.to_string(); + + assert!( + message.contains("exceeds maximum size"), + "error should report the skill size limit, got: {message}" + ); + assert!( + !message.contains("failed to read response body"), + "reader should not be polled past the limit, got: {message}" + ); + } + + #[gpui::test] + async fn fetch_imported_skill_truncates_error_response_body(_cx: &mut gpui::TestAppContext) { + let body = format!( + "{}tail-that-should-not-appear", + "x".repeat(URL_IMPORT_ERROR_BODY_MAX_LEN + 20) + ); + let client = TestHttpClient::new(500, AsyncBody::from(body)); + + let error = fetch_imported_skill_from_url( + client, + "https://github.com/owner/repo/blob/main/skill.md".to_string(), + ) + .await + .expect_err("non-success responses should be rejected"); + let message = error.to_string(); + + assert!(message.contains("GitHub returned 500")); + assert!( + message.ends_with('…'), + "error body should be visibly truncated, got: {message}" + ); + assert!( + !message.contains("tail-that-should-not-appear"), + "error body should not include the unbounded tail, got: {message}" + ); + } + + #[gpui::test] + async fn write_skill_to_disk_creates_directory_and_file(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/skills", serde_json::json!({})).await; + + let path = write_skill_to_disk( + fs.as_ref(), + Path::new("/skills"), + "draft-pr", + "Push a draft PR", + "Body of the skill.", + false, + ) + .await + .expect("write should succeed"); + + assert_eq!(path, Path::new("/skills/draft-pr/SKILL.md")); + let content = fs.load(&path).await.expect("file should exist"); + let skill = parse_skill_frontmatter(&path, &content, SkillSource::Global) + .expect("written file should be parseable"); + assert_eq!(skill.name, "draft-pr"); + assert_eq!(skill.description, "Push a draft PR"); + } + + #[gpui::test] + async fn write_skill_to_disk_refuses_to_overwrite(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/skills", + serde_json::json!({ + "draft-pr": { + "SKILL.md": "---\nname: draft-pr\ndescription: existing\n---\nbody\n" + } + }), + ) + .await; + + let err = write_skill_to_disk( + fs.as_ref(), + Path::new("/skills"), + "draft-pr", + "Push a draft PR", + "Body of the skill.", + false, + ) + .await + .expect_err("writing over an existing skill must fail"); + assert!( + err.to_string().contains("already exists"), + "error message should mention the conflict, got: {err}" + ); + } + + #[gpui::test] + async fn write_skill_to_disk_rejects_non_directory_at_skill_path( + cx: &mut gpui::TestAppContext, + ) { + let fs = FakeFs::new(cx.executor()); + // A *file* (not a directory) sitting at `/skills/draft-pr`. With the + // old `is_dir` check this slipped through and we ended up surfacing + // the underlying "File exists" OS error. + fs.insert_tree( + "/skills", + serde_json::json!({ "draft-pr": "i am a stray file" }), + ) + .await; + + let err = write_skill_to_disk( + fs.as_ref(), + Path::new("/skills"), + "draft-pr", + "Push a draft PR", + "Body of the skill.", + false, + ) + .await + .expect_err("writing where a file already lives must fail"); + let message = err.to_string(); + assert!( + message.contains("not a skill directory"), + "error should explain the conflict is a non-directory, got: {message}" + ); + // Path separator differs between platforms (`/` on Unix, `\` on + // Windows), so reconstruct the expected `Display` form rather than + // hard-coding a separator. + let expected_path = Path::new("/skills").join("draft-pr"); + let expected_path = expected_path.display().to_string(); + assert!( + message.contains(&expected_path), + "error should include the conflicting path {expected_path:?}, got: {message}" + ); + } +} diff --git a/crates/streaming_diff/Cargo.toml b/crates/streaming_diff/Cargo.toml index b3645a182c3..d3365d5793a 100644 --- a/crates/streaming_diff/Cargo.toml +++ b/crates/streaming_diff/Cargo.toml @@ -16,5 +16,10 @@ ordered-float.workspace = true rope.workspace = true [dev-dependencies] +criterion.workspace = true rand.workspace = true util = { workspace = true, features = ["test-support"] } + +[[bench]] +name = "streaming_diff" +harness = false diff --git a/crates/streaming_diff/benches/streaming_diff.rs b/crates/streaming_diff/benches/streaming_diff.rs new file mode 100644 index 00000000000..cbf83b7557e --- /dev/null +++ b/crates/streaming_diff/benches/streaming_diff.rs @@ -0,0 +1,321 @@ +use criterion::{ + BatchSize, BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main, +}; +use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; +use streaming_diff::StreamingDiff; + +const SEED: u64 = 0x5EED_5EED; +const CHUNK_SIZE: usize = 512; + +#[derive(Clone)] +struct EditFixture { + name: &'static str, + old_text: String, + new_text: String, +} + +fn streaming_diff_push_new(criterion: &mut Criterion) { + let fixtures = fixtures(); + let mut group = criterion.benchmark_group("streaming_diff_push_new"); + group.sample_size(10); + + for fixture in fixtures { + group.throughput(Throughput::Bytes(fixture.new_text.len() as u64)); + group.bench_with_input( + BenchmarkId::new(fixture.name, fixture.old_text.len()), + &fixture, + |bench, fixture| { + bench.iter_batched( + || StreamingDiff::new(fixture.old_text.clone()), + |mut diff| { + let mut operation_count = 0; + for chunk in chunk_text(&fixture.new_text, CHUNK_SIZE) { + operation_count += black_box(diff.push_new(chunk)).len(); + } + black_box(operation_count); + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + +fn streaming_diff_finish(criterion: &mut Criterion) { + let fixtures = fixtures(); + let mut group = criterion.benchmark_group("streaming_diff_finish"); + group.sample_size(10); + + for fixture in fixtures { + group.throughput(Throughput::Bytes(fixture.new_text.len() as u64)); + group.bench_with_input( + BenchmarkId::new(fixture.name, fixture.old_text.len()), + &fixture, + |bench, fixture| { + bench.iter_batched( + || { + let mut diff = StreamingDiff::new(fixture.old_text.clone()); + for chunk in chunk_text(&fixture.new_text, CHUNK_SIZE) { + black_box(diff.push_new(chunk)); + } + diff + }, + |diff| { + black_box(diff.finish()); + }, + BatchSize::SmallInput, + ); + }, + ); + } + + group.finish(); +} + +fn fixtures() -> Vec { + // Keep fixtures modest because `StreamingDiff` is intentionally stressed here and + // can become very slow on tens of kilobytes of replacement text. These sizes still + // represent realistic `edit_file` old/new text blocks and are large enough to cross + // frame-budget-sized CPU work. + vec![ + make_fixture( + "tiny_function_rewrite", + 2, + EditPattern::LocalizedRewrite { + start_line: 12, + line_count: 6, + }, + SEED, + ), + make_fixture( + "small_function_rewrite", + 5, + EditPattern::LocalizedRewrite { + start_line: 22, + line_count: 12, + }, + SEED + 1, + ), + make_fixture( + "medium_many_small_changes", + 8, + EditPattern::ManySmallChanges { every_nth_line: 7 }, + SEED + 2, + ), + make_fixture( + "medium_insertions", + 8, + EditPattern::InsertHelperBlocks { every_nth_line: 9 }, + SEED + 3, + ), + ] +} + +enum EditPattern { + LocalizedRewrite { + start_line: usize, + line_count: usize, + }, + ManySmallChanges { + every_nth_line: usize, + }, + InsertHelperBlocks { + every_nth_line: usize, + }, +} + +fn make_fixture( + name: &'static str, + function_count: usize, + pattern: EditPattern, + seed: u64, +) -> EditFixture { + let mut rng = StdRng::seed_from_u64(seed); + let mut lines = random_rust_module(&mut rng, function_count); + let old_text = lines.join("\n"); + + match pattern { + EditPattern::LocalizedRewrite { + start_line, + line_count, + } => rewrite_local_block(&mut lines, start_line, line_count, &mut rng), + EditPattern::ManySmallChanges { every_nth_line } => { + rewrite_many_small_lines(&mut lines, every_nth_line, &mut rng) + } + EditPattern::InsertHelperBlocks { every_nth_line } => { + insert_helper_blocks(&mut lines, every_nth_line, &mut rng) + } + } + + EditFixture { + name, + old_text, + new_text: lines.join("\n"), + } +} + +fn random_rust_module(rng: &mut StdRng, function_count: usize) -> Vec { + let mut lines = vec![ + "use anyhow::{Context as _, Result};".to_string(), + "use collections::HashMap;".to_string(), + "".to_string(), + "#[derive(Clone, Debug)]".to_string(), + "pub struct WorkspaceSnapshot {".to_string(), + " buffers: HashMap,".to_string(), + " version: usize,".to_string(), + "}".to_string(), + "".to_string(), + "impl WorkspaceSnapshot {".to_string(), + ]; + + for function_index in 0..function_count { + let function_name = identifier(rng, function_index); + let argument_name = identifier(rng, function_index + 1_000); + let local_name = identifier(rng, function_index + 2_000); + let branch_name = identifier(rng, function_index + 3_000); + let multiplier = rng.random_range(2..17); + let offset = rng.random_range(1..128); + + lines.extend([ + format!( + " pub fn {function_name}(&mut self, {argument_name}: usize) -> Result {{" + ), + format!(" let mut {local_name} = {argument_name}.saturating_mul({multiplier});"), + format!(" if {local_name} % 2 == 0 {{"), + format!( + " {local_name} = {local_name}.saturating_add(self.version + {offset});" + ), + " } else {".to_string(), + format!(" {local_name} = {local_name}.saturating_sub({offset});"), + " }".to_string(), + format!(" let {branch_name} = self.buffers.len().saturating_add({local_name});"), + format!(" self.version = self.version.saturating_add({branch_name});"), + format!(" Ok({branch_name})"), + " }".to_string(), + "".to_string(), + ]); + } + + lines.push("}".to_string()); + lines.push("".to_string()); + lines.push("pub fn normalize_path(path: &str) -> String {".to_string()); + lines.push(" path.replace('\\\\', \"/\")".to_string()); + lines.push("}".to_string()); + lines +} + +fn rewrite_local_block( + lines: &mut [String], + start_line: usize, + line_count: usize, + rng: &mut StdRng, +) { + let end_line = (start_line + line_count).min(lines.len()); + for (relative_index, line) in lines[start_line..end_line].iter_mut().enumerate() { + let suffix = identifier(rng, relative_index + 10_000); + if line.contains("saturating_add") { + *line = format!( + " let {suffix} = self.version.checked_add({relative_index}).context(\"version overflow\")?;" + ); + } else if line.contains("saturating_sub") { + *line = format!( + " {suffix}.saturating_sub({});", + rng.random_range(8..256) + ); + } else if line.trim().is_empty() { + *line = format!( + " tracing::trace!(target: \"agent_bench\", value = {relative_index});" + ); + } else { + *line = format!("{line} // updated {suffix}"); + } + } +} + +fn rewrite_many_small_lines(lines: &mut [String], every_nth_line: usize, rng: &mut StdRng) { + for (line_index, line) in lines.iter_mut().enumerate() { + if line_index % every_nth_line != 0 || line.trim().is_empty() { + continue; + } + + if line.contains("let mut") { + *line = line.replace("let mut", "let mut updated"); + } else if line.contains("Ok(") { + *line = line.replace("Ok(", "Ok(black_box_value("); + } else if line.ends_with('{') { + *line = format!("{line} // scenario {}", identifier(rng, line_index)); + } else { + *line = format!("{line} // touched {}", identifier(rng, line_index)); + } + } +} + +fn insert_helper_blocks(lines: &mut Vec, every_nth_line: usize, rng: &mut StdRng) { + let mut line_index = every_nth_line; + while line_index < lines.len() { + if lines[line_index].trim() == "}" { + let helper_name = identifier(rng, line_index + 20_000); + lines.splice( + line_index..line_index, + [ + format!(" let {helper_name} = self.buffers.len();"), + format!(" tracing::trace!(target: \"agent_bench\", {helper_name});"), + ], + ); + line_index += 2; + } + line_index += every_nth_line; + } +} + +fn identifier(rng: &mut StdRng, salt: usize) -> String { + const WORDS: &[&str] = &[ + "buffer", + "workspace", + "snapshot", + "version", + "project", + "entry", + "path", + "cursor", + "anchor", + "edit", + "thread", + "message", + "context", + "store", + "diff", + "range", + "token", + "parser", + "semantic", + "format", + "completion", + "diagnostic", + "terminal", + "channel", + ]; + + let first = WORDS[(rng.random_range(0..WORDS.len()) + salt) % WORDS.len()]; + let second = WORDS[(rng.random_range(0..WORDS.len()) + salt / 3) % WORDS.len()]; + format!("{first}_{second}_{salt}") +} + +fn chunk_text(text: &str, max_chunk_size: usize) -> Vec<&str> { + let mut chunks = Vec::new(); + let mut start = 0; + while start < text.len() { + let mut end = (start + max_chunk_size).min(text.len()); + while end < text.len() && !text.is_char_boundary(end) { + end += 1; + } + chunks.push(&text[start..end]); + start = end; + } + chunks +} + +criterion_group!(benches, streaming_diff_push_new, streaming_diff_finish); +criterion_main!(benches); diff --git a/crates/streaming_diff/src/streaming_diff.rs b/crates/streaming_diff/src/streaming_diff.rs index 5677981b0dc..6d455a236bd 100644 --- a/crates/streaming_diff/src/streaming_diff.rs +++ b/crates/streaming_diff/src/streaming_diff.rs @@ -1,6 +1,6 @@ use ordered_float::OrderedFloat; use rope::{Point, Rope, TextSummary}; -use std::collections::{BTreeSet, HashMap}; +use std::collections::BTreeSet; use std::{ cmp, fmt::{self, Debug}, @@ -74,6 +74,20 @@ impl Matrix { self.cells[col * self.rows + row] = value; } + + fn adjacent_columns_mut(&mut self, current_col: usize) -> (&[f64], &mut [f64]) { + if current_col == 0 || current_col >= self.cols { + panic!("column out of bounds"); + } + + let current_col_start = current_col * self.rows; + let previous_col_start = current_col_start - self.rows; + let (before_current, current_and_after) = self.cells.split_at_mut(current_col_start); + ( + &before_current[previous_col_start..current_col_start], + &mut current_and_after[..self.rows], + ) + } } impl Debug for Matrix { @@ -103,7 +117,8 @@ pub struct StreamingDiff { scores: Matrix, old_text_ix: usize, new_text_ix: usize, - equal_runs: HashMap<(usize, usize), u32>, + previous_equal_runs: Vec, + current_equal_runs: Vec, } impl StreamingDiff { @@ -114,9 +129,10 @@ impl StreamingDiff { pub fn new(old: String) -> Self { let old = old.chars().collect::>(); + let old_len = old.len(); let mut scores = Matrix::new(); - scores.resize(old.len() + 1, 1); - for i in 0..=old.len() { + scores.resize(old_len + 1, 1); + for i in 0..=old_len { scores.set(i, 0, i as f64 * Self::DELETION_SCORE); } Self { @@ -125,7 +141,8 @@ impl StreamingDiff { scores, old_text_ix: 0, new_text_ix: 0, - equal_runs: Default::default(), + previous_equal_runs: vec![0; old_len + 1], + current_equal_runs: vec![0; old_len + 1], } } @@ -134,30 +151,34 @@ impl StreamingDiff { self.scores.swap_columns(0, self.scores.cols - 1); self.scores .resize(self.old.len() + 1, self.new.len() - self.new_text_ix + 1); - self.equal_runs.retain(|(_i, j), _| *j == self.new_text_ix); for j in self.new_text_ix + 1..=self.new.len() { + self.current_equal_runs.fill(0); let relative_j = j - self.new_text_ix; + let new_char = self.new[j - 1]; + let old = &self.old; + let previous_equal_runs = &self.previous_equal_runs; + let current_equal_runs = &mut self.current_equal_runs; + let (previous_scores, current_scores) = self.scores.adjacent_columns_mut(relative_j); - self.scores - .set(0, relative_j, j as f64 * Self::INSERTION_SCORE); - for i in 1..=self.old.len() { - let insertion_score = self.scores.get(i, relative_j - 1) + Self::INSERTION_SCORE; - let deletion_score = self.scores.get(i - 1, relative_j) + Self::DELETION_SCORE; - let equality_score = if self.old[i - 1] == self.new[j - 1] { - let mut equal_run = self.equal_runs.get(&(i - 1, j - 1)).copied().unwrap_or(0); - equal_run += 1; - self.equal_runs.insert((i, j), equal_run); + current_scores[0] = j as f64 * Self::INSERTION_SCORE; + for i in 1..=old.len() { + let insertion_score = previous_scores[i] + Self::INSERTION_SCORE; + let deletion_score = current_scores[i - 1] + Self::DELETION_SCORE; + let equality_score = if old[i - 1] == new_char { + let equal_run = previous_equal_runs[i - 1] + 1; + current_equal_runs[i] = equal_run; let exponent = cmp::min(equal_run as i32 / 4, Self::MAX_EQUALITY_EXPONENT); - self.scores.get(i - 1, relative_j - 1) + Self::EQUALITY_BASE.powi(exponent) + previous_scores[i - 1] + Self::EQUALITY_BASE.powi(exponent) } else { f64::NEG_INFINITY }; - let score = insertion_score.max(deletion_score).max(equality_score); - self.scores.set(i, relative_j, score); + current_scores[i] = insertion_score.max(deletion_score).max(equality_score); } + + std::mem::swap(&mut self.previous_equal_runs, &mut self.current_equal_runs); } let mut max_score = f64::NEG_INFINITY; diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index 251a194d2c7..352db3c65a3 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -1396,7 +1396,7 @@ mod tests { use rand::{distr::StandardUniform, prelude::*}; use std::cmp; - #[ctor::ctor] + #[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index 7dc74ee7474..0700781ebb8 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -7,7 +7,7 @@ use serde_json::json; use util::{path, rel_path::rel_path}; use workspace::{ActivatePreviousItem, AppState, MultiWorkspace, Workspace, item::test::TestItem}; -#[ctor::ctor] +#[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 3b7edef415f..f5a416ad3c3 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -734,11 +734,14 @@ mod tests { use std::{path::PathBuf, sync::Arc}; use editor::{Editor, SelectionEffects}; - use gpui::{TestAppContext, VisualTestContext}; - use language::{Language, LanguageConfig, LanguageMatcher, Point}; + use gpui::{App, Entity, Task, TestAppContext, VisualTestContext}; + use language::{ + Buffer, ContextProvider, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, + LanguageServerName, Point, + }; use project::{ContextProviderWithTasks, FakeFs, Project}; use serde_json::json; - use task::TaskTemplates; + use task::{TaskTemplate, TaskTemplates}; use util::path; use workspace::{CloseInactiveTabsAndPanes, MultiWorkspace, OpenOptions, OpenVisible}; @@ -1033,6 +1036,80 @@ mod tests { cx.executor().run_until_parked(); } + #[gpui::test] + async fn test_empty_lsp_task_response_keeps_language_tasks_in_modal(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "main.test": "test" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new( + Language::new( + LanguageConfig { + name: "Test".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["test".to_string()], + ..LanguageMatcher::default() + }, + ..LanguageConfig::default() + }, + None, + ) + .with_context_provider(Some(Arc::new( + ContextProviderWithLspTaskSource::new(ContextProviderWithTasks::new( + TaskTemplates(vec![TaskTemplate { + label: "Run language task".to_string(), + command: "echo".to_string(), + args: vec!["language task".to_string()], + ..TaskTemplate::default() + }]), + )), + ))), + )); + let mut fake_servers = language_registry.register_fake_lsp( + "Test", + FakeLspAdapter { + name: TEST_LSP_NAME, + ..FakeLspAdapter::default() + }, + ); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + let _item = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/dir/main.test")), + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + window, + cx, + ) + }) + .await + .unwrap(); + cx.executor().run_until_parked(); + let fake_server = fake_servers + .try_recv() + .expect("fake LSP server should have started"); + use project::lsp_store::lsp_ext_command::Runnables; + fake_server + .set_request_handler::(move |_, _| async move { Ok(Vec::new()) }); + + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + task_names(&tasks_picker, cx), + vec!["Run language task"], + "An empty LSP task response should not suppress language tasks in the modal" + ); + } + #[gpui::test] async fn test_language_task_filtering(cx: &mut TestAppContext) { init_test(cx); @@ -1238,6 +1315,32 @@ mod tests { ); } + const TEST_LSP_NAME: &str = "test-lsp"; + + struct ContextProviderWithLspTaskSource { + tasks: ContextProviderWithTasks, + } + + impl ContextProviderWithLspTaskSource { + fn new(tasks: ContextProviderWithTasks) -> Self { + Self { tasks } + } + } + + impl ContextProvider for ContextProviderWithLspTaskSource { + fn associated_tasks( + &self, + buffer: Option>, + cx: &App, + ) -> Task> { + self.tasks.associated_tasks(buffer, cx) + } + + fn lsp_task_source(&self) -> Option { + Some(LanguageServerName::new_static(TEST_LSP_NAME)) + } + } + fn emulate_task_schedule( tasks_picker: Entity>, project: &Entity, diff --git a/crates/terminal/src/mappings/keys.rs b/crates/terminal/src/mappings/keys.rs index 8345c417280..5983ce5d63d 100644 --- a/crates/terminal/src/mappings/keys.rs +++ b/crates/terminal/src/mappings/keys.rs @@ -303,8 +303,18 @@ mod test { assert_eq!(to_esc_str(&home, &app_cursor, false), Some("\x1bOH".into())); assert_eq!(to_esc_str(&end, &app_cursor, false), Some("\x1bOF".into())); + let shift_up = Keystroke::parse("shift-up").unwrap(); + let shift_down = Keystroke::parse("shift-down").unwrap(); let shift_home = Keystroke::parse("shift-home").unwrap(); let shift_end = Keystroke::parse("shift-end").unwrap(); + assert_eq!( + to_esc_str(&shift_up, &none, false), + Some("\x1b[1;2A".into()) + ); + assert_eq!( + to_esc_str(&shift_down, &none, false), + Some("\x1b[1;2B".into()) + ); assert_eq!( to_esc_str(&shift_home, &none, false), Some("\x1b[1;2H".into()) diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 4e16d69e405..560a24ca547 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -185,6 +185,11 @@ impl PtyProcessInfo { Some(info) } + #[cfg(all(test, unix))] + pub(crate) fn load_for_test(&self) -> Option { + self.load() + } + /// Updates the cached process info, emitting a [`Event::TitleChanged`] event if the Zed-relevant info has changed pub fn emit_title_changed_if_changed(self: &Arc, cx: &mut Context<'_, Terminal>) { if self.task.lock().is_some() { @@ -192,8 +197,9 @@ impl PtyProcessInfo { } let this = self.clone(); let has_changed = cx.background_executor().spawn(async move { + let previous = this.current.read().clone(); let current = this.load(); - let has_changed = match (this.current.read().as_ref(), current.as_ref()) { + let has_changed = match (previous.as_ref(), current.as_ref()) { (None, None) => false, (Some(prev), Some(now)) => prev.cwd != now.cwd || prev.name != now.name, _ => true, diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 00aa2cbb01b..7d22a86d7db 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -60,7 +60,7 @@ use std::{ cmp::{self, min}, fmt::Display, ops::{Deref, RangeInclusive}, - path::PathBuf, + path::{Path, PathBuf}, process::ExitStatus, sync::Arc, time::{Duration, Instant}, @@ -453,6 +453,13 @@ impl TerminalBuilder { ) -> Task> { let version = release_channel::AppVersion::global(cx); let background_executor = cx.background_executor().clone(); + #[cfg(not(windows))] + let child_signal_mask = match tty::SignalMask::current() + .context("failed to capture terminal child signal mask") + { + Ok(signal_mask) => Some(signal_mask), + Err(error) => return Task::ready(Err(error)), + }; let fut = async move { // Remove SHLVL so the spawned shell initializes it to 1, matching // the behavior of standalone terminal emulators like iTerm2/Kitty/Alacritty. @@ -542,6 +549,12 @@ impl TerminalBuilder { working_directory: working_directory.clone(), drain_on_exit: true, env: env.clone().into_iter().collect(), + // We pass in the foreground thread's signal mask to the child process via pty_options, + // so terminal construction can run on a background thread without breaking Ctrl-C and other signals + // otherwise the terminal would inherit the background executor's signal mask which blocks + // some terminal signals + #[cfg(not(windows))] + child_signal_mask, #[cfg(windows)] escape_args: shell_kind.tty_escape_args(), } @@ -688,12 +701,7 @@ impl TerminalBuilder { events_rx, }) }; - // the thread we spawn things on has an effect on signal handling - if !cfg!(target_os = "windows") { - cx.spawn(async move |_| fut.await) - } else { - cx.background_spawn(fut) - } + cx.background_spawn(fut) } pub fn subscribe(mut self, cx: &Context) -> Terminal { @@ -985,7 +993,7 @@ impl Terminal { AlacTermEvent::Bell => { cx.emit(Event::Bell); } - AlacTermEvent::Exit => self.register_task_finished(Some(9), cx), + AlacTermEvent::Exit => self.register_task_finished(None, cx), AlacTermEvent::MouseCursorDirty => { //NOOP, Handled in render } @@ -1009,8 +1017,8 @@ impl Terminal { .unwrap_or_else(|| to_alac_rgb(get_color_at_index(index, cx.theme().as_ref()))); self.write_to_pty(format(color).into_bytes()); } - AlacTermEvent::ChildExit(raw_status) => { - self.register_task_finished(Some(raw_status), cx); + AlacTermEvent::ChildExit(exit_status) => { + self.register_task_finished(Some(exit_status), cx); } } } @@ -2137,6 +2145,18 @@ impl Terminal { } } + /// Normalizes the command name of the foreground process, if one is known. + pub fn foreground_process_command_name(&self) -> Option { + match &self.terminal_type { + TerminalType::Pty { info, .. } => info + .current + .read() + .as_ref() + .and_then(|process| foreground_process_command_from_argv(&process.argv)), + TerminalType::DisplayOnly => None, + } + } + /// Returns the working directory of the process that's connected to the PTY. /// That means it returns the working directory of the local shell or program /// that's running inside the terminal. @@ -2250,18 +2270,11 @@ impl Terminal { Task::ready(None) } - fn register_task_finished(&mut self, raw_status: Option, cx: &mut Context) { - let exit_status: Option = raw_status.map(|value| { - #[cfg(unix)] - { - std::os::unix::process::ExitStatusExt::from_raw(value) - } - #[cfg(windows)] - { - std::os::windows::process::ExitStatusExt::from_raw(value as u32) - } - }); - + fn register_task_finished( + &mut self, + exit_status: Option, + cx: &mut Context, + ) { if let Some(tx) = &self.completion_tx { tx.try_send(exit_status).ok(); } @@ -2461,6 +2474,77 @@ impl Drop for Terminal { impl EventEmitter for Terminal {} +fn normalize_path_command_name(command: &str) -> Option { + const MAX_COMMAND_NAME_LENGTH: usize = 64; + + let command = command.trim(); + if command.is_empty() + || command.len() > MAX_COMMAND_NAME_LENGTH + || command.starts_with('.') + || command.starts_with('-') + || command.contains('/') + || command.contains('\\') + { + return None; + } + + let mut command = command.to_ascii_lowercase(); + for suffix in [".exe", ".cmd", ".bat", ".ps1"] { + if command.ends_with(suffix) { + command.truncate(command.len() - suffix.len()); + break; + } + } + + if command.is_empty() + || !command.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + { + return None; + } + + Some(command) +} + +fn foreground_process_command_from_argv(argv: &[String]) -> Option { + let command = argv + .first() + .and_then(|command| normalize_path_command_name(command)); + + if !matches!( + command.as_deref(), + Some("node" | "python" | "python3" | "bun" | "deno") + ) { + return command; + } + + argv.iter() + .skip(1) + .filter_map(|argument| normalize_script_command_name(argument)) + .next() + .or(command) +} + +fn normalize_script_command_name(argument: &str) -> Option { + let path = Path::new(argument); + let file_stem = path + .file_stem() + .and_then(|file_stem| file_stem.to_str()) + .and_then(normalize_path_command_name)?; + + if file_stem != "index" { + return Some(file_stem); + } + + path.parent() + .and_then(|parent| parent.parent()) + .and_then(|package_path| package_path.file_name()) + .and_then(|package_name| package_name.to_str()) + .and_then(|package_name| package_name.strip_suffix("-cli").or(Some(package_name))) + .and_then(normalize_path_command_name) +} + fn make_selection(range: &RangeInclusive) -> Selection { let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left); selection.update(*range.end(), AlacDirection::Right); @@ -2597,6 +2681,54 @@ mod tests { use rand::{Rng, distr, rngs::StdRng}; use task::{Shell, ShellBuilder}; + #[test] + fn test_normalize_path_command_name() { + assert_eq!(normalize_path_command_name("claude"), Some("claude".into())); + assert_eq!(normalize_path_command_name("Cargo"), Some("cargo".into())); + assert_eq!(normalize_path_command_name("node.exe"), Some("node".into())); + assert_eq!( + normalize_path_command_name("my-agent_cli.1"), + Some("my-agent_cli.1".into()) + ); + assert_eq!(normalize_path_command_name("./local-agent"), None); + assert_eq!(normalize_path_command_name("../local-agent"), None); + assert_eq!(normalize_path_command_name("/usr/local/bin/cargo"), None); + assert_eq!( + normalize_path_command_name("target\\debug\\agent.exe"), + None + ); + assert_eq!(normalize_path_command_name(".hidden-agent"), None); + assert_eq!(normalize_path_command_name("agent with spaces"), None); + assert_eq!(normalize_path_command_name("zsh"), Some("zsh".into())); + assert_eq!(normalize_path_command_name("-zsh"), None); + assert_eq!(normalize_path_command_name("pwsh.exe"), Some("pwsh".into())); + } + + #[test] + fn test_foreground_process_command_from_interpreter_wrapper() { + assert_eq!( + foreground_process_command_from_argv(&[ + "node".to_string(), + "/opt/homebrew/lib/node_modules/@google/gemini-cli/dist/index.js".to_string(), + ]), + Some("gemini".to_string()) + ); + assert_eq!( + foreground_process_command_from_argv(&[ + "python3".to_string(), + "/Users/me/.local/bin/codex.py".to_string(), + ]), + Some("codex".to_string()) + ); + assert_eq!( + foreground_process_command_from_argv(&[ + "node".to_string(), + "/Users/me/private-project/scripts/customer-data-export.js".to_string(), + ]), + Some("customer-data-export".to_string()) + ); + } + #[cfg(not(target_os = "windows"))] fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -2613,10 +2745,18 @@ mod tests { command: &str, args: &[&str], ) -> (Entity, Receiver>) { - let (completion_tx, completion_rx) = async_channel::unbounded(); let args: Vec = args.iter().map(|s| s.to_string()).collect(); let (program, args) = ShellBuilder::new(&Shell::System, false).build(Some(command.to_owned()), &args); + build_test_terminal_with_arguments(cx, program, args).await + } + + async fn build_test_terminal_with_arguments( + cx: &mut TestAppContext, + program: String, + args: Vec, + ) -> (Entity, Receiver>) { + let (completion_tx, completion_rx) = async_channel::unbounded(); let builder = cx .update(|cx| { TerminalBuilder::new( @@ -2755,6 +2895,23 @@ mod tests { ); } + #[cfg(unix)] + #[gpui::test] + async fn test_foreground_process_command_tracks_path_command(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (terminal, completion_rx) = + build_test_terminal_with_arguments(cx, "sleep".to_string(), vec!["1".to_string()]) + .await; + + assert_foreground_process_command_eventually(&terminal, "sleep", cx).await; + + assert!( + completion_rx.recv().await.is_ok(), + "expected terminal completion after sleep exits" + ); + } + // TODO should be tested on Linux too, but does not work there well #[cfg(target_os = "macos")] #[gpui::test(iterations = 10)] @@ -3359,6 +3516,42 @@ mod tests { panic!("Expected terminal content to contain {expected:?}, got: {content}"); } + #[cfg(unix)] + async fn assert_foreground_process_command_eventually( + terminal: &Entity, + expected: &str, + cx: &mut TestAppContext, + ) { + let mut command_name = None; + for _ in 0..100 { + terminal.update(cx, |terminal, _| { + if let TerminalType::Pty { info, .. } = &terminal.terminal_type { + info.load_for_test(); + } + }); + command_name = + terminal.update(cx, |terminal, _| terminal.foreground_process_command_name()); + if command_name.as_deref() == Some(expected) { + return; + } + cx.background_executor + .timer(Duration::from_millis(10)) + .await; + } + let process_info = terminal.update(cx, |terminal, _| match &terminal.terminal_type { + TerminalType::Pty { info, .. } => format!( + "pid={:?}, fallback_pid={:?}, has_current_info={}", + info.pid(), + info.pid_getter().fallback_pid(), + info.current.read().is_some() + ), + TerminalType::DisplayOnly => "display-only".to_string(), + }); + panic!( + "Expected foreground process command name to be {expected:?}, got {command_name:?}; process info: {process_info:?}" + ); + } + /// Test that kill_active_task properly terminates both the foreground process /// and the shell, allowing wait_for_completed_task to complete and output to be captured. #[cfg(unix)] diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index fb3abf41db7..e6e6e94bffc 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -1,71 +1,17 @@ use super::{HoverTarget, HoveredWord, TerminalView}; use anyhow::{Context as _, Result}; use editor::Editor; -use gpui::{App, AppContext, Context, Task, TaskExt, WeakEntity, Window}; -use itertools::Itertools; -use project::{Entry, Metadata}; +use gpui::{Context, Task, TaskExt, WeakEntity, Window}; use std::path::PathBuf; use terminal::PathLikeTarget; -use util::{ - ResultExt, debug_panic, - paths::{PathStyle, PathWithPosition, normalize_lexically}, - rel_path::RelPath, +use util::{ResultExt, debug_panic}; +#[cfg(not(test))] +use workspace::path_link::possible_open_target; +#[cfg(test)] +use workspace::path_link::{ + BackgroundFsChecks, OpenTargetFoundBy, possible_open_target_with_fs_checks, }; -use workspace::{OpenOptions, OpenVisible, Workspace}; - -/// The way we found the open target. This is important to have for test assertions. -/// For example, remote projects never look in the file system. -#[cfg(test)] -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum OpenTargetFoundBy { - WorktreeExact, - WorktreeScan, - FileSystemBackground, -} - -#[cfg(test)] -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum BackgroundFsChecks { - Enabled, - Disabled, -} - -#[derive(Debug, Clone)] -enum OpenTarget { - Worktree(PathWithPosition, Entry, #[cfg(test)] OpenTargetFoundBy), - File(PathWithPosition, Metadata), -} - -impl OpenTarget { - fn is_file(&self) -> bool { - match self { - OpenTarget::Worktree(_, entry, ..) => entry.is_file(), - OpenTarget::File(_, metadata) => !metadata.is_dir, - } - } - - fn is_dir(&self) -> bool { - match self { - OpenTarget::Worktree(_, entry, ..) => entry.is_dir(), - OpenTarget::File(_, metadata) => metadata.is_dir, - } - } - - fn path(&self) -> &PathWithPosition { - match self { - OpenTarget::Worktree(path, ..) => path, - OpenTarget::File(path, _) => path, - } - } - - #[cfg(test)] - fn found_by(&self) -> OpenTargetFoundBy { - match self { - OpenTarget::Worktree(.., found_by) => *found_by, - OpenTarget::File(..) => OpenTargetFoundBy::FileSystemBackground, - } - } -} +use workspace::{OpenOptions, OpenVisible, Workspace, path_link::OpenTarget}; pub(super) fn hover_path_like_target( workspace: &WeakEntity, @@ -96,11 +42,19 @@ fn possible_hover_target( cx: &mut Context, #[cfg(test)] background_fs_checks: BackgroundFsChecks, ) -> Task<()> { + #[cfg(not(test))] let file_to_open_task = possible_open_target( workspace, - path_like_target, + &path_like_target.maybe_path, + path_like_target.terminal_dir.as_deref(), + cx, + ); + #[cfg(test)] + let file_to_open_task = possible_open_target_with_fs_checks( + workspace, + &path_like_target.maybe_path, + path_like_target.terminal_dir.as_deref(), cx, - #[cfg(test)] background_fs_checks, ); cx.spawn(async move |terminal_view, cx| { @@ -122,297 +76,6 @@ fn possible_hover_target( }) } -fn possible_open_target( - workspace: &WeakEntity, - path_like_target: &PathLikeTarget, - cx: &App, - #[cfg(test)] background_fs_checks: BackgroundFsChecks, -) -> Task> { - let Some(workspace) = workspace.upgrade() else { - return Task::ready(None); - }; - // We have to check for both paths, as on Unix, certain paths with positions are valid file paths too. - // We can be on FS remote part, without real FS, so cannot canonicalize or check for existence the path right away. - let mut potential_paths = Vec::new(); - let cwd = path_like_target.terminal_dir.as_ref(); - let maybe_path = &path_like_target.maybe_path; - let original_path = PathWithPosition::from_path(PathBuf::from(maybe_path)); - let path_with_position = PathWithPosition::parse_str(maybe_path); - let worktree_candidates = workspace - .read(cx) - .worktrees(cx) - .sorted_by_key(|worktree| { - let worktree_root = worktree.read(cx).abs_path(); - match cwd.and_then(|cwd| worktree_root.strip_prefix(cwd).ok()) { - Some(cwd_child) => cwd_child.components().count(), - None => usize::MAX, - } - }) - .collect::>(); - // Since we do not check paths via FS and joining, we need to strip off potential `./`, `a/`, `b/` prefixes out of it. - const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"]; - for prefix_str in GIT_DIFF_PATH_PREFIXES.iter().chain(std::iter::once(&".")) { - if let Some(stripped) = original_path.path.strip_prefix(prefix_str).ok() { - potential_paths.push(PathWithPosition { - path: stripped.to_owned(), - row: original_path.row, - column: original_path.column, - }); - } - if let Some(stripped) = path_with_position.path.strip_prefix(prefix_str).ok() { - potential_paths.push(PathWithPosition { - path: stripped.to_owned(), - row: path_with_position.row, - column: path_with_position.column, - }); - } - } - - let insert_both_paths = original_path != path_with_position; - potential_paths.insert(0, original_path); - if insert_both_paths { - potential_paths.insert(1, path_with_position); - } - - // If we won't find paths "easily", we can traverse the entire worktree to look what ends with the potential path suffix. - // That will be slow, though, so do the fast checks first. - let mut worktree_paths_to_check = Vec::new(); - let mut is_cwd_in_worktree = false; - let mut open_target = None; - 'worktree_loop: for worktree in &worktree_candidates { - let worktree_root = worktree.read(cx).abs_path(); - let mut paths_to_check = Vec::with_capacity(potential_paths.len()); - let relative_cwd = cwd - .and_then(|cwd| cwd.strip_prefix(&worktree_root).ok()) - .and_then(|cwd| RelPath::new(cwd, PathStyle::local()).ok()) - .and_then(|cwd_stripped| { - (cwd_stripped.as_ref() != RelPath::empty()).then(|| { - is_cwd_in_worktree = true; - cwd_stripped - }) - }); - - for path_with_position in &potential_paths { - let path_to_check = if worktree_root.ends_with(&path_with_position.path) { - let root_path_with_position = PathWithPosition { - path: worktree_root.to_path_buf(), - row: path_with_position.row, - column: path_with_position.column, - }; - match worktree.read(cx).root_entry() { - Some(root_entry) => { - open_target = Some(OpenTarget::Worktree( - root_path_with_position, - root_entry.clone(), - #[cfg(test)] - OpenTargetFoundBy::WorktreeExact, - )); - break 'worktree_loop; - } - None => root_path_with_position, - } - } else { - PathWithPosition { - path: path_with_position - .path - .strip_prefix(&worktree_root) - .unwrap_or(&path_with_position.path) - .to_owned(), - row: path_with_position.row, - column: path_with_position.column, - } - }; - - // Normalize the path by joining with cwd if available (handles `.` and `..` segments) - let normalized_path = if path_to_check.path.is_relative() { - relative_cwd.as_ref().and_then(|relative_cwd| { - let joined = relative_cwd - .as_ref() - .as_std_path() - .join(&path_to_check.path); - normalize_lexically(&joined).ok().and_then(|p| { - RelPath::new(&p, PathStyle::local()) - .ok() - .map(std::borrow::Cow::into_owned) - }) - }) - } else { - None - }; - let original_path = RelPath::new(&path_to_check.path, PathStyle::local()).ok(); - - if !worktree.read(cx).is_single_file() - && let Some(entry) = normalized_path - .as_ref() - .and_then(|p| worktree.read(cx).entry_for_path(p)) - .or_else(|| { - original_path - .as_ref() - .and_then(|p| worktree.read(cx).entry_for_path(p.as_ref())) - }) - { - open_target = Some(OpenTarget::Worktree( - PathWithPosition { - path: worktree.read(cx).absolutize(&entry.path), - row: path_to_check.row, - column: path_to_check.column, - }, - entry.clone(), - #[cfg(test)] - OpenTargetFoundBy::WorktreeExact, - )); - break 'worktree_loop; - } - - paths_to_check.push(path_to_check); - } - - if !paths_to_check.is_empty() { - worktree_paths_to_check.push((worktree.clone(), paths_to_check)); - } - } - - #[cfg(not(test))] - let enable_background_fs_checks = workspace.read(cx).project().read(cx).is_local(); - #[cfg(test)] - let enable_background_fs_checks = background_fs_checks == BackgroundFsChecks::Enabled; - - if open_target.is_some() { - // We we want to prefer open targets found via background fs checks over worktree matches, - // however we can return early if either: - // - This is a remote project, or - // - If the terminal working directory is inside of at least one worktree - if !enable_background_fs_checks || is_cwd_in_worktree { - return Task::ready(open_target); - } - } - - // Before entire worktree traversal(s), make an attempt to do FS checks if available. - let fs_paths_to_check = - if enable_background_fs_checks { - let fs_cwd_paths_to_check = cwd - .iter() - .flat_map(|cwd| { - let mut paths_to_check = Vec::new(); - for path_to_check in &potential_paths { - let maybe_path = &path_to_check.path; - if path_to_check.path.is_relative() { - paths_to_check.push(PathWithPosition { - path: cwd.join(&maybe_path), - row: path_to_check.row, - column: path_to_check.column, - }); - } - } - paths_to_check - }) - .collect::>(); - fs_cwd_paths_to_check - .into_iter() - .chain( - potential_paths - .into_iter() - .flat_map(|path_to_check| { - let mut paths_to_check = Vec::new(); - let maybe_path = &path_to_check.path; - if maybe_path.starts_with("~") { - if let Some(home_path) = maybe_path.strip_prefix("~").ok().and_then( - |stripped_maybe_path| { - Some(dirs::home_dir()?.join(stripped_maybe_path)) - }, - ) { - paths_to_check.push(PathWithPosition { - path: home_path, - row: path_to_check.row, - column: path_to_check.column, - }); - } - } else { - paths_to_check.push(PathWithPosition { - path: maybe_path.clone(), - row: path_to_check.row, - column: path_to_check.column, - }); - if maybe_path.is_relative() { - for worktree in &worktree_candidates { - if !worktree.read(cx).is_single_file() { - paths_to_check.push(PathWithPosition { - path: worktree.read(cx).abs_path().join(maybe_path), - row: path_to_check.row, - column: path_to_check.column, - }); - } - } - } - } - paths_to_check - }) - .collect::>(), - ) - .collect() - } else { - Vec::new() - }; - - let fs = workspace.read(cx).project().read(cx).fs().clone(); - let background_fs_checks_task = cx.background_spawn(async move { - for mut path_to_check in fs_paths_to_check { - if let Some(fs_path_to_check) = fs.canonicalize(&path_to_check.path).await.ok() - && let Some(metadata) = fs.metadata(&fs_path_to_check).await.ok().flatten() - { - if open_target - .as_ref() - .map(|open_target| open_target.path().path != fs_path_to_check) - .unwrap_or(true) - { - path_to_check.path = fs_path_to_check; - return Some(OpenTarget::File(path_to_check, metadata)); - } - - break; - } - } - - open_target - }); - - cx.spawn(async move |cx| { - background_fs_checks_task.await.or_else(|| { - for (worktree, worktree_paths_to_check) in worktree_paths_to_check { - if let Some(found_entry) = - worktree.update(cx, |worktree, _| -> Option { - let traversal = - worktree.traverse_from_path(true, true, false, RelPath::empty()); - for entry in traversal { - if let Some(path_in_worktree) = - worktree_paths_to_check.iter().find(|path_to_check| { - RelPath::new(&path_to_check.path, PathStyle::local()) - .is_ok_and(|path| entry.path.ends_with(&path)) - }) - { - return Some(OpenTarget::Worktree( - PathWithPosition { - path: worktree.absolutize(&entry.path), - row: path_in_worktree.row, - column: path_in_worktree.column, - }, - entry.clone(), - #[cfg(test)] - OpenTargetFoundBy::WorktreeScan, - )); - } - } - None - }) - { - return Some(found_entry); - } - } - None - }) - }) -} - pub(super) fn open_path_like_target( workspace: &WeakEntity, terminal_view: &mut TerminalView, @@ -455,13 +118,25 @@ fn possibly_open_target( cx.spawn_in(window, async move |terminal_view, cx| { let Some(open_target) = terminal_view .update(cx, |_, cx| { - possible_open_target( - &workspace, - &path_like_target, - cx, - #[cfg(test)] - background_fs_checks, - ) + #[cfg(not(test))] + { + possible_open_target( + &workspace, + &path_like_target.maybe_path, + path_like_target.terminal_dir.as_deref(), + cx, + ) + } + #[cfg(test)] + { + possible_open_target_with_fs_checks( + &workspace, + &path_like_target.maybe_path, + path_like_target.terminal_dir.as_deref(), + cx, + background_fs_checks, + ) + } })? .await else { @@ -530,7 +205,7 @@ fn possibly_open_target( #[cfg(test)] mod tests { use super::*; - use gpui::TestAppContext; + use gpui::{AppContext as _, TestAppContext}; use project::Project; use serde_json::json; use std::path::{Path, PathBuf}; @@ -540,6 +215,7 @@ mod tests { terminal_settings::{AlternateScroll, CursorShape}, }; use util::path; + use util::paths::PathStyle; use workspace::{AppState, MultiWorkspace}; async fn init_test( diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 449e1c67d79..68c4281441a 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -513,13 +513,16 @@ impl TerminalView { .action("Paste Text", Box::new(PasteText)) .action("Select All", Box::new(SelectAll)) .action("Clear", Box::new(Clear)) - .when(assistant_enabled, |menu| { - menu.separator() - .action("Inline Assist", Box::new(InlineAssist::default())) - .when(has_selection, |menu| { - menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) - }) - }) + .when( + assistant_enabled && !matches!(self.mode, TerminalMode::Embedded { .. }), + |menu| { + menu.separator() + .action("Inline Assist", Box::new(InlineAssist::default())) + .when(has_selection, |menu| { + menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) + }) + }, + ) .separator() .action( "Close Terminal Tab", @@ -670,7 +673,20 @@ impl TerminalView { }); } + fn is_alt_screen(&self, cx: &App) -> bool { + self.terminal + .read(cx) + .last_content + .mode + .contains(TermMode::ALT_SCREEN) + } + fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + let terminal_content = self.terminal.read(cx).last_content(); if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 @@ -686,6 +702,11 @@ impl TerminalView { } fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + let terminal_content = self.terminal.read(cx).last_content(); if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 { let max_scroll_top = self.max_scroll_top(cx); @@ -701,6 +722,11 @@ impl TerminalView { } fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + if self.scroll_top == Pixels::ZERO { self.terminal.update(cx, |term, _| term.scroll_page_up()); } else { @@ -726,6 +752,11 @@ impl TerminalView { } fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + self.terminal.update(cx, |term, _| term.scroll_page_down()); let terminal = self.terminal.read(cx); if terminal.last_content().display_offset < terminal.viewport_lines() { @@ -735,11 +766,21 @@ impl TerminalView { } fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + self.terminal.update(cx, |term, _| term.scroll_to_top()); cx.notify(); } fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + self.terminal.update(cx, |term, _| term.scroll_to_bottom()); if self.block_below_cursor.is_some() { self.scroll_top = self.max_scroll_top(cx); @@ -855,7 +896,7 @@ impl TerminalView { }); } - fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { + pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { let mut text = paths.iter().map(|path| format!(" {path:?}")).join(""); text.push(' '); window.focus(&self.focus_handle(cx), cx); @@ -2057,7 +2098,7 @@ fn first_project_directory(workspace: &Workspace, cx: &App) -> Option { #[cfg(test)] mod tests { use super::*; - use gpui::TestAppContext; + use gpui::{TestAppContext, VisualTestContext}; use project::{Entry, Project, ProjectPath, Worktree}; use remote::RemoteClient; use std::path::{Path, PathBuf}; @@ -2101,6 +2142,88 @@ mod tests { assert_eq!(written, expected_text); } + // DEC private mode 1049: a program writes this to enter the alternate screen buffer. + const ENTER_ALT_SCREEN: &[u8] = b"\x1b[?1049h"; + + // CSI `1;2A` = cursor-up with the xterm Shift modifier (`1 + 1` for Shift). + const SHIFT_UP_ESCAPE: &[u8] = b"\x1b[1;2A"; + + #[gpui::test] + async fn shift_up_scrolls_history_in_normal_screen(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(load_default_keymap); + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + + let output = (0..200) + .map(|line| format!("line {line}\n")) + .collect::(); + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.write_output(output.as_bytes(), cx); + terminal.sync(window, cx); + }); + }); + terminal.read_with(&cx, |terminal, _| { + assert!(!terminal.last_content.mode.contains(TermMode::ALT_SCREEN)); + assert_eq!(terminal.last_content.display_offset, 0); + }); + + cx.simulate_keystrokes("shift-up"); + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| terminal.sync(window, cx)); + }); + + assert_eq!( + terminal.read_with(&cx, |terminal, _| terminal.last_content.display_offset), + 1, + "shift-up should scroll terminal history in the normal screen", + ); + assert!( + terminal + .update(&mut cx, |terminal, _| terminal.take_input_log()) + .is_empty(), + "shift-up in the normal screen should not be forwarded to the shell", + ); + } + + #[gpui::test] + async fn shift_up_is_forwarded_to_program_in_alt_screen(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(load_default_keymap); + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.write_output(ENTER_ALT_SCREEN, cx); + terminal.sync(window, cx); + }); + }); + terminal.read_with(&cx, |terminal, _| { + assert!(terminal.last_content.mode.contains(TermMode::ALT_SCREEN)); + }); + + cx.simulate_keystrokes("shift-up"); + assert_eq!( + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()), + vec![SHIFT_UP_ESCAPE.to_vec()], + "shift-up should be forwarded to the program in the alternate screen", + ); + } + // Working directory calculation tests // No Worktrees in project -> home_dir() @@ -2273,6 +2396,72 @@ mod tests { (project, workspace) } + fn load_default_keymap(cx: &mut App) { + cx.bind_keys( + settings::KeymapFile::load_asset_allow_partial_failure( + settings::DEFAULT_KEYMAP_PATH, + cx, + ) + .unwrap(), + ); + } + + fn add_display_only_terminal( + project: &Entity, + window_handle: gpui::WindowHandle, + focus: bool, + cx: &mut TestAppContext, + ) -> (Entity, Entity, Entity) { + let project = project.clone(); + window_handle + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + let active_pane = workspace.read(cx).active_pane().clone(); + + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + CursorShape::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .unwrap() + .subscribe(cx) + }); + let terminal_view = cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }); + + active_pane.update(cx, |pane, cx| { + pane.add_item( + Box::new(terminal_view.clone()), + true, + false, + None, + window, + cx, + ); + }); + + if focus { + let focus_handle = terminal_view.read(cx).focus_handle.clone(); + focus_handle.focus(window, cx); + } + + (active_pane, terminal, terminal_view) + }) + .unwrap() + } + /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle. async fn init_test_with_window( cx: &mut TestAppContext, @@ -2473,45 +2662,11 @@ mod tests { }) .unwrap(); - let (active_pane, terminal, terminal_view, tab_item) = window_handle - .update(cx, |multi_workspace, window, cx| { - let workspace = multi_workspace.workspace().clone(); - let active_pane = workspace.read(cx).active_pane().clone(); - - let terminal = cx.new(|cx| { - terminal::TerminalBuilder::new_display_only( - CursorShape::default(), - terminal::terminal_settings::AlternateScroll::On, - None, - 0, - cx.background_executor(), - PathStyle::local(), - ) - .unwrap() - .subscribe(cx) - }); - let terminal_view = cx.new(|cx| { - TerminalView::new( - terminal.clone(), - workspace.downgrade(), - None, - project.downgrade(), - window, - cx, - ) - }); - - active_pane.update(cx, |pane, cx| { - pane.add_item( - Box::new(terminal_view.clone()), - true, - false, - None, - window, - cx, - ); - }); + let (active_pane, terminal, terminal_view) = + add_display_only_terminal(&project, window_handle, false, cx); + let tab_item = window_handle + .update(cx, |_, window, cx| { let tab_project_item = cx.new(|_| TestProjectItem { entry_id: Some(second_entry.id), project_path: Some(ProjectPath { @@ -2525,8 +2680,7 @@ mod tests { active_pane.update(cx, |pane, cx| { pane.add_item(Box::new(tab_item.clone()), true, false, None, window, cx); }); - - (active_pane, terminal, terminal_view, tab_item) + tab_item }) .unwrap(); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e6e7534cb28..61cd45b0f1a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -9,7 +9,7 @@ use std::{ }; #[cfg(test)] -#[ctor::ctor] +#[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 4b947234054..fa07a7922e9 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -1176,7 +1176,7 @@ impl Buffer { fragment_start = old_fragments.start().0.full_offset(); } - // Skip over insertions that are concurrent to this edit, but have a lower lamport + // Skip over insertions that are concurrent to this edit, but have a higher lamport // timestamp. while let Some(fragment) = old_fragments.item() { if fragment_start == range.start && fragment.timestamp > timestamp { diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index a1d65dddc46..5303f2952e8 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -80,6 +80,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ], ), ("backup", &["bak"]), + ("ballerina", &["bal"]), ("bicep", &["bicep"]), ("bun", &["lockb"]), ("c", &["c", "h"]), @@ -312,6 +313,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ const FILE_ICONS: &[(&str, &str)] = &[ ("astro", "icons/file_icons/astro.svg"), ("audio", "icons/file_icons/audio.svg"), + ("ballerina", "icons/file_icons/ballerina.svg"), ("bicep", "icons/file_icons/file.svg"), ("bun", "icons/file_icons/bun.svg"), ("c", "icons/file_icons/c.svg"), diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index c9f220a923c..3d36f08aa58 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -56,6 +56,7 @@ pub struct ThemeSettings { agent_ui_font_size: Option, /// The agent buffer font size. Determines the size of user messages in the agent panel. agent_buffer_font_size: Option, + git_commit_buffer_font_size: Option, /// The font family to use for rendering in the markdown preview. /// Falls back to the UI font family if unset. markdown_preview_font_family: Option, @@ -118,6 +119,11 @@ pub struct AgentBufferFontSize(Pixels); impl Global for AgentBufferFontSize {} +#[derive(Default)] +pub struct GitCommitBufferFontSize(Pixels); + +impl Global for GitCommitBufferFontSize {} + /// Represents the selection of a theme, which can be either static or dynamic. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(untagged)] @@ -410,6 +416,14 @@ impl ThemeSettings { .unwrap_or_else(|| self.buffer_font_size(cx)) } + pub fn git_commit_buffer_font_size(&self, cx: &App) -> Pixels { + cx.try_global::() + .map(|size| size.0) + .or(self.git_commit_buffer_font_size) + .map(clamp_font_size) + .unwrap_or_else(|| self.buffer_font_size(cx)) + } + /// Returns the font family to use in the markdown preview, /// falling back to the UI font family when unset. pub fn markdown_preview_font_family(&self) -> &SharedString { @@ -458,6 +472,10 @@ impl ThemeSettings { self.agent_buffer_font_size } + pub fn git_commit_buffer_font_size_settings(&self) -> Option { + self.git_commit_buffer_font_size + } + /// Returns the buffer's line height. pub fn line_height(&self) -> f32 { f32::max(self.buffer_line_height.value(), MIN_LINE_HEIGHT) @@ -609,6 +627,22 @@ pub fn reset_agent_buffer_font_size(cx: &mut App) { } } +pub fn adjust_git_commit_buffer_font_size(cx: &mut App, f: impl FnOnce(Pixels) -> Pixels) { + let git_commit_buffer_font_size = ThemeSettings::get_global(cx).git_commit_buffer_font_size(cx); + let adjusted_size = cx + .try_global::() + .map_or(git_commit_buffer_font_size, |adjusted_size| adjusted_size.0); + cx.set_global(GitCommitBufferFontSize(clamp_font_size(f(adjusted_size)))); + cx.refresh_windows(); +} + +pub fn reset_git_commit_buffer_font_size(cx: &mut App) { + if cx.has_global::() { + cx.remove_global::(); + cx.refresh_windows(); + } +} + /// Ensures font size is within the valid range. pub fn clamp_font_size(size: Pixels) -> Pixels { size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE) @@ -658,6 +692,7 @@ impl settings::Settings for ThemeSettings { buffer_line_height: content.buffer_line_height.unwrap().into(), agent_ui_font_size: content.agent_ui_font_size.map(|s| s.into_gpui()), agent_buffer_font_size: content.agent_buffer_font_size.map(|s| s.into_gpui()), + git_commit_buffer_font_size: content.git_commit_buffer_font_size.map(|s| s.into_gpui()), markdown_preview_font_family: content .markdown_preview_font_family .as_ref() diff --git a/crates/theme_settings/src/theme_settings.rs b/crates/theme_settings/src/theme_settings.rs index b5bf1a60283..192dfa54c47 100644 --- a/crates/theme_settings/src/theme_settings.rs +++ b/crates/theme_settings/src/theme_settings.rs @@ -28,12 +28,14 @@ pub use crate::schema::{ }; use crate::settings::adjust_buffer_font_size; pub use crate::settings::{ - AgentBufferFontSize, AgentUiFontSize, BufferLineHeight, FontFamilyName, IconThemeName, - IconThemeSelection, ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, - adjust_agent_buffer_font_size, adjust_agent_ui_font_size, adjust_ui_font_size, - adjusted_font_size, appearance_to_mode, clamp_font_size, default_theme, - observe_buffer_font_size_adjustment, reset_agent_buffer_font_size, reset_agent_ui_font_size, - reset_buffer_font_size, reset_ui_font_size, set_icon_theme, set_mode, set_theme, setup_ui_font, + AgentBufferFontSize, AgentUiFontSize, BufferLineHeight, FontFamilyName, + GitCommitBufferFontSize, IconThemeName, IconThemeSelection, ThemeAppearanceMode, ThemeName, + ThemeSelection, ThemeSettings, adjust_agent_buffer_font_size, adjust_agent_ui_font_size, + adjust_git_commit_buffer_font_size, adjust_ui_font_size, adjusted_font_size, + appearance_to_mode, clamp_font_size, default_theme, observe_buffer_font_size_adjustment, + reset_agent_buffer_font_size, reset_agent_ui_font_size, reset_buffer_font_size, + reset_git_commit_buffer_font_size, reset_ui_font_size, set_icon_theme, set_mode, set_theme, + setup_ui_font, }; pub use theme::UiDensity; @@ -87,6 +89,8 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) { let mut prev_ui_font_size_settings = settings.ui_font_size_settings(); let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let mut prev_git_commit_buffer_font_size_settings = + settings.git_commit_buffer_font_size_settings(); let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0); let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); let mut prev_theme_overrides = ( @@ -101,6 +105,7 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) { let ui_font_size_settings = settings.ui_font_size_settings(); let agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let git_commit_buffer_font_size_settings = settings.git_commit_buffer_font_size_settings(); let theme_name = settings.theme.name(SystemAppearance::global(cx).0); let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); let theme_overrides = ( @@ -128,6 +133,11 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) { reset_agent_buffer_font_size(cx); } + if git_commit_buffer_font_size_settings != prev_git_commit_buffer_font_size_settings { + prev_git_commit_buffer_font_size_settings = git_commit_buffer_font_size_settings; + reset_git_commit_buffer_font_size(cx); + } + if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides { prev_theme_name = theme_name; prev_theme_overrides = theme_overrides; diff --git a/crates/time_format/src/time_format.rs b/crates/time_format/src/time_format.rs index bbf214623eb..85c79e9be4b 100644 --- a/crates/time_format/src/time_format.rs +++ b/crates/time_format/src/time_format.rs @@ -293,12 +293,14 @@ fn format_relative_date(timestamp: OffsetDateTime, reference: OffsetDateTime) -> match month_diff { 0..=1 => "1 month ago".to_string(), 2..=11 => format!("{} months ago", month_diff), + // Match git's `show_date_relative` behavior: for dates under 5 years old, + // include both years and months so, for example, 22 months is shown as + // "1 year, 10 months ago" instead of being collapsed to "1 year ago". + 12..60 => format_compound_year_month(month_diff), + // Beyond 5 years, round to the nearest year. months => { - let years = months / 12; - match years { - 1 => "1 year ago".to_string(), - _ => format!("{years} years ago"), - } + let years = (months + 6) / 12; + format!("{years} years ago") } } } @@ -307,6 +309,18 @@ fn format_relative_date(timestamp: OffsetDateTime, reference: OffsetDateTime) -> } } +fn format_compound_year_month(month_diff: usize) -> String { + let years = month_diff / 12; + let months = month_diff % 12; + let year_unit = if years == 1 { "year" } else { "years" }; + if months == 0 { + format!("{years} {year_unit} ago") + } else { + let month_unit = if months == 1 { "month" } else { "months" }; + format!("{years} {year_unit}, {months} {month_unit} ago") + } +} + /// Calculates the difference in months between two timestamps. /// The reference timestamp should always be greater than the timestamp. fn calculate_month_difference(timestamp: OffsetDateTime, reference: OffsetDateTime) -> usize { @@ -999,7 +1013,7 @@ mod tests { fn test_relative_format_years() { let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0); - // 12 months + // 12 months (exactly 1 year, no remainder) assert_eq!( format_relative_date(create_offset_datetime(1989, 4, 12, 23, 0, 0), reference), "1 year ago" @@ -1008,16 +1022,22 @@ mod tests { // 13 months assert_eq!( format_relative_date(create_offset_datetime(1989, 3, 12, 23, 0, 0), reference), - "1 year ago" + "1 year, 1 month ago" + ); + + // 22 months (regression test for issue #57907) + assert_eq!( + format_relative_date(create_offset_datetime(1988, 6, 12, 23, 0, 0), reference), + "1 year, 10 months ago" ); // 23 months assert_eq!( format_relative_date(create_offset_datetime(1988, 5, 12, 23, 0, 0), reference), - "1 year ago" + "1 year, 11 months ago" ); - // 24 months + // 24 months (exactly 2 years, no remainder) assert_eq!( format_relative_date(create_offset_datetime(1988, 4, 12, 23, 0, 0), reference), "2 years ago" @@ -1026,16 +1046,16 @@ mod tests { // 25 months assert_eq!( format_relative_date(create_offset_datetime(1988, 3, 12, 23, 0, 0), reference), - "2 years ago" + "2 years, 1 month ago" ); // 35 months assert_eq!( format_relative_date(create_offset_datetime(1987, 5, 12, 23, 0, 0), reference), - "2 years ago" + "2 years, 11 months ago" ); - // 36 months + // 36 months (exactly 3 years, no remainder) assert_eq!( format_relative_date(create_offset_datetime(1987, 4, 12, 23, 0, 0), reference), "3 years ago" @@ -1044,7 +1064,31 @@ mod tests { // 37 months assert_eq!( format_relative_date(create_offset_datetime(1987, 3, 12, 23, 0, 0), reference), - "3 years ago" + "3 years, 1 month ago" + ); + + // 59 months (just under 5-year compound cutoff) + assert_eq!( + format_relative_date(create_offset_datetime(1985, 5, 12, 23, 0, 0), reference), + "4 years, 11 months ago" + ); + + // 60 months (5 years exactly; switches to year-only) + assert_eq!( + format_relative_date(create_offset_datetime(1985, 4, 12, 23, 0, 0), reference), + "5 years ago" + ); + + // 65 months (5 years + 5 months → rounds down to 5 years) + assert_eq!( + format_relative_date(create_offset_datetime(1984, 11, 12, 23, 0, 0), reference), + "5 years ago" + ); + + // 66 months (5 years + 6 months → rounds up to 6 years) + assert_eq!( + format_relative_date(create_offset_datetime(1984, 10, 12, 23, 0, 0), reference), + "6 years ago" ); // 120 months diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 61ffd3f8175..4a762b18eef 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -40,7 +40,7 @@ chrono.workspace = true client.workspace = true cloud_api_types.workspace = true db.workspace = true -feature_flags.workspace = true + git_ui.workspace = true gpui = { workspace = true, features = ["screen-capture"] } icons.workspace = true diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index c15f840e69d..73c183289a2 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -25,7 +25,6 @@ use auto_update::AutoUpdateStatus; use call::ActiveCall; use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; -use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag}; use gpui::{ Action, Anchor, Animation, AnimationExt, AnyElement, App, Context, Element, Entity, Focusable, @@ -52,7 +51,8 @@ use ui::{ use update_version::UpdateVersion; use util::ResultExt; use workspace::{ - MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt, + MultiWorkspace, ToggleWorktreeSecurity, Workspace, + notifications::{NotifyResultExt, NotifyTaskExt as _}, }; use zed_actions::OpenRemote; @@ -184,7 +184,7 @@ impl Render for TitleBar { let show_menus = show_menus(cx); - let mut children = >::new(); + let mut children = >::new(); let mut project_name = None; let mut repository = None; @@ -455,22 +455,7 @@ impl TitleBar { titlebar }); - // The banner label stays static ("Introducing: Skills") regardless - // of whether the user had Rules to migrate; the explainer modal - // is where the migration-specific summary surfaces. Keeping the - // label static avoids the rebuild-on-migration-completion plumbing - // we'd otherwise need to dodge the title-bar-vs-migration race. - let banner = Some(cx.new(|cx| { - OnboardingBanner::new( - "Skills Migration Announcement", - IconName::Sparkle, - "Skills", - Some("Introducing:".into()), - zed_actions::agent::OpenRulesToSkillsMigrationInfo.boxed_clone(), - cx, - ) - .visible_when(|cx| cx.has_flag::()) - })); + let banner = None; let mut this = Self { platform_titlebar, @@ -641,13 +626,8 @@ impl TitleBar { } pub fn render_restricted_mode(&self, cx: &mut Context) -> Option { - let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees - .read(cx) - .has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx) - }) - .unwrap_or(false); + let has_restricted_worktrees = + TrustedWorktrees::has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx); if !has_restricted_worktrees { return None; } @@ -1180,6 +1160,7 @@ impl TitleBar { let show_update_button = self.update_version.read(cx).show_update_in_menu_bar(); let user_store = self.user_store.clone(); + let workspace = self.workspace.clone(); let user_store_read = user_store.read(cx); let user = user_store_read.current_user(); @@ -1246,6 +1227,7 @@ impl TitleBar { let current_organization = current_organization.clone(); let organizations = organizations.clone(); let user_store = user_store.clone(); + let workspace = workspace.clone(); let ai_enabled = !project::DisableAiSettings::get_global(cx).disable_ai; let current_layout = AgentSettings::get_layout(cx); @@ -1337,11 +1319,13 @@ impl TitleBar { { let user_store = user_store.clone(); let organization = organization.clone(); - move |_window, cx| { - user_store.update(cx, |user_store, cx| { + let workspace = workspace.clone(); + move |window, cx| { + let task = user_store.update(cx, |user_store, cx| { user_store - .set_current_organization(organization.clone(), cx); + .set_current_organization(organization.clone(), cx) }); + task.detach_and_notify_err(workspace.clone(), window, cx); } }, ); diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 4ae0e6d2e46..7999a300648 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -20,7 +20,9 @@ gpui.workspace = true gpui_macros.workspace = true icons.workspace = true itertools.workspace = true +log.workspace = true menu.workspace = true +num-format.workspace = true schemars.workspace = true serde.workspace = true smallvec.workspace = true @@ -35,5 +37,8 @@ windows.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } +[package.metadata.cargo-machete] +ignored = ["log"] + [features] default = [] diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs index 91b5385c45b..479ae55ceb5 100644 --- a/crates/ui/src/components/ai.rs +++ b/crates/ui/src/components/ai.rs @@ -1,11 +1,11 @@ mod agent_setup_button; mod ai_setting_item; mod configured_api_card; -mod parallel_agents_illustration; +mod skills_illustration; mod thread_item; pub use agent_setup_button::*; pub use ai_setting_item::*; pub use configured_api_card::*; -pub use parallel_agents_illustration::*; +pub use skills_illustration::*; pub use thread_item::*; diff --git a/crates/ui/src/components/ai/agent_setup_button.rs b/crates/ui/src/components/ai/agent_setup_button.rs index d56baf91e13..c30add743b6 100644 --- a/crates/ui/src/components/ai/agent_setup_button.rs +++ b/crates/ui/src/components/ai/agent_setup_button.rs @@ -57,8 +57,21 @@ impl Component for AgentSetupButton { ComponentScope::Agent } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - None + fn description() -> &'static str { + "A large, two-section button used in agent onboarding flows \ + to launch the setup of a provider or tool, showing an icon, name, \ + and current setup state." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + single_example( + "Default", + AgentSetupButton::new("preview") + .icon(Icon::new(IconName::ZedAgent)) + .name("Zed Agent") + .into_any_element(), + ) + .into_any_element() } } diff --git a/crates/ui/src/components/ai/ai_setting_item.rs b/crates/ui/src/components/ai/ai_setting_item.rs index bfb55e4c7da..372ff75597d 100644 --- a/crates/ui/src/components/ai/ai_setting_item.rs +++ b/crates/ui/src/components/ai/ai_setting_item.rs @@ -10,6 +10,7 @@ pub enum AiSettingItemStatus { Running, Error, AuthRequired, + ClientSecretRequired, Authenticating, } @@ -21,6 +22,7 @@ impl AiSettingItemStatus { Self::Running => "Server is active.", Self::Error => "Server has an error.", Self::AuthRequired => "Authentication required.", + Self::ClientSecretRequired => "Client secret required.", Self::Authenticating => "Waiting for authorization…", } } @@ -31,7 +33,7 @@ impl AiSettingItemStatus { Self::Starting | Self::Authenticating => Some(Color::Muted), Self::Running => Some(Color::Success), Self::Error => Some(Color::Error), - Self::AuthRequired => Some(Color::Warning), + Self::AuthRequired | Self::ClientSecretRequired => Some(Color::Warning), } } @@ -242,7 +244,12 @@ impl Component for AiSettingItem { ComponentScope::Agent } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A reusable row used in AI-related configuration lists to display a \ + server or provider's name, source, current status, and associated actions." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { v_flex() .w_80() @@ -401,6 +408,6 @@ impl Component for AiSettingItem { ), ]; - Some(example_group(examples).vertical().into_any_element()) + example_group(examples).vertical().into_any_element() } } diff --git a/crates/ui/src/components/ai/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs index c9fd129a678..9e89acc4962 100644 --- a/crates/ui/src/components/ai/configured_api_card.rs +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -57,7 +57,12 @@ impl Component for ConfiguredApiCard { ComponentScope::Agent } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A card used in AI provider settings to indicate that an API has been \ + configured, with an optional action button to manage or reconfigure it." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { v_flex() .w_72() @@ -101,7 +106,7 @@ impl Component for ConfiguredApiCard { ), ]; - Some(example_group(examples).into_any_element()) + example_group(examples).into_any_element() } } diff --git a/crates/ui/src/components/ai/parallel_agents_illustration.rs b/crates/ui/src/components/ai/parallel_agents_illustration.rs deleted file mode 100644 index e7694e9359f..00000000000 --- a/crates/ui/src/components/ai/parallel_agents_illustration.rs +++ /dev/null @@ -1,272 +0,0 @@ -use crate::{DiffStat, Divider, prelude::*}; -use gpui::{Animation, AnimationExt, pulsating_between}; -use std::time::Duration; - -#[derive(IntoElement)] -pub struct ParallelAgentsIllustration; - -impl ParallelAgentsIllustration { - pub fn new() -> Self { - Self - } -} - -impl RenderOnce for ParallelAgentsIllustration { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let icon_container = || h_flex().size_4().flex_shrink_0().justify_center(); - - let loading_bar = |id: &'static str, width: DefiniteLength, duration_ms: u64| { - div() - .h(rems_from_px(5.)) - .w(width) - .rounded_full() - .bg(cx.theme().colors().element_selected) - .with_animation( - id, - Animation::new(Duration::from_millis(duration_ms)) - .repeat() - .with_easing(pulsating_between(0.1, 0.8)), - |label, delta| label.opacity(delta), - ) - }; - - let skeleton_bar = |width: DefiniteLength| { - div().h(rems_from_px(5.)).w(width).rounded_full().bg(cx - .theme() - .colors() - .text_muted - .opacity(0.05)) - }; - - let time = - |time: SharedString| Label::new(time).size(LabelSize::XSmall).color(Color::Muted); - - let worktree = |worktree: SharedString| { - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::GitWorktree) - .color(Color::Muted) - .size(IconSize::Indicator), - ) - .child( - Label::new(worktree) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }; - - let dot_separator = || { - Label::new("•") - .size(LabelSize::Small) - .color(Color::Muted) - .alpha(0.5) - }; - - let agent = |title: SharedString, icon: IconName, selected: bool, data: Vec| { - v_flex() - .when(selected, |this| { - this.bg(cx.theme().colors().element_active.opacity(0.2)) - }) - .p_1() - .child( - h_flex() - .w_full() - .gap_1() - .child( - icon_container() - .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), - ) - .map(|this| { - if selected { - this.child( - Label::new(title) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - } else { - this.child(skeleton_bar(relative(0.7))) - } - }), - ) - .child( - h_flex() - .opacity(0.8) - .w_full() - .gap_1() - .child(icon_container()) - .children(data), - ) - }; - - let agents = v_flex() - .col_span(3) - .bg(cx.theme().colors().elevated_surface_background) - .child(agent( - "Fix branch label".into(), - IconName::ZedAgent, - true, - vec![ - worktree("bug-fix".into()).into_any_element(), - dot_separator().into_any_element(), - DiffStat::new("ds", 5, 2) - .label_size(LabelSize::XSmall) - .into_any_element(), - dot_separator().into_any_element(), - time("2m".into()).into_any_element(), - ], - )) - .child(Divider::horizontal()) - .child(agent( - "Improve thread id".into(), - IconName::AiClaude, - false, - vec![ - DiffStat::new("ds", 120, 84) - .label_size(LabelSize::XSmall) - .into_any_element(), - dot_separator().into_any_element(), - time("16m".into()).into_any_element(), - ], - )) - .child(Divider::horizontal()) - .child(agent( - "Refactor archive view".into(), - IconName::AiOpenAi, - false, - vec![ - worktree("silent-forest".into()).into_any_element(), - dot_separator().into_any_element(), - time("37m".into()).into_any_element(), - ], - )); - - let thread_view = v_flex() - .col_span(3) - .h_full() - .flex_1() - .border_l_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .bg(cx.theme().colors().panel_background) - .child( - h_flex() - .px_1p5() - .py_0p5() - .w_full() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .child( - Label::new("Fix branch label") - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - Icon::new(IconName::Plus) - .size(IconSize::Indicator) - .color(Color::Muted), - ), - ) - .child( - div().p_1().child( - v_flex() - .px_1() - .py_1p5() - .gap_1() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .bg(cx.theme().colors().editor_background) - .rounded_sm() - .shadow_sm() - .child(skeleton_bar(relative(0.7))) - .child(skeleton_bar(relative(0.2))), - ), - ) - .child( - v_flex() - .p_2() - .gap_1() - .child(loading_bar("a", relative(0.55), 2200)) - .child(loading_bar("b", relative(0.75), 2000)) - .child(loading_bar("c", relative(0.25), 2400)), - ); - - let file_row = |indent: usize, is_folder: bool, bar_width: Rems| { - let indent_px = rems_from_px((indent as f32) * 4.0); - - h_flex() - .px_2() - .py_px() - .gap_1() - .pl(indent_px) - .child( - icon_container().child( - Icon::new(if is_folder { - IconName::FolderOpen - } else { - IconName::FileRust - }) - .size(IconSize::Indicator) - .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.2))), - ), - ) - .child( - div().h_1p5().w(bar_width).rounded_sm().bg(cx - .theme() - .colors() - .text - .opacity(if is_folder { 0.15 } else { 0.1 })), - ) - }; - - let project_panel = v_flex() - .col_span(1) - .h_full() - .flex_1() - .border_l_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .bg(cx.theme().colors().panel_background) - .child( - v_flex() - .child(file_row(0, true, rems_from_px(42.0))) - .child(file_row(1, true, rems_from_px(28.0))) - .child(file_row(2, false, rems_from_px(52.0))) - .child(file_row(2, false, rems_from_px(36.0))) - .child(file_row(2, false, rems_from_px(44.0))) - .child(file_row(1, true, rems_from_px(34.0))) - .child(file_row(2, false, rems_from_px(48.0))) - .child(file_row(2, true, rems_from_px(26.0))) - .child(file_row(3, false, rems_from_px(40.0))) - .child(file_row(3, false, rems_from_px(56.0))) - .child(file_row(1, false, rems_from_px(38.0))) - .child(file_row(0, true, rems_from_px(30.0))) - .child(file_row(1, false, rems_from_px(46.0))) - .child(file_row(1, false, rems_from_px(32.0))), - ); - - let workspace = div() - .absolute() - .top_8() - .grid() - .grid_cols(7) - .w(rems_from_px(380.)) - .rounded_t_sm() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.5)) - .shadow_md() - .child(agents) - .child(thread_view) - .child(project_panel); - - h_flex() - .relative() - .h(rems_from_px(180.)) - .bg(cx.theme().colors().editor_background.opacity(0.6)) - .justify_center() - .items_end() - .rounded_t_md() - .overflow_hidden() - .bg(gpui::black().opacity(0.2)) - .child(workspace) - } -} diff --git a/crates/ui/src/components/ai/skills_illustration.rs b/crates/ui/src/components/ai/skills_illustration.rs new file mode 100644 index 00000000000..8f2de0078d6 --- /dev/null +++ b/crates/ui/src/components/ai/skills_illustration.rs @@ -0,0 +1,86 @@ +use crate::prelude::*; +use gpui::{linear_color_stop, linear_gradient}; + +#[derive(IntoElement)] +pub struct SkillsIllustration; + +impl SkillsIllustration { + pub fn new() -> Self { + Self + } +} + +impl RenderOnce for SkillsIllustration { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let skill_crease = |label: SharedString, source: SharedString| { + h_flex() + .py_1() + .px_1p5() + .gap_1p5() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().element_active.opacity(0.5)) + .justify_center() + .rounded_md() + .shadow_sm() + .child( + Icon::new(IconName::Sparkle) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .child(Label::new(label).size(LabelSize::XSmall).buffer_font(cx)) + .child( + Label::new(format!("({source})")) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx), + ) + }; + + let skill_list = v_flex() + .absolute() + .top_8() + .gap_2p5() + .items_center() + .child( + h_flex() + .gap_2p5() + .child(skill_crease("img-gen".into(), "studio".into())) + .child(skill_crease("frontend-design".into(), "global".into())), + ) + .child( + h_flex() + .gap_2p5() + .child(skill_crease("brainstorming".into(), "global".into())) + .child(skill_crease("borrow-checker-expert".into(), "zed".into())), + ) + .child( + h_flex() + .gap_2p5() + .child(skill_crease("grill-with-docs".into(), "global".into())) + .child(skill_crease("video-edit".into(), "studio".into())), + ); + + let gradient_bg = cx.theme().colors().editor_background; + let gradient_fade = div() + .absolute() + .rounded_t_md() + .inset_0() + .bg(linear_gradient( + 0., + linear_color_stop(gradient_bg.opacity(0.8), 0.), + linear_color_stop(gradient_bg.opacity(0.0), 1.), + )); + + v_flex() + .relative() + .h(rems_from_px(150.)) + .justify_end() + .items_center() + .rounded_t_md() + .overflow_hidden() + .bg(gpui::black().opacity(0.2)) + .child(skill_list) + .child(gradient_fade) + } +} diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index c962aa4f9f2..439e11f9d22 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -39,6 +39,7 @@ pub struct ThreadItem { icon_visible: bool, custom_icon_from_external_svg: Option, title: SharedString, + title_slot: Option, title_label_color: Option, title_generating: bool, highlight_positions: Vec, @@ -49,6 +50,7 @@ pub struct ThreadItem { focused: bool, hovered: bool, rounded: bool, + is_truncated: bool, added: Option, removed: Option, project_paths: Option>, @@ -71,6 +73,7 @@ impl ThreadItem { icon_visible: true, custom_icon_from_external_svg: None, title: title.into(), + title_slot: None, title_label_color: None, title_generating: false, highlight_positions: Vec::new(), @@ -81,6 +84,7 @@ impl ThreadItem { focused: false, hovered: false, rounded: false, + is_truncated: true, added: None, removed: None, project_paths: None, @@ -140,6 +144,11 @@ impl ThreadItem { self } + pub fn title_slot(mut self, element: impl IntoElement) -> Self { + self.title_slot = Some(element.into_any_element()); + self + } + pub fn highlight_positions(mut self, positions: Vec) -> Self { self.highlight_positions = positions; self @@ -200,6 +209,11 @@ impl ThreadItem { self } + pub fn is_truncated(mut self, is_truncated: bool) -> Self { + self.is_truncated = is_truncated; + self + } + pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -317,7 +331,9 @@ impl RenderOnce for ThreadItem { let title = self.title; let highlight_positions = self.highlight_positions; - let title_label = if self.title_generating { + let title_label = if let Some(title_slot) = self.title_slot { + title_slot + } else if self.title_generating { Label::new(title) .color(Color::Muted) .with_animation( @@ -403,6 +419,7 @@ impl RenderOnce for ThreadItem { h_flex() .min_w_0() .w_full() + .h_6() .gap_2() .justify_between() .child( @@ -414,7 +431,7 @@ impl RenderOnce for ThreadItem { .child(icon) .child(title_label), ) - .child(gradient_overlay) + .when(self.is_truncated, |this| this.child(gradient_overlay)) .when(self.hovered, |this| { this.when_some(self.action_slot, |this, slot| { let overlay = GradientFade::new(base_bg, hover_bg, hover_bg) @@ -593,7 +610,12 @@ impl Component for ThreadItem { ComponentScope::Agent } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A row representing an agent thread in a list, showing its title, status, \ + timestamp, and contextual metadata such as worktree and branch information." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let color = cx.theme().colors(); let bg = color .title_bar_background @@ -930,10 +952,8 @@ impl Component for ThreadItem { ), ]; - Some( - example_group(thread_item_examples) - .vertical() - .into_any_element(), - ) + example_group(thread_item_examples) + .vertical() + .into_any_element() } } diff --git a/crates/ui/src/components/avatar.rs b/crates/ui/src/components/avatar.rs index 6ae969246f8..6d12a0d3d22 100644 --- a/crates/ui/src/components/avatar.rs +++ b/crates/ui/src/components/avatar.rs @@ -235,15 +235,14 @@ impl Component for Avatar { ComponentScope::Collaboration } - fn description() -> Option<&'static str> { - Some(Avatar::DOCS) + fn description() -> &'static str { + Avatar::DOCS } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let example_avatar = "https://avatars.githubusercontent.com/u/1714999?v=4"; - Some( - v_flex() + v_flex() .gap_6() .children(vec![ example_group(vec![ @@ -297,7 +296,6 @@ impl Component for Avatar { ], ), ]) - .into_any_element(), - ) + .into_any_element() } } diff --git a/crates/ui/src/components/banner.rs b/crates/ui/src/components/banner.rs index 19795c2c7c8..4568959a373 100644 --- a/crates/ui/src/components/banner.rs +++ b/crates/ui/src/components/banner.rs @@ -134,7 +134,12 @@ impl Component for Banner { ComponentScope::DataDisplay } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "A non-blocking, severity-aware message strip used to surface informative, \ + success, warning, or error messages without interrupting the user." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let severity_examples = vec![ single_example( "Default", @@ -179,10 +184,8 @@ impl Component for Banner { ), ]; - Some( - example_group(severity_examples) - .vertical() - .into_any_element(), - ) + example_group(severity_examples) + .vertical() + .into_any_element() } } diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 61cb4a88556..5278ffa2118 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -448,130 +448,128 @@ impl Component for Button { "ButtonA" } - fn description() -> Option<&'static str> { - Some("A button triggers an event or action.") + fn description() -> &'static str { + "A button triggers an event or action." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Button Styles", - vec![ - single_example( - "Default", - Button::new("default", "Default").into_any_element(), - ), - single_example( - "Filled", - Button::new("filled", "Filled") - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "Subtle", - Button::new("outline", "Subtle") - .style(ButtonStyle::Subtle) - .into_any_element(), - ), - single_example( - "Tinted", - Button::new("tinted_accent_style", "Accent") - .style(ButtonStyle::Tinted(TintColor::Accent)) - .into_any_element(), - ), - single_example( - "Transparent", - Button::new("transparent", "Transparent") - .style(ButtonStyle::Transparent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Tint Styles", - vec![ - single_example( - "Accent", - Button::new("tinted_accent", "Accent") - .style(ButtonStyle::Tinted(TintColor::Accent)) - .into_any_element(), - ), - single_example( - "Error", - Button::new("tinted_negative", "Error") - .style(ButtonStyle::Tinted(TintColor::Error)) - .into_any_element(), - ), - single_example( - "Warning", - Button::new("tinted_warning", "Warning") - .style(ButtonStyle::Tinted(TintColor::Warning)) - .into_any_element(), - ), - single_example( - "Success", - Button::new("tinted_positive", "Success") - .style(ButtonStyle::Tinted(TintColor::Success)) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Special States", - vec![ - single_example( - "Default", - Button::new("default_state", "Default").into_any_element(), - ), - single_example( - "Disabled", - Button::new("disabled", "Disabled") - .disabled(true) - .into_any_element(), - ), - single_example( - "Selected", - Button::new("selected", "Selected") - .toggle_state(true) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Buttons with Icons", - vec![ - single_example( - "Start Icon", - Button::new("icon_start", "Start Icon") - .start_icon(Icon::new(IconName::Check)) - .into_any_element(), - ), - single_example( - "End Icon", - Button::new("icon_end", "End Icon") - .end_icon(Icon::new(IconName::Check)) - .into_any_element(), - ), - single_example( - "Both Icons", - Button::new("both_icons", "Both Icons") - .start_icon(Icon::new(IconName::Check)) - .end_icon(Icon::new(IconName::ChevronDown)) - .into_any_element(), - ), - single_example( - "Icon Color", - Button::new("icon_color", "Icon Color") - .start_icon(Icon::new(IconName::Check).color(Color::Accent)) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Button Styles", + vec![ + single_example( + "Default", + Button::new("default", "Default").into_any_element(), + ), + single_example( + "Filled", + Button::new("filled", "Filled") + .style(ButtonStyle::Filled) + .into_any_element(), + ), + single_example( + "Subtle", + Button::new("outline", "Subtle") + .style(ButtonStyle::Subtle) + .into_any_element(), + ), + single_example( + "Tinted", + Button::new("tinted_accent_style", "Accent") + .style(ButtonStyle::Tinted(TintColor::Accent)) + .into_any_element(), + ), + single_example( + "Transparent", + Button::new("transparent", "Transparent") + .style(ButtonStyle::Transparent) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Tint Styles", + vec![ + single_example( + "Accent", + Button::new("tinted_accent", "Accent") + .style(ButtonStyle::Tinted(TintColor::Accent)) + .into_any_element(), + ), + single_example( + "Error", + Button::new("tinted_negative", "Error") + .style(ButtonStyle::Tinted(TintColor::Error)) + .into_any_element(), + ), + single_example( + "Warning", + Button::new("tinted_warning", "Warning") + .style(ButtonStyle::Tinted(TintColor::Warning)) + .into_any_element(), + ), + single_example( + "Success", + Button::new("tinted_positive", "Success") + .style(ButtonStyle::Tinted(TintColor::Success)) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Special States", + vec![ + single_example( + "Default", + Button::new("default_state", "Default").into_any_element(), + ), + single_example( + "Disabled", + Button::new("disabled", "Disabled") + .disabled(true) + .into_any_element(), + ), + single_example( + "Selected", + Button::new("selected", "Selected") + .toggle_state(true) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Buttons with Icons", + vec![ + single_example( + "Start Icon", + Button::new("icon_start", "Start Icon") + .start_icon(Icon::new(IconName::Check)) + .into_any_element(), + ), + single_example( + "End Icon", + Button::new("icon_end", "End Icon") + .end_icon(Icon::new(IconName::Check)) + .into_any_element(), + ), + single_example( + "Both Icons", + Button::new("both_icons", "Both Icons") + .start_icon(Icon::new(IconName::Check)) + .end_icon(Icon::new(IconName::ChevronDown)) + .into_any_element(), + ), + single_example( + "Icon Color", + Button::new("icon_color", "Icon Color") + .start_icon(Icon::new(IconName::Check).color(Color::Accent)) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 2b0fa20683c..05999daef48 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -793,88 +793,86 @@ impl Component for ButtonLike { "ButtonZ" } - fn description() -> Option<&'static str> { - Some(ButtonLike::DOCS) + fn description() -> &'static str { + ButtonLike::DOCS } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group(vec![ - single_example( - "Default", - ButtonLike::new("default") - .child(Label::new("Default")) - .into_any_element(), - ), - single_example( - "Filled", - ButtonLike::new("filled") - .style(ButtonStyle::Filled) - .child(Label::new("Filled")) - .into_any_element(), - ), - single_example( - "Subtle", - ButtonLike::new("outline") - .style(ButtonStyle::Subtle) - .child(Label::new("Subtle")) - .into_any_element(), - ), - single_example( - "Tinted", - ButtonLike::new("tinted_accent_style") - .style(ButtonStyle::Tinted(TintColor::Accent)) - .child(Label::new("Accent")) - .into_any_element(), - ), - single_example( - "Transparent", - ButtonLike::new("transparent") - .style(ButtonStyle::Transparent) - .child(Label::new("Transparent")) - .into_any_element(), - ), - ]), - example_group_with_title( - "Button Group Constructors", - vec![ - single_example( - "Left Rounded", - ButtonLike::new_rounded_left("left_rounded") - .child(Label::new("Left Rounded")) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "Right Rounded", - ButtonLike::new_rounded_right("right_rounded") - .child(Label::new("Right Rounded")) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "Button Group", - h_flex() - .gap_px() - .child( - ButtonLike::new_rounded_left("bg_left") - .child(Label::new("Left")) - .style(ButtonStyle::Filled), - ) - .child( - ButtonLike::new_rounded_right("bg_right") - .child(Label::new("Right")) - .style(ButtonStyle::Filled), - ) - .into_any_element(), - ), - ], + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group(vec![ + single_example( + "Default", + ButtonLike::new("default") + .child(Label::new("Default")) + .into_any_element(), ), - ]) - .into_any_element(), - ) + single_example( + "Filled", + ButtonLike::new("filled") + .style(ButtonStyle::Filled) + .child(Label::new("Filled")) + .into_any_element(), + ), + single_example( + "Subtle", + ButtonLike::new("outline") + .style(ButtonStyle::Subtle) + .child(Label::new("Subtle")) + .into_any_element(), + ), + single_example( + "Tinted", + ButtonLike::new("tinted_accent_style") + .style(ButtonStyle::Tinted(TintColor::Accent)) + .child(Label::new("Accent")) + .into_any_element(), + ), + single_example( + "Transparent", + ButtonLike::new("transparent") + .style(ButtonStyle::Transparent) + .child(Label::new("Transparent")) + .into_any_element(), + ), + ]), + example_group_with_title( + "Button Group Constructors", + vec![ + single_example( + "Left Rounded", + ButtonLike::new_rounded_left("left_rounded") + .child(Label::new("Left Rounded")) + .style(ButtonStyle::Filled) + .into_any_element(), + ), + single_example( + "Right Rounded", + ButtonLike::new_rounded_right("right_rounded") + .child(Label::new("Right Rounded")) + .style(ButtonStyle::Filled) + .into_any_element(), + ), + single_example( + "Button Group", + h_flex() + .gap_px() + .child( + ButtonLike::new_rounded_left("bg_left") + .child(Label::new("Left")) + .style(ButtonStyle::Filled), + ) + .child( + ButtonLike::new_rounded_right("bg_right") + .child(Label::new("Right")) + .style(ButtonStyle::Filled), + ) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/button/button_link.rs b/crates/ui/src/components/button/button_link.rs index caffe2772bc..841df96f555 100644 --- a/crates/ui/src/components/button/button_link.rs +++ b/crates/ui/src/components/button/button_link.rs @@ -81,22 +81,20 @@ impl Component for ButtonLink { ComponentScope::Navigation } - fn description() -> Option<&'static str> { - Some("A button that opens a URL.") + fn description() -> &'static str { + "A button that opens a URL." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .child( - example_group(vec![single_example( - "Simple", - ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(), - )]) - .vertical(), - ) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .child( + example_group(vec![single_example( + "Simple", + ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(), + )]) + .vertical(), + ) + .into_any_element() } } diff --git a/crates/ui/src/components/button/copy_button.rs b/crates/ui/src/components/button/copy_button.rs index c31d0eabbd6..924d34e7c2f 100644 --- a/crates/ui/src/components/button/copy_button.rs +++ b/crates/ui/src/components/button/copy_button.rs @@ -139,11 +139,11 @@ impl Component for CopyButton { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some("An icon button that encapsulates the logic to copy a string into the clipboard.") + fn description() -> &'static str { + "An icon button that encapsulates the logic to copy a string into the clipboard." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let label_text = "Here's an example label"; let examples = vec![ @@ -195,6 +195,6 @@ impl Component for CopyButton { ), ]; - Some(example_group(examples).vertical().into_any_element()) + example_group(examples).vertical().into_any_element() } } diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index a103ddf169a..246f46b911e 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -2,7 +2,8 @@ use gpui::{AnyView, DefiniteLength, Hsla}; use super::button_like::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle}; use crate::{ - ElevationIndex, Icon, IconWithIndicator, Indicator, SelectableButton, TintColor, prelude::*, + ElevationIndex, Icon, IconWithIndicator, Indicator, SelectableButton, TintColor, Tooltip, + prelude::*, }; use crate::{IconName, IconSize}; @@ -240,160 +241,171 @@ impl Component for IconButton { "ButtonB" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Icon Button Styles", - vec![ - single_example( - "Default", - IconButton::new("default", IconName::Check) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Filled", - IconButton::new("filled", IconName::Check) - .layer(ElevationIndex::Background) - .style(ButtonStyle::Filled) - .into_any_element(), - ), - single_example( - "Subtle", - IconButton::new("subtle", IconName::Check) - .layer(ElevationIndex::Background) - .style(ButtonStyle::Subtle) - .into_any_element(), - ), - single_example( - "Tinted", - IconButton::new("tinted", IconName::Check) - .layer(ElevationIndex::Background) - .style(ButtonStyle::Tinted(TintColor::Accent)) - .into_any_element(), - ), - single_example( - "Transparent", - IconButton::new("transparent", IconName::Check) - .layer(ElevationIndex::Background) - .style(ButtonStyle::Transparent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Icon Button Shapes", - vec![ - single_example( - "Square", - IconButton::new("square", IconName::Check) - .shape(IconButtonShape::Square) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Wide", - IconButton::new("wide", IconName::Check) - .shape(IconButtonShape::Wide) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Icon Button Sizes", - vec![ - single_example( - "XSmall", - IconButton::new("xsmall", IconName::Check) - .icon_size(IconSize::XSmall) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Small", - IconButton::new("small", IconName::Check) - .icon_size(IconSize::Small) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Medium", - IconButton::new("medium", IconName::Check) - .icon_size(IconSize::Medium) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "XLarge", - IconButton::new("xlarge", IconName::Check) - .icon_size(IconSize::XLarge) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Special States", - vec![ - single_example( - "Disabled", - IconButton::new("disabled", IconName::Check) - .disabled(true) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "Selected", - IconButton::new("selected", IconName::Check) - .toggle_state(true) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "With Indicator", - IconButton::new("indicator", IconName::Check) - .indicator(Indicator::dot().color(Color::Success)) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Custom Colors", - vec![ - single_example( - "Custom Icon Color", - IconButton::new("custom_color", IconName::Check) - .icon_color(Color::Accent) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - single_example( - "With Alpha", - IconButton::new("alpha", IconName::Check) - .alpha(0.5) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::Background) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn description() -> &'static str { + "A compact button that displays a single icon with an optional tooltip.\ + The most frequently used button in the Zed codebase." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Icon Button Styles", + vec![ + single_example( + "Default", + IconButton::new("default", IconName::Check) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Filled", + IconButton::new("filled", IconName::Check) + .layer(ElevationIndex::Background) + .style(ButtonStyle::Filled) + .into_any_element(), + ), + single_example( + "Subtle", + IconButton::new("subtle", IconName::Check) + .layer(ElevationIndex::Background) + .style(ButtonStyle::Subtle) + .into_any_element(), + ), + single_example( + "Tinted", + IconButton::new("tinted", IconName::Check) + .layer(ElevationIndex::Background) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .into_any_element(), + ), + single_example( + "Transparent", + IconButton::new("transparent", IconName::Check) + .layer(ElevationIndex::Background) + .style(ButtonStyle::Transparent) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Icon Button Shapes", + vec![ + single_example( + "Square", + IconButton::new("square", IconName::Check) + .shape(IconButtonShape::Square) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Wide", + IconButton::new("wide", IconName::Check) + .shape(IconButtonShape::Wide) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Icon Button Sizes", + vec![ + single_example( + "XSmall", + IconButton::new("xsmall", IconName::Check) + .icon_size(IconSize::XSmall) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Small", + IconButton::new("small", IconName::Check) + .icon_size(IconSize::Small) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Medium", + IconButton::new("medium", IconName::Check) + .icon_size(IconSize::Medium) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "XLarge", + IconButton::new("xlarge", IconName::Check) + .icon_size(IconSize::XLarge) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Special States", + vec![ + single_example( + "Disabled", + IconButton::new("disabled", IconName::Check) + .disabled(true) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "Selected", + IconButton::new("selected", IconName::Check) + .toggle_state(true) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "With Indicator", + IconButton::new("indicator", IconName::Check) + .indicator(Indicator::dot().color(Color::Success)) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "With Tooltip", + IconButton::new("tooltip", IconName::Check) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .tooltip(Tooltip::text("As mentioned - with a tooltip")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Custom Colors", + vec![ + single_example( + "Custom Icon Color", + IconButton::new("custom_color", IconName::Check) + .icon_color(Color::Accent) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + single_example( + "With Alpha", + IconButton::new("alpha", IconName::Check) + .alpha(0.5) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::Background) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index e6821e94900..7871ea3e98c 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/crates/ui/src/components/button/split_button.rs @@ -94,6 +94,7 @@ impl RenderOnce for SplitButton { offset: point(px(0.), px(1.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }]) }) } diff --git a/crates/ui/src/components/button/toggle_button.rs b/crates/ui/src/components/button/toggle_button.rs index 5cecfef0625..395154cdf6e 100644 --- a/crates/ui/src/components/button/toggle_button.rs +++ b/crates/ui/src/components/button/toggle_button.rs @@ -424,346 +424,241 @@ impl Component "ButtonG" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Transparent Variant", - vec![ - single_example( - "Single Row Group", - ToggleButtonGroup::single_row( - "single_row_test", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - ) - .selected_index(1) - .into_any_element(), - ), - single_example( - "Single Row Group with icons", - ToggleButtonGroup::single_row( - "single_row_test_icon", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(1) - .into_any_element(), - ), - single_example( - "Multiple Row Group", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - [ - ToggleButtonSimple::new("Fourth", |_, _, _| {}), - ToggleButtonSimple::new("Fifth", |_, _, _| {}), - ToggleButtonSimple::new("Sixth", |_, _, _| {}), - ], - ) - .selected_index(3) - .into_any_element(), - ), - single_example( - "Multiple Row Group with Icons", - ToggleButtonGroup::two_rows( - "multiple_row_test_icons", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - [ - ToggleButtonWithIcon::new( - "Fourth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Fifth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Sixth", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(3) - .into_any_element(), - ), + fn description() -> &'static str { + "A grouped set of toggle buttons arranged in rows and columns, \ + where each button represents a mutually exclusive option in a segmented control." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Transparent Variant", + vec![ + single_example( + "Single Row Group", + ToggleButtonGroup::single_row( + "single_row_test", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + ) + .selected_index(1) + .into_any_element(), + ), + single_example( + "Single Row Group with icons", + ToggleButtonGroup::single_row( + "single_row_test_icon", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(1) + .into_any_element(), + ), + single_example( + "Multiple Row Group", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + [ + ToggleButtonSimple::new("Fourth", |_, _, _| {}), + ToggleButtonSimple::new("Fifth", |_, _, _| {}), + ToggleButtonSimple::new("Sixth", |_, _, _| {}), + ], + ) + .selected_index(3) + .into_any_element(), + ), + single_example( + "Multiple Row Group with Icons", + ToggleButtonGroup::two_rows( + "multiple_row_test_icons", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + [ + ToggleButtonWithIcon::new("Fourth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Fifth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Sixth", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(3) + .into_any_element(), + ), + ], + )]) + .children(vec![example_group_with_title( + "Outlined Variant", + vec![ + single_example( + "Single Row Group", + ToggleButtonGroup::single_row( + "single_row_test_outline", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + ) + .selected_index(1) + .style(ToggleButtonGroupStyle::Outlined) + .into_any_element(), + ), + single_example( + "Single Row Group with icons", + ToggleButtonGroup::single_row( + "single_row_test_icon_outlined", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(1) + .style(ToggleButtonGroupStyle::Outlined) + .into_any_element(), + ), + single_example( + "Multiple Row Group", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + [ + ToggleButtonSimple::new("Fourth", |_, _, _| {}), + ToggleButtonSimple::new("Fifth", |_, _, _| {}), + ToggleButtonSimple::new("Sixth", |_, _, _| {}), + ], + ) + .selected_index(3) + .style(ToggleButtonGroupStyle::Outlined) + .into_any_element(), + ), + single_example( + "Multiple Row Group with Icons", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + [ + ToggleButtonWithIcon::new("Fourth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Fifth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Sixth", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(3) + .style(ToggleButtonGroupStyle::Outlined) + .into_any_element(), + ), + ], + )]) + .children(vec![example_group_with_title( + "Filled Variant", + vec![ + single_example( + "Single Row Group", + ToggleButtonGroup::single_row( + "single_row_test_outline", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + ) + .selected_index(2) + .style(ToggleButtonGroupStyle::Filled) + .into_any_element(), + ), + single_example( + "Single Row Group with icons", + ToggleButtonGroup::single_row( + "single_row_test_icon_outlined", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(1) + .style(ToggleButtonGroupStyle::Filled) + .into_any_element(), + ), + single_example( + "Multiple Row Group", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonSimple::new("First", |_, _, _| {}), + ToggleButtonSimple::new("Second", |_, _, _| {}), + ToggleButtonSimple::new("Third", |_, _, _| {}), + ], + [ + ToggleButtonSimple::new("Fourth", |_, _, _| {}), + ToggleButtonSimple::new("Fifth", |_, _, _| {}), + ToggleButtonSimple::new("Sixth", |_, _, _| {}), + ], + ) + .selected_index(3) + .width(rems_from_px(100.)) + .style(ToggleButtonGroupStyle::Filled) + .into_any_element(), + ), + single_example( + "Multiple Row Group with Icons", + ToggleButtonGroup::two_rows( + "multiple_row_test", + [ + ToggleButtonWithIcon::new("First", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Second", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Third", IconName::AiZed, |_, _, _| {}), + ], + [ + ToggleButtonWithIcon::new("Fourth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Fifth", IconName::AiZed, |_, _, _| {}), + ToggleButtonWithIcon::new("Sixth", IconName::AiZed, |_, _, _| {}), + ], + ) + .selected_index(3) + .width(rems_from_px(100.)) + .style(ToggleButtonGroupStyle::Filled) + .into_any_element(), + ), + ], + )]) + .children(vec![single_example( + "With Tooltips", + ToggleButtonGroup::single_row( + "with_tooltips", + [ + ToggleButtonSimple::new("First", |_, _, _| {}) + .tooltip(Tooltip::text("This is a tooltip. Hello!")), + ToggleButtonSimple::new("Second", |_, _, _| {}) + .tooltip(Tooltip::text("This is a tooltip. Hey?")), + ToggleButtonSimple::new("Third", |_, _, _| {}) + .tooltip(Tooltip::text("This is a tooltip. Get out of here now!")), ], - )]) - .children(vec![example_group_with_title( - "Outlined Variant", - vec![ - single_example( - "Single Row Group", - ToggleButtonGroup::single_row( - "single_row_test_outline", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - ) - .selected_index(1) - .style(ToggleButtonGroupStyle::Outlined) - .into_any_element(), - ), - single_example( - "Single Row Group with icons", - ToggleButtonGroup::single_row( - "single_row_test_icon_outlined", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(1) - .style(ToggleButtonGroupStyle::Outlined) - .into_any_element(), - ), - single_example( - "Multiple Row Group", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - [ - ToggleButtonSimple::new("Fourth", |_, _, _| {}), - ToggleButtonSimple::new("Fifth", |_, _, _| {}), - ToggleButtonSimple::new("Sixth", |_, _, _| {}), - ], - ) - .selected_index(3) - .style(ToggleButtonGroupStyle::Outlined) - .into_any_element(), - ), - single_example( - "Multiple Row Group with Icons", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - [ - ToggleButtonWithIcon::new( - "Fourth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Fifth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Sixth", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(3) - .style(ToggleButtonGroupStyle::Outlined) - .into_any_element(), - ), - ], - )]) - .children(vec![example_group_with_title( - "Filled Variant", - vec![ - single_example( - "Single Row Group", - ToggleButtonGroup::single_row( - "single_row_test_outline", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - ) - .selected_index(2) - .style(ToggleButtonGroupStyle::Filled) - .into_any_element(), - ), - single_example( - "Single Row Group with icons", - ToggleButtonGroup::single_row( - "single_row_test_icon_outlined", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(1) - .style(ToggleButtonGroupStyle::Filled) - .into_any_element(), - ), - single_example( - "Multiple Row Group", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonSimple::new("First", |_, _, _| {}), - ToggleButtonSimple::new("Second", |_, _, _| {}), - ToggleButtonSimple::new("Third", |_, _, _| {}), - ], - [ - ToggleButtonSimple::new("Fourth", |_, _, _| {}), - ToggleButtonSimple::new("Fifth", |_, _, _| {}), - ToggleButtonSimple::new("Sixth", |_, _, _| {}), - ], - ) - .selected_index(3) - .width(rems_from_px(100.)) - .style(ToggleButtonGroupStyle::Filled) - .into_any_element(), - ), - single_example( - "Multiple Row Group with Icons", - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonWithIcon::new( - "First", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Second", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Third", - IconName::AiZed, - |_, _, _| {}, - ), - ], - [ - ToggleButtonWithIcon::new( - "Fourth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Fifth", - IconName::AiZed, - |_, _, _| {}, - ), - ToggleButtonWithIcon::new( - "Sixth", - IconName::AiZed, - |_, _, _| {}, - ), - ], - ) - .selected_index(3) - .width(rems_from_px(100.)) - .style(ToggleButtonGroupStyle::Filled) - .into_any_element(), - ), - ], - )]) - .children(vec![single_example( - "With Tooltips", - ToggleButtonGroup::single_row( - "with_tooltips", - [ - ToggleButtonSimple::new("First", |_, _, _| {}) - .tooltip(Tooltip::text("This is a tooltip. Hello!")), - ToggleButtonSimple::new("Second", |_, _, _| {}) - .tooltip(Tooltip::text("This is a tooltip. Hey?")), - ToggleButtonSimple::new("Third", |_, _, _| {}) - .tooltip(Tooltip::text("This is a tooltip. Get out of here now!")), - ], - ) - .selected_index(1) - .into_any_element(), - )]) + ) + .selected_index(1) .into_any_element(), - ) + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index 23c820cd545..9a897df1ac4 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -224,13 +224,14 @@ impl Component for Callout { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "Used to display a callout for situations where the user needs to know some information, and likely make a decision. This might be a thread running out of tokens, or running out of prompts on a plan and needing to upgrade.", - ) + fn description() -> &'static str { + "Used to display a callout for situations where the user \ + needs to know some information, and likely make a decision. \ + This might be a thread running out of tokens, \ + or running out of prompts on a plan and needing to upgrade." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let single_action = || Button::new("got-it", "Got it").label_size(LabelSize::Small); let multiple_actions = || { h_flex() @@ -354,12 +355,10 @@ impl Component for Callout { ), ]; - Some( - v_flex() - .gap_4() - .child(example_group(basic_examples).vertical()) - .child(example_group_with_title("Severity", severity_examples).vertical()) - .into_any_element(), - ) + v_flex() + .gap_4() + .child(example_group(basic_examples).vertical()) + .child(example_group_with_title("Severity", severity_examples).vertical()) + .into_any_element() } } diff --git a/crates/ui/src/components/chip.rs b/crates/ui/src/components/chip.rs index bcb21fb379c..2609f2fc97b 100644 --- a/crates/ui/src/components/chip.rs +++ b/crates/ui/src/components/chip.rs @@ -138,7 +138,12 @@ impl Component for Chip { ComponentScope::DataDisplay } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A small, compact label container used to display tags, statuses, \ + or other short informative pieces of metadata, optionally with an icon." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let chip_examples = vec![ single_example("Default", Chip::new("Chip Example").into_any_element()), single_example( @@ -162,6 +167,6 @@ impl Component for Chip { ), ]; - Some(example_group(chip_examples).vertical().into_any_element()) + example_group(chip_examples).vertical().into_any_element() } } diff --git a/crates/ui/src/components/collab/collab_notification.rs b/crates/ui/src/components/collab/collab_notification.rs index 28d28b0a292..382749da37d 100644 --- a/crates/ui/src/components/collab/collab_notification.rs +++ b/crates/ui/src/components/collab/collab_notification.rs @@ -63,7 +63,12 @@ impl Component for CollabNotification { ComponentScope::Collaboration } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "A toast-style notification surface for collaboration events, \ + such as incoming calls or shared project invites, with an accept and dismiss action." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let avatar = "https://avatars.githubusercontent.com/u/67129314?v=4"; let container = || div().h(px(72.)).w(px(400.)); // Size of the actual notification window @@ -173,14 +178,10 @@ impl Component for CollabNotification { ), ]; - Some( - v_flex() - .gap_6() - .child(example_group_with_title("Calls & Projects", call_examples).vertical()) - .child( - example_group_with_title("Contact & Channel Toasts", toast_examples).vertical(), - ) - .into_any_element(), - ) + v_flex() + .gap_6() + .child(example_group_with_title("Calls & Projects", call_examples).vertical()) + .child(example_group_with_title("Contact & Channel Toasts", toast_examples).vertical()) + .into_any_element() } } diff --git a/crates/ui/src/components/collab/update_button.rs b/crates/ui/src/components/collab/update_button.rs index 56d7b0da151..c0e74867cc6 100644 --- a/crates/ui/src/components/collab/update_button.rs +++ b/crates/ui/src/components/collab/update_button.rs @@ -172,48 +172,45 @@ impl Component for UpdateButton { "UpdateButton" } - fn description() -> Option<&'static str> { - Some( - "A button component displayed in the title bar to show auto-update status and allow users to restart Zed.", - ) + fn description() -> &'static str { + "A button component displayed in the title bar to \ + show auto-update status and allow users to restart Zed." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let version = "1.3.0+stable.2025051"; - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Progress States", - vec![ - single_example("Checking", UpdateButton::checking().into_any_element()), - single_example( - "Downloading", - UpdateButton::downloading(version).into_any_element(), - ), - single_example( - "Installing", - UpdateButton::installing(version).into_any_element(), - ), - ], - ), - example_group_with_title( - "Actionable States", - vec![ - single_example( - "Ready to Update", - UpdateButton::updated(version).into_any_element(), - ), - single_example( - "Error", - UpdateButton::errored("Network timeout").into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Progress States", + vec![ + single_example("Checking", UpdateButton::checking().into_any_element()), + single_example( + "Downloading", + UpdateButton::downloading(version).into_any_element(), + ), + single_example( + "Installing", + UpdateButton::installing(version).into_any_element(), + ), + ], + ), + example_group_with_title( + "Actionable States", + vec![ + single_example( + "Ready to Update", + UpdateButton::updated(version).into_any_element(), + ), + single_example( + "Error", + UpdateButton::errored("Network timeout").into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/count_badge.rs b/crates/ui/src/components/count_badge.rs index c546d69e6d1..bf42e5c6fd3 100644 --- a/crates/ui/src/components/count_badge.rs +++ b/crates/ui/src/components/count_badge.rs @@ -57,11 +57,11 @@ impl Component for CountBadge { ComponentScope::Status } - fn description() -> Option<&'static str> { - Some("A small, pill-shaped badge that displays a numeric count.") + fn description() -> &'static str { + "A small, pill-shaped badge that displays a numeric count." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { div() .relative() @@ -71,23 +71,21 @@ impl Component for CountBadge { .bg(cx.theme().colors().background) }; - Some( - v_flex() - .gap_6() - .child(example_group_with_title( - "Count Badge", - vec![ - single_example( - "Basic Count", - container().child(CountBadge::new(3)).into_any_element(), - ), - single_example( - "Capped Count", - container().child(CountBadge::new(150)).into_any_element(), - ), - ], - )) - .into_any_element(), - ) + v_flex() + .gap_6() + .child(example_group_with_title( + "Count Badge", + vec![ + single_example( + "Basic Count", + container().child(CountBadge::new(3)).into_any_element(), + ), + single_example( + "Capped Count", + container().child(CountBadge::new(150)).into_any_element(), + ), + ], + )) + .into_any_element() } } diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 1700699a875..42f6a9150fb 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -1289,109 +1289,107 @@ impl Component for Table { ComponentScope::Layout } - fn description() -> Option<&'static str> { - Some("A table component for displaying data in rows and columns with optional styling.") + fn description() -> &'static str { + "A table component for displaying data in rows and columns with optional styling." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Tables", - vec![ - single_example( - "Simple Table", - Table::new(3) - .width(px(400.)) - .header(vec!["Name", "Age", "City"]) - .row(vec!["Alice", "28", "New York"]) - .row(vec!["Bob", "32", "San Francisco"]) - .row(vec!["Charlie", "25", "London"]) - .into_any_element(), - ), - single_example( - "Two Column Table", - Table::new(2) - .header(vec!["Category", "Value"]) - .width(px(300.)) - .row(vec!["Revenue", "$100,000"]) - .row(vec!["Expenses", "$75,000"]) - .row(vec!["Profit", "$25,000"]) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Styled Tables", - vec![ - single_example( - "Default", - Table::new(3) - .width(px(400.)) - .header(vec!["Product", "Price", "Stock"]) - .row(vec!["Laptop", "$999", "In Stock"]) - .row(vec!["Phone", "$599", "Low Stock"]) - .row(vec!["Tablet", "$399", "Out of Stock"]) - .into_any_element(), - ), - single_example( - "Striped", - Table::new(3) - .width(px(400.)) - .striped() - .header(vec!["Product", "Price", "Stock"]) - .row(vec!["Laptop", "$999", "In Stock"]) - .row(vec!["Phone", "$599", "Low Stock"]) - .row(vec!["Tablet", "$399", "Out of Stock"]) - .row(vec!["Headphones", "$199", "In Stock"]) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Mixed Content Table", - vec![single_example( - "Table with Elements", - Table::new(5) - .width(px(840.)) - .header(vec!["Status", "Name", "Priority", "Deadline", "Action"]) - .row(vec![ - Indicator::dot().color(Color::Success).into_any_element(), - "Project A".into_any_element(), - "High".into_any_element(), - "2023-12-31".into_any_element(), - Button::new("view_a", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ]) - .row(vec![ - Indicator::dot().color(Color::Warning).into_any_element(), - "Project B".into_any_element(), - "Medium".into_any_element(), - "2024-03-15".into_any_element(), - Button::new("view_b", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ]) - .row(vec![ - Indicator::dot().color(Color::Error).into_any_element(), - "Project C".into_any_element(), - "Low".into_any_element(), - "2024-06-30".into_any_element(), - Button::new("view_c", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ]) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Tables", + vec![ + single_example( + "Simple Table", + Table::new(3) + .width(px(400.)) + .header(vec!["Name", "Age", "City"]) + .row(vec!["Alice", "28", "New York"]) + .row(vec!["Bob", "32", "San Francisco"]) + .row(vec!["Charlie", "25", "London"]) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + single_example( + "Two Column Table", + Table::new(2) + .header(vec!["Category", "Value"]) + .width(px(300.)) + .row(vec!["Revenue", "$100,000"]) + .row(vec!["Expenses", "$75,000"]) + .row(vec!["Profit", "$25,000"]) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Styled Tables", + vec![ + single_example( + "Default", + Table::new(3) + .width(px(400.)) + .header(vec!["Product", "Price", "Stock"]) + .row(vec!["Laptop", "$999", "In Stock"]) + .row(vec!["Phone", "$599", "Low Stock"]) + .row(vec!["Tablet", "$399", "Out of Stock"]) + .into_any_element(), + ), + single_example( + "Striped", + Table::new(3) + .width(px(400.)) + .striped() + .header(vec!["Product", "Price", "Stock"]) + .row(vec!["Laptop", "$999", "In Stock"]) + .row(vec!["Phone", "$599", "Low Stock"]) + .row(vec!["Tablet", "$399", "Out of Stock"]) + .row(vec!["Headphones", "$199", "In Stock"]) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Mixed Content Table", + vec![single_example( + "Table with Elements", + Table::new(5) + .width(px(840.)) + .header(vec!["Status", "Name", "Priority", "Deadline", "Action"]) + .row(vec![ + Indicator::dot().color(Color::Success).into_any_element(), + "Project A".into_any_element(), + "High".into_any_element(), + "2023-12-31".into_any_element(), + Button::new("view_a", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ]) + .row(vec![ + Indicator::dot().color(Color::Warning).into_any_element(), + "Project B".into_any_element(), + "Medium".into_any_element(), + "2024-03-15".into_any_element(), + Button::new("view_b", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ]) + .row(vec![ + Indicator::dot().color(Color::Error).into_any_element(), + "Project C".into_any_element(), + "Low".into_any_element(), + "2024-06-30".into_any_element(), + Button::new("view_c", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ]) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/diff_stat.rs b/crates/ui/src/components/diff_stat.rs index c2e76b171e7..d6d644db437 100644 --- a/crates/ui/src/components/diff_stat.rs +++ b/crates/ui/src/components/diff_stat.rs @@ -1,5 +1,6 @@ use crate::Tooltip; use crate::prelude::*; +use num_format::{Locale, ToFormattedString}; #[derive(IntoElement, RegisterComponent)] pub struct DiffStat { @@ -35,16 +36,19 @@ impl DiffStat { impl RenderOnce for DiffStat { fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement { let tooltip = self.tooltip; + let added = self.added.to_formatted_string(&Locale::en); + let removed = self.removed.to_formatted_string(&Locale::en); + h_flex() .id(self.id) .gap_1() .child( - Label::new(format!("+\u{2009}{}", self.added)) + Label::new(format!("+\u{2009}{added}")) .color(Color::Success) .size(self.label_size), ) .child( - Label::new(format!("\u{2012}\u{2009}{}", self.removed)) + Label::new(format!("\u{2012}\u{2009}{removed}")) .color(Color::Error) .size(self.label_size), ) @@ -59,7 +63,12 @@ impl Component for DiffStat { ComponentScope::VersionControl } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A compact summary of additions and deletions for a diff, \ + displayed as colored insertion and deletion counts." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { h_flex() .py_4() @@ -73,14 +82,12 @@ impl Component for DiffStat { let diff_stat_example = vec![single_example( "Default", container() - .child(DiffStat::new("id", 1, 2)) + .child(DiffStat::new("id", 1_234, 5_678)) .into_any_element(), )]; - Some( - example_group(diff_stat_example) - .vertical() - .into_any_element(), - ) + example_group(diff_stat_example) + .vertical() + .into_any_element() } } diff --git a/crates/ui/src/components/disclosure.rs b/crates/ui/src/components/disclosure.rs index 320751890da..09df75a5d5b 100644 --- a/crates/ui/src/components/disclosure.rs +++ b/crates/ui/src/components/disclosure.rs @@ -109,43 +109,37 @@ impl Component for Disclosure { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some( - "An interactive element used to show or hide content, typically used in expandable sections or tree-like structures.", - ) + fn description() -> &'static str { + "An interactive element used to show or hide content, \ + typically used in expandable sections or tree-like structures." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Disclosure States", - vec![ - single_example( - "Closed", - Disclosure::new("closed", false).into_any_element(), - ), - single_example( - "Open", - Disclosure::new("open", true).into_any_element(), - ), - ], - ), - example_group_with_title( - "Interactive Example", - vec![single_example( - "Toggleable", - v_flex() - .gap_2() - .child(Disclosure::new("interactive", false).into_any_element()) - .child(Label::new("Click to toggle")) - .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Disclosure States", + vec![ + single_example( + "Closed", + Disclosure::new("closed", false).into_any_element(), + ), + single_example("Open", Disclosure::new("open", true).into_any_element()), + ], + ), + example_group_with_title( + "Interactive Example", + vec![single_example( + "Toggleable", + v_flex() + .gap_2() + .child(Disclosure::new("interactive", false).into_any_element()) + .child(Label::new("Click to toggle")) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/divider.rs b/crates/ui/src/components/divider.rs index 5ad2187cfae..ff1f84adaa4 100644 --- a/crates/ui/src/components/divider.rs +++ b/crates/ui/src/components/divider.rs @@ -168,85 +168,76 @@ impl Component for Divider { ComponentScope::Layout } - fn description() -> Option<&'static str> { - Some( - "Visual separator used to create divisions between groups of content or sections in a layout.", - ) + fn description() -> &'static str { + "Visual separator used to create divisions between groups of content \ + or sections in a layout." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Horizontal Dividers", - vec![ - single_example("Default", Divider::horizontal().into_any_element()), - single_example( - "Border Color", - Divider::horizontal() - .color(DividerColor::Border) - .into_any_element(), - ), - single_example( - "Inset", - Divider::horizontal().inset().into_any_element(), - ), - single_example( - "Dashed", - Divider::horizontal_dashed().into_any_element(), - ), - ], - ), - example_group_with_title( - "Vertical Dividers", - vec![ - single_example( - "Default", - div().h_16().child(Divider::vertical()).into_any_element(), - ), - single_example( - "Border Color", - div() - .h_16() - .child(Divider::vertical().color(DividerColor::Border)) - .into_any_element(), - ), - single_example( - "Inset", - div() - .h_16() - .child(Divider::vertical().inset()) - .into_any_element(), - ), - single_example( - "Dashed", - div() - .h_16() - .child(Divider::vertical_dashed()) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Example Usage", - vec![single_example( - "Between Content", - v_flex() - .w_full() - .gap_4() - .px_4() - .child(Label::new("Section One")) - .child(Divider::horizontal()) - .child(Label::new("Section Two")) - .child(Divider::horizontal_dashed()) - .child(Label::new("Section Three")) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Horizontal Dividers", + vec![ + single_example("Default", Divider::horizontal().into_any_element()), + single_example( + "Border Color", + Divider::horizontal() + .color(DividerColor::Border) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + single_example("Inset", Divider::horizontal().inset().into_any_element()), + single_example("Dashed", Divider::horizontal_dashed().into_any_element()), + ], + ), + example_group_with_title( + "Vertical Dividers", + vec![ + single_example( + "Default", + div().h_16().child(Divider::vertical()).into_any_element(), + ), + single_example( + "Border Color", + div() + .h_16() + .child(Divider::vertical().color(DividerColor::Border)) + .into_any_element(), + ), + single_example( + "Inset", + div() + .h_16() + .child(Divider::vertical().inset()) + .into_any_element(), + ), + single_example( + "Dashed", + div() + .h_16() + .child(Divider::vertical_dashed()) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Example Usage", + vec![single_example( + "Between Content", + v_flex() + .w_full() + .gap_4() + .px_4() + .child(Label::new("Section One")) + .child(Divider::horizontal()) + .child(Label::new("Section Two")) + .child(Divider::horizontal_dashed()) + .child(Label::new("Section Three")) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index c3cb3bcf0d5..ebce559d7d4 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -231,13 +231,12 @@ impl Component for DropdownMenu { "DropdownMenu" } - fn description() -> Option<&'static str> { - Some( - "A dropdown menu displays a list of actions or options. A dropdown menu is always activated by clicking a trigger (or via a keybinding).", - ) + fn description() -> &'static str { + "A dropdown menu displays a list of actions or options. \ + A dropdown menu is always activated by clicking a trigger (or via a keybinding)." } - fn preview(window: &mut Window, cx: &mut App) -> Option { + fn preview(window: &mut Window, cx: &mut App) -> AnyElement { let menu = ContextMenu::build(window, cx, |this, _, _| { this.entry("Option 1", None, |_, _| {}) .entry("Option 2", None, |_, _| {}) @@ -270,66 +269,60 @@ impl Component for DropdownMenu { }) }); - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Default", - DropdownMenu::new("default", "Select an option", menu.clone()) - .into_any_element(), - ), - single_example( - "Full Width", - DropdownMenu::new( - "full-width", - "Full Width Dropdown", - menu.clone(), - ) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Usage", + vec![ + single_example( + "Default", + DropdownMenu::new("default", "Select an option", menu.clone()) + .into_any_element(), + ), + single_example( + "Full Width", + DropdownMenu::new("full-width", "Full Width Dropdown", menu.clone()) .full_width(true) .into_any_element(), - ), - ], - ), - example_group_with_title( - "Submenus", - vec![single_example( - "With Submenus", - DropdownMenu::new("submenu", "Submenu", menu_with_submenu) + ), + ], + ), + example_group_with_title( + "Submenus", + vec![single_example( + "With Submenus", + DropdownMenu::new("submenu", "Submenu", menu_with_submenu) + .into_any_element(), + )], + ), + example_group_with_title( + "Styles", + vec![ + single_example( + "Outlined", + DropdownMenu::new("outlined", "Outlined Dropdown", menu.clone()) + .style(DropdownStyle::Outlined) .into_any_element(), - )], - ), - example_group_with_title( - "Styles", - vec![ - single_example( - "Outlined", - DropdownMenu::new("outlined", "Outlined Dropdown", menu.clone()) - .style(DropdownStyle::Outlined) - .into_any_element(), - ), - single_example( - "Ghost", - DropdownMenu::new("ghost", "Ghost Dropdown", menu.clone()) - .style(DropdownStyle::Ghost) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![single_example( - "Disabled", - DropdownMenu::new("disabled", "Disabled Dropdown", menu) - .disabled(true) + ), + single_example( + "Ghost", + DropdownMenu::new("ghost", "Ghost Dropdown", menu.clone()) + .style(DropdownStyle::Ghost) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "States", + vec![single_example( + "Disabled", + DropdownMenu::new("disabled", "Disabled Dropdown", menu) + .disabled(true) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/facepile.rs b/crates/ui/src/components/facepile.rs index 79a36871d60..4960cb98601 100644 --- a/crates/ui/src/components/facepile.rs +++ b/crates/ui/src/components/facepile.rs @@ -94,42 +94,39 @@ impl Component for Facepile { ComponentScope::Collaboration } - fn description() -> Option<&'static str> { - Some( - "Displays a collection of avatars or initials in a compact format. Often used to represent active collaborators or a subset of contributors.", - ) + fn description() -> &'static str { + "Displays a collection of avatars or initials in a compact format. \ + Often used to represent active collaborators or a subset of contributors." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Facepile Examples", - vec![ - single_example( - "Default", - Facepile::new( - EXAMPLE_FACES - .iter() - .map(|&url| Avatar::new(url).into_any_element()) - .collect(), - ) - .into_any_element(), - ), - single_example( - "Custom Size", - Facepile::new( - EXAMPLE_FACES - .iter() - .map(|&url| Avatar::new(url).size(px(24.)).into_any_element()) - .collect(), - ) - .into_any_element(), - ), - ], - )]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Facepile Examples", + vec![ + single_example( + "Default", + Facepile::new( + EXAMPLE_FACES + .iter() + .map(|&url| Avatar::new(url).into_any_element()) + .collect(), + ) + .into_any_element(), + ), + single_example( + "Custom Size", + Facepile::new( + EXAMPLE_FACES + .iter() + .map(|&url| Avatar::new(url).size(px(24.)).into_any_element()) + .collect(), + ) + .into_any_element(), + ), + ], + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index d9a1553b7da..6089bba4b86 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -288,57 +288,54 @@ impl Component for Icon { ComponentScope::Images } - fn description() -> Option<&'static str> { - Some( - "A versatile icon component that supports SVG and image-based icons with customizable size, color, and transformations.", - ) + fn description() -> &'static str { + "A versatile icon component that supports SVG and image-based icons \ + with customizable size, color, and transformations." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Sizes", - vec![single_example( - "XSmall, Small, Default, Large", - h_flex() - .gap_1() - .child(Icon::new(IconName::Star).size(IconSize::XSmall)) - .child(Icon::new(IconName::Star).size(IconSize::Small)) - .child(Icon::new(IconName::Star)) - .child(Icon::new(IconName::Star).size(IconSize::XLarge)) - .into_any_element(), - )], - ), - example_group(vec![single_example( - "All Icons", + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Sizes", + vec![single_example( + "XSmall, Small, Default, Large", h_flex() - .image_cache(gpui::retain_all("all icons")) - .flex_wrap() - .gap_2() - .children(::iter().map( - |icon_name: IconName| { - let name: SharedString = format!("{icon_name:?}").into(); - v_flex() - .min_w_0() - .w_24() - .p_1p5() - .gap_2() - .border_1() - .border_color(cx.theme().colors().border_variant) - .bg(cx.theme().colors().element_disabled) - .rounded_sm() - .items_center() - .child(Icon::new(icon_name)) - .child(Label::new(name).size(LabelSize::XSmall).truncate()) - }, - )) + .gap_1() + .child(Icon::new(IconName::Star).size(IconSize::XSmall)) + .child(Icon::new(IconName::Star).size(IconSize::Small)) + .child(Icon::new(IconName::Star)) + .child(Icon::new(IconName::Star).size(IconSize::XLarge)) .into_any_element(), - )]), - ]) - .into_any_element(), - ) + )], + ), + example_group(vec![single_example( + "All Icons", + h_flex() + .image_cache(gpui::retain_all("all icons")) + .flex_wrap() + .gap_2() + .children(::iter().map( + |icon_name: IconName| { + let name: SharedString = format!("{icon_name:?}").into(); + v_flex() + .min_w_0() + .w_24() + .p_1p5() + .gap_2() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().element_disabled) + .rounded_sm() + .items_center() + .child(Icon::new(icon_name)) + .child(Label::new(name).size(LabelSize::XSmall).truncate()) + }, + )) + .into_any_element(), + )]), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/icon/decorated_icon.rs b/crates/ui/src/components/icon/decorated_icon.rs index 82ca844c384..5ea8cdc48e4 100644 --- a/crates/ui/src/components/icon/decorated_icon.rs +++ b/crates/ui/src/components/icon/decorated_icon.rs @@ -29,13 +29,12 @@ impl Component for DecoratedIcon { ComponentScope::Images } - fn description() -> Option<&'static str> { - Some( - "An icon with an optional decoration overlay (like an X, triangle, or dot) that can be positioned relative to the icon", - ) + fn description() -> &'static str { + "An icon with an optional decoration overlay (like an X, triangle, or dot) \ + that can be positioned relative to the icon" } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let decoration_x = IconDecoration::new( IconDecorationKind::X, cx.theme().colors().surface_background, @@ -69,38 +68,32 @@ impl Component for DecoratedIcon { y: px(-2.), }); - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Decorations", - vec![ - single_example( - "No Decoration", - DecoratedIcon::new(Icon::new(IconName::FileDoc), None) - .into_any_element(), - ), - single_example( - "X Decoration", - DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_x)) - .into_any_element(), - ), - single_example( - "Triangle Decoration", - DecoratedIcon::new( - Icon::new(IconName::FileDoc), - Some(decoration_triangle), - ) + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Decorations", + vec![ + single_example( + "No Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), None).into_any_element(), + ), + single_example( + "X Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_x)) .into_any_element(), - ), - single_example( - "Dot Decoration", - DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_dot)) - .into_any_element(), - ), - ], - )]) - .into_any_element(), - ) + ), + single_example( + "Triangle Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_triangle)) + .into_any_element(), + ), + single_example( + "Dot Decoration", + DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_dot)) + .into_any_element(), + ), + ], + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/image.rs b/crates/ui/src/components/image.rs index 759d637d451..25b1a3003f4 100644 --- a/crates/ui/src/components/image.rs +++ b/crates/ui/src/components/image.rs @@ -106,66 +106,64 @@ impl Component for Vector { "Vector" } - fn description() -> Option<&'static str> { - Some("A vector image component that can be displayed at specific sizes.") + fn description() -> &'static str { + "A vector image component that can be displayed at specific sizes." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let size = rems_from_px(60.); - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Default", - Vector::square(VectorName::ZedLogo, size).into_any_element(), - ), - single_example( - "Custom Size", - h_flex() - .h(rems_from_px(120.)) - .justify_center() - .child(Vector::new( - VectorName::ZedLogo, - rems_from_px(120.), - rems_from_px(200.), - )) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Colored", - vec![ - single_example( - "Accent Color", - Vector::square(VectorName::ZedLogo, size) - .color(Color::Accent) - .into_any_element(), - ), - single_example( - "Error Color", - Vector::square(VectorName::ZedLogo, size) - .color(Color::Error) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Different Vectors", - vec![single_example( - "Zed X Copilot", - Vector::square(VectorName::ZedXCopilot, rems_from_px(100.)) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Usage", + vec![ + single_example( + "Default", + Vector::square(VectorName::ZedLogo, size).into_any_element(), + ), + single_example( + "Custom Size", + h_flex() + .h(rems_from_px(120.)) + .justify_center() + .child(Vector::new( + VectorName::ZedLogo, + rems_from_px(120.), + rems_from_px(200.), + )) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "Colored", + vec![ + single_example( + "Accent Color", + Vector::square(VectorName::ZedLogo, size) + .color(Color::Accent) + .into_any_element(), + ), + single_example( + "Error Color", + Vector::square(VectorName::ZedLogo, size) + .color(Color::Error) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Different Vectors", + vec![single_example( + "Zed X Copilot", + Vector::square(VectorName::ZedXCopilot, rems_from_px(100.)) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/indicator.rs b/crates/ui/src/components/indicator.rs index 59d69a068b3..41f6f676685 100644 --- a/crates/ui/src/components/indicator.rs +++ b/crates/ui/src/components/indicator.rs @@ -89,89 +89,86 @@ impl Component for Indicator { ComponentScope::Status } - fn description() -> Option<&'static str> { - Some( - "Visual indicators used to represent status, notifications, or draw attention to specific elements.", - ) + fn description() -> &'static str { + "Visual indicators used to represent status, notifications, \ + or draw attention to specific elements." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Dot Indicators", - vec![ - single_example("Default", Indicator::dot().into_any_element()), - single_example( - "Success", - Indicator::dot().color(Color::Success).into_any_element(), - ), - single_example( - "Warning", - Indicator::dot().color(Color::Warning).into_any_element(), - ), - single_example( - "Error", - Indicator::dot().color(Color::Error).into_any_element(), - ), - single_example( - "With Border", - Indicator::dot() - .color(Color::Accent) - .border_color(Color::Default) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Bar Indicators", - vec![ - single_example("Default", Indicator::bar().into_any_element()), - single_example( - "Success", - Indicator::bar().color(Color::Success).into_any_element(), - ), - single_example( - "Warning", - Indicator::bar().color(Color::Warning).into_any_element(), - ), - single_example( - "Error", - Indicator::bar().color(Color::Error).into_any_element(), - ), - ], - ), - example_group_with_title( - "Icon Indicators", - vec![ - single_example( - "Default", - Indicator::icon(Icon::new(IconName::Circle)).into_any_element(), - ), - single_example( - "Success", - Indicator::icon(Icon::new(IconName::Check)) - .color(Color::Success) - .into_any_element(), - ), - single_example( - "Warning", - Indicator::icon(Icon::new(IconName::Warning)) - .color(Color::Warning) - .into_any_element(), - ), - single_example( - "Error", - Indicator::icon(Icon::new(IconName::Close)) - .color(Color::Error) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Dot Indicators", + vec![ + single_example("Default", Indicator::dot().into_any_element()), + single_example( + "Success", + Indicator::dot().color(Color::Success).into_any_element(), + ), + single_example( + "Warning", + Indicator::dot().color(Color::Warning).into_any_element(), + ), + single_example( + "Error", + Indicator::dot().color(Color::Error).into_any_element(), + ), + single_example( + "With Border", + Indicator::dot() + .color(Color::Accent) + .border_color(Color::Default) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Bar Indicators", + vec![ + single_example("Default", Indicator::bar().into_any_element()), + single_example( + "Success", + Indicator::bar().color(Color::Success).into_any_element(), + ), + single_example( + "Warning", + Indicator::bar().color(Color::Warning).into_any_element(), + ), + single_example( + "Error", + Indicator::bar().color(Color::Error).into_any_element(), + ), + ], + ), + example_group_with_title( + "Icon Indicators", + vec![ + single_example( + "Default", + Indicator::icon(Icon::new(IconName::Circle)).into_any_element(), + ), + single_example( + "Success", + Indicator::icon(Icon::new(IconName::Check)) + .color(Color::Success) + .into_any_element(), + ), + single_example( + "Warning", + Indicator::icon(Icon::new(IconName::Warning)) + .color(Color::Warning) + .into_any_element(), + ), + single_example( + "Error", + Indicator::icon(Icon::new(IconName::Close)) + .color(Color::Error) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 016181ee9bd..af30dd68659 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -130,6 +130,11 @@ impl KeyBinding { self.disabled = disabled; self } + + fn vim_mode(mut self, vim_mode: bool) -> Self { + self.vim_mode = vim_mode; + self + } } fn render_key( @@ -563,85 +568,77 @@ impl Component for KeyBinding { "KeyBinding" } - fn description() -> Option<&'static str> { - Some( - "A component that displays a key binding, supporting different platform styles and vim mode.", - ) + fn description() -> &'static str { + "A component that displays a key binding, \ + supporting different platform styles and vim mode." } - // fn preview(_window: &mut Window, cx: &mut App) -> Option { - // Some( - // v_flex() - // .gap_6() - // .children(vec![ - // example_group_with_title( - // "Basic Usage", - // vec![ - // single_example( - // "Default", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None), - // cx, - // ) - // .into_any_element(), - // ), - // single_example( - // "Mac Style", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("cmd-s", gpui::NoAction, None), - // cx, - // ) - // .platform_style(PlatformStyle::Mac) - // .into_any_element(), - // ), - // single_example( - // "Windows Style", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None), - // cx, - // ) - // .platform_style(PlatformStyle::Windows) - // .into_any_element(), - // ), - // ], - // ), - // example_group_with_title( - // "Vim Mode", - // vec![single_example( - // "Vim Mode Enabled", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("dd", gpui::NoAction, None), - // cx, - // ) - // .vim_mode(true) - // .into_any_element(), - // )], - // ), - // example_group_with_title( - // "Complex Bindings", - // vec![ - // single_example( - // "Multiple Keys", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("ctrl-k ctrl-b", gpui::NoAction, None), - // cx, - // ) - // .into_any_element(), - // ), - // single_example( - // "With Shift", - // KeyBinding::new_from_gpui( - // gpui::KeyBinding::new("shift-cmd-p", gpui::NoAction, None), - // cx, - // ) - // .into_any_element(), - // ), - // ], - // ), - // ]) - // .into_any_element(), - // ) - // } + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + fn keybinding(input: &str) -> KeyBinding { + let keystrokes: Rc<[KeybindingKeystroke]> = input + .split_whitespace() + .filter_map(|chunk| Keystroke::parse(chunk).ok()) + .map(KeybindingKeystroke::from_keystroke) + .collect::>() + .into(); + KeyBinding::from_keystrokes(keystrokes, false) + } + + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Platform Styles", + vec![ + single_example( + "Mac Style", + keybinding("cmd-s") + .platform_style(PlatformStyle::Mac) + .into_any_element(), + ), + single_example( + "Linux Style", + keybinding("ctrl-s") + .platform_style(PlatformStyle::Linux) + .into_any_element(), + ), + single_example( + "Windows Style", + keybinding("ctrl-s") + .platform_style(PlatformStyle::Windows) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Vim Mode Style", + vec![ + single_example( + "Simple", + keybinding("s") + .platform_style(PlatformStyle::Mac) + .vim_mode(true) + .into_any_element(), + ), + single_example( + "With Modifiers", + keybinding("ctrl-s") + .platform_style(PlatformStyle::Linux) + .vim_mode(true) + .into_any_element(), + ), + single_example( + "With other special key", + keybinding("ctrl-escape") + .platform_style(PlatformStyle::Windows) + .vim_mode(true) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() + } } #[cfg(test)] diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs index 9da470c4ee4..0e74ad07e62 100644 --- a/crates/ui/src/components/keybinding_hint.rs +++ b/crates/ui/src/components/keybinding_hint.rs @@ -247,6 +247,7 @@ impl RenderOnce for KeybindingHint { offset: point(px(0.), px(1.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }]) .child(self.keybinding.size(rems_from_px(kb_size))), ) @@ -259,74 +260,68 @@ impl Component for KeybindingHint { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some("Displays a keyboard shortcut hint with optional prefix and suffix text") + fn description() -> &'static str { + "Displays a keyboard shortcut hint with optional prefix and suffix text" } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let enter = KeyBinding::for_action(&menu::Confirm, cx); let bg_color = cx.theme().colors().surface_background; - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic", - vec![ - single_example( - "With Prefix", - KeybindingHint::with_prefix( - "Go to Start:", - enter.clone(), - bg_color, - ) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic", + vec![ + single_example( + "With Prefix", + KeybindingHint::with_prefix("Go to Start:", enter.clone(), bg_color) .into_any_element(), - ), - single_example( - "With Suffix", - KeybindingHint::with_suffix(enter.clone(), "Go to End", bg_color) - .into_any_element(), - ), - single_example( - "With Prefix and Suffix", - KeybindingHint::new(enter.clone(), bg_color) - .prefix("Confirm:") - .suffix("Execute selected action") - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Sizes", - vec![ - single_example( - "Small", - KeybindingHint::new(enter.clone(), bg_color) - .size(Pixels::from(12.0)) - .prefix("Small:") - .into_any_element(), - ), - single_example( - "Medium", - KeybindingHint::new(enter.clone(), bg_color) - .size(Pixels::from(16.0)) - .suffix("Medium") - .into_any_element(), - ), - single_example( - "Large", - KeybindingHint::new(enter, bg_color) - .size(Pixels::from(20.0)) - .prefix("Large:") - .suffix("Size") - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + ), + single_example( + "With Suffix", + KeybindingHint::with_suffix(enter.clone(), "Go to End", bg_color) + .into_any_element(), + ), + single_example( + "With Prefix and Suffix", + KeybindingHint::new(enter.clone(), bg_color) + .prefix("Confirm:") + .suffix("Execute selected action") + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Sizes", + vec![ + single_example( + "Small", + KeybindingHint::new(enter.clone(), bg_color) + .size(Pixels::from(12.0)) + .prefix("Small:") + .into_any_element(), + ), + single_example( + "Medium", + KeybindingHint::new(enter.clone(), bg_color) + .size(Pixels::from(16.0)) + .suffix("Medium") + .into_any_element(), + ), + single_example( + "Large", + KeybindingHint::new(enter, bg_color) + .size(Pixels::from(20.0)) + .prefix("Large:") + .suffix("Size") + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 73e03f82dfd..b3f19e00604 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -1,6 +1,7 @@ use std::ops::Range; use gpui::{FontWeight, HighlightStyle, StyleRefinement, StyledText}; +use gpui_util::debug_panic; use crate::{LabelCommon, LabelLike, LabelSize, LineHeightStyle, prelude::*}; @@ -14,14 +15,21 @@ pub struct HighlightedLabel { impl HighlightedLabel { /// Constructs a label with the given characters highlighted. /// Characters are identified by UTF-8 byte position. - pub fn new(label: impl Into, highlight_indices: Vec) -> Self { + #[track_caller] + pub fn new(label: impl Into, mut highlight_indices: Vec) -> Self { let label = label.into(); - for &run in &highlight_indices { - assert!( - label.is_char_boundary(run), - "highlight index {run} is not a valid UTF-8 boundary" + + if let Some(index) = highlight_indices + .iter() + .find(|&i| !label.is_char_boundary(*i)) + { + let location = std::panic::Location::caller(); + debug_panic!( + "highlight index {index} is not a valid UTF-8 boundary (called from {location})" ); + highlight_indices.clear(); } + Self { base: LabelLike::new(), label, @@ -215,89 +223,94 @@ impl Component for HighlightedLabel { "HighlightedLabel" } - fn description() -> Option<&'static str> { - Some("A label with highlighted characters based on specified indices.") + fn description() -> &'static str { + "A label with highlighted characters based on specified indices." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Default", - HighlightedLabel::new("Highlighted Text", vec![0, 1, 2, 3]).into_any_element(), - ), - single_example( - "Custom Color", - HighlightedLabel::new("Colored Highlight", vec![0, 1, 7, 8, 9]) - .color(Color::Accent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Styles", - vec![ - single_example( - "Bold", - HighlightedLabel::new("Bold Highlight", vec![0, 1, 2, 3]) - .weight(FontWeight::BOLD) - .into_any_element(), - ), - single_example( - "Italic", - HighlightedLabel::new("Italic Highlight", vec![0, 1, 6, 7, 8]) - .italic() - .into_any_element(), - ), - single_example( - "Underline", - HighlightedLabel::new("Underlined Highlight", vec![0, 1, 10, 11, 12]) - .underline() - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Sizes", - vec![ - single_example( - "Small", - HighlightedLabel::new("Small Highlight", vec![0, 1, 5, 6, 7]) - .size(LabelSize::Small) - .into_any_element(), - ), - single_example( - "Large", - HighlightedLabel::new("Large Highlight", vec![0, 1, 5, 6, 7]) - .size(LabelSize::Large) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Special Cases", - vec![ - single_example( - "Single Line", - HighlightedLabel::new("Single Line Highlight\nWith Newline", vec![0, 1, 7, 8, 9]) - .single_line() - .into_any_element(), - ), - single_example( - "Truncate", - HighlightedLabel::new("This is a very long text that should be truncated with highlights", vec![0, 1, 2, 3, 4, 5]) - .truncate() - .into_any_element(), - ), - ], - ), - ]) - .into_any_element() - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Usage", + vec![ + single_example( + "Default", + HighlightedLabel::new("Highlighted Text", vec![0, 1, 2, 3]) + .into_any_element(), + ), + single_example( + "Custom Color", + HighlightedLabel::new("Colored Highlight", vec![0, 1, 7, 8, 9]) + .color(Color::Accent) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Styles", + vec![ + single_example( + "Bold", + HighlightedLabel::new("Bold Highlight", vec![0, 1, 2, 3]) + .weight(FontWeight::BOLD) + .into_any_element(), + ), + single_example( + "Italic", + HighlightedLabel::new("Italic Highlight", vec![0, 1, 6, 7, 8]) + .italic() + .into_any_element(), + ), + single_example( + "Underline", + HighlightedLabel::new("Underlined Highlight", vec![0, 1, 10, 11, 12]) + .underline() + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Sizes", + vec![ + single_example( + "Small", + HighlightedLabel::new("Small Highlight", vec![0, 1, 5, 6, 7]) + .size(LabelSize::Small) + .into_any_element(), + ), + single_example( + "Large", + HighlightedLabel::new("Large Highlight", vec![0, 1, 5, 6, 7]) + .size(LabelSize::Large) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Special Cases", + vec![ + single_example( + "Single Line", + HighlightedLabel::new( + "Single Line Highlight\nWith Newline", + vec![0, 1, 7, 8, 9], + ) + .single_line() + .into_any_element(), + ), + single_example( + "Truncate", + HighlightedLabel::new( + "This is a very long text that should be truncated with highlights", + vec![0, 1, 2, 3, 4, 5], + ) + .truncate() + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 871f53fbe4d..370c9166c36 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -352,13 +352,13 @@ impl Component for Label { ComponentScope::Typography } - fn description() -> Option<&'static str> { - Some("A text label component that supports various styles, sizes, and formatting options.") + fn description() -> &'static str { + "A text label component that supports various styles, \ + sizes, and formatting options." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { - Some( - v_flex() + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() .gap_6() .children(vec![ example_group_with_title( @@ -405,6 +405,5 @@ impl Component for Label { ), ]) .into_any_element() - ) } } diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 5cad04efcfa..27eb9e797fa 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -271,15 +271,13 @@ impl Component for LabelLike { "LabelLike" } - fn description() -> Option<&'static str> { - Some( - "A flexible, customizable label-like component that serves as a base for other label types.", - ) + fn description() -> &'static str { + "A flexible, customizable label-like component \ + that serves as a base for other label types." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { - Some( - v_flex() + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() .gap_6() .children(vec![ example_group_with_title( @@ -326,6 +324,5 @@ impl Component for LabelLike { ), ]) .into_any_element() - ) } } diff --git a/crates/ui/src/components/label/spinner_label.rs b/crates/ui/src/components/label/spinner_label.rs index 33eeeae1251..583debde9cb 100644 --- a/crates/ui/src/components/label/spinner_label.rs +++ b/crates/ui/src/components/label/spinner_label.rs @@ -190,7 +190,12 @@ impl Component for SpinnerLabel { "Spinner Label" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn description() -> &'static str { + "A text-based loading indicator that animates through a sequence of \ + unicode glyphs to show ongoing or indeterminate work." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let examples = vec![ single_example("Default", SpinnerLabel::new().into_any_element()), single_example( @@ -200,6 +205,6 @@ impl Component for SpinnerLabel { single_example("Sand Variant", SpinnerLabel::sand().into_any_element()), ]; - Some(example_group(examples).vertical().into_any_element()) + example_group(examples).vertical().into_any_element() } } diff --git a/crates/ui/src/components/list/list.rs b/crates/ui/src/components/list/list.rs index ccae5bed23d..4659c7e16b5 100644 --- a/crates/ui/src/components/list/list.rs +++ b/crates/ui/src/components/list/list.rs @@ -99,44 +99,41 @@ impl Component for List { ComponentScope::Layout } - fn description() -> Option<&'static str> { - Some( - "A container component for displaying a collection of list items with optional header and empty state.", - ) + fn description() -> &'static str { + "A container component for displaying a collection of list items \ + with optional header and empty state." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Basic Lists", - vec![ - single_example( - "Simple List", - List::new() - .child(ListItem::new("item1").child(Label::new("Item 1"))) - .child(ListItem::new("item2").child(Label::new("Item 2"))) - .child(ListItem::new("item3").child(Label::new("Item 3"))) - .into_any_element(), - ), - single_example( - "With Header", - List::new() - .header(ListHeader::new("Section Header")) - .child(ListItem::new("item1").child(Label::new("Item 1"))) - .child(ListItem::new("item2").child(Label::new("Item 2"))) - .into_any_element(), - ), - single_example( - "Empty List", - List::new() - .empty_message("No items to display") - .into_any_element(), - ), - ], - )]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Basic Lists", + vec![ + single_example( + "Simple List", + List::new() + .child(ListItem::new("item1").child(Label::new("Item 1"))) + .child(ListItem::new("item2").child(Label::new("Item 2"))) + .child(ListItem::new("item3").child(Label::new("Item 3"))) + .into_any_element(), + ), + single_example( + "With Header", + List::new() + .header(ListHeader::new("Section Header")) + .child(ListItem::new("item1").child(Label::new("Item 1"))) + .child(ListItem::new("item2").child(Label::new("Item 2"))) + .into_any_element(), + ), + single_example( + "Empty List", + List::new() + .empty_message("No items to display") + .into_any_element(), + ), + ], + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/list/list_bullet_item.rs b/crates/ui/src/components/list/list_bullet_item.rs index 934f0853dbe..8fed3158756 100644 --- a/crates/ui/src/components/list/list_bullet_item.rs +++ b/crates/ui/src/components/list/list_bullet_item.rs @@ -71,11 +71,11 @@ impl Component for ListBulletItem { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some("A list item with a dash indicator for unordered lists.") + fn description() -> &'static str { + "A list item with a dash indicator for unordered lists." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let basic_examples = vec![ single_example( "Simple", @@ -105,11 +105,9 @@ impl Component for ListBulletItem { ), ]; - Some( - v_flex() - .gap_6() - .child(example_group(basic_examples).vertical()) - .into_any_element(), - ) + v_flex() + .gap_6() + .child(example_group(basic_examples).vertical()) + .into_any_element() } } diff --git a/crates/ui/src/components/list/list_header.rs b/crates/ui/src/components/list/list_header.rs index 9d72366c3be..741f60ba34d 100644 --- a/crates/ui/src/components/list/list_header.rs +++ b/crates/ui/src/components/list/list_header.rs @@ -144,74 +144,71 @@ impl Component for ListHeader { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "A header component for lists with support for icons, actions, and collapsible sections.", - ) + fn description() -> &'static str { + "A header component for lists with support for icons, actions, \ + and collapsible sections." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Headers", - vec![ - single_example( - "Simple", - ListHeader::new("Section Header").into_any_element(), - ), - single_example( - "With Icon", - ListHeader::new("Files") - .start_slot(Icon::new(IconName::File)) - .into_any_element(), - ), - single_example( - "With End Slot", - ListHeader::new("Recent") - .end_slot(Label::new("5").color(Color::Muted)) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Collapsible Headers", - vec![ - single_example( - "Expanded", - ListHeader::new("Expanded Section") - .toggle(Some(true)) - .into_any_element(), - ), - single_example( - "Collapsed", - ListHeader::new("Collapsed Section") - .toggle(Some(false)) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![ - single_example( - "Selected", - ListHeader::new("Selected Header") - .toggle_state(true) - .into_any_element(), - ), - single_example( - "Inset", - ListHeader::new("Inset Header") - .inset(true) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Headers", + vec![ + single_example( + "Simple", + ListHeader::new("Section Header").into_any_element(), + ), + single_example( + "With Icon", + ListHeader::new("Files") + .start_slot(Icon::new(IconName::File)) + .into_any_element(), + ), + single_example( + "With End Slot", + ListHeader::new("Recent") + .end_slot(Label::new("5").color(Color::Muted)) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Collapsible Headers", + vec![ + single_example( + "Expanded", + ListHeader::new("Expanded Section") + .toggle(Some(true)) + .into_any_element(), + ), + single_example( + "Collapsed", + ListHeader::new("Collapsed Section") + .toggle(Some(false)) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Selected", + ListHeader::new("Selected Header") + .toggle_state(true) + .into_any_element(), + ), + single_example( + "Inset", + ListHeader::new("Inset Header") + .inset(true) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index ece1fd3c61e..06be27abab2 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -385,109 +385,106 @@ impl Component for ListItem { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "A flexible list item component with support for icons, actions, disclosure toggles, and hierarchical display.", - ) + fn description() -> &'static str { + "A flexible list item component with support for icons, actions, \ + disclosure toggles, and hierarchical display." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic List Items", - vec![ - single_example( - "Simple", - ListItem::new("simple") - .child(Label::new("Simple list item")) - .into_any_element(), - ), - single_example( - "With Icon", - ListItem::new("with_icon") - .start_slot(Icon::new(IconName::File)) - .child(Label::new("List item with icon")) - .into_any_element(), - ), - single_example( - "Selected", - ListItem::new("selected") - .toggle_state(true) - .start_slot(Icon::new(IconName::Check)) - .child(Label::new("Selected item")) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "List Item Spacing", - vec![ - single_example( - "Dense", - ListItem::new("dense") - .spacing(ListItemSpacing::Dense) - .child(Label::new("Dense spacing")) - .into_any_element(), - ), - single_example( - "Extra Dense", - ListItem::new("extra_dense") - .spacing(ListItemSpacing::ExtraDense) - .child(Label::new("Extra dense spacing")) - .into_any_element(), - ), - single_example( - "Sparse", - ListItem::new("sparse") - .spacing(ListItemSpacing::Sparse) - .child(Label::new("Sparse spacing")) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Slots", - vec![ - single_example( - "End Slot", - ListItem::new("end_slot") - .child(Label::new("Item with end slot")) - .end_slot(Icon::new(IconName::ChevronRight)) - .into_any_element(), - ), - single_example( - "With Toggle", - ListItem::new("with_toggle") - .toggle(Some(true)) - .child(Label::new("Expandable item")) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![ - single_example( - "Disabled", - ListItem::new("disabled") - .disabled(true) - .child(Label::new("Disabled item")) - .into_any_element(), - ), - single_example( - "Non-selectable", - ListItem::new("non_selectable") - .selectable(false) - .child(Label::new("Non-selectable item")) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic List Items", + vec![ + single_example( + "Simple", + ListItem::new("simple") + .child(Label::new("Simple list item")) + .into_any_element(), + ), + single_example( + "With Icon", + ListItem::new("with_icon") + .start_slot(Icon::new(IconName::File)) + .child(Label::new("List item with icon")) + .into_any_element(), + ), + single_example( + "Selected", + ListItem::new("selected") + .toggle_state(true) + .start_slot(Icon::new(IconName::Check)) + .child(Label::new("Selected item")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "List Item Spacing", + vec![ + single_example( + "Dense", + ListItem::new("dense") + .spacing(ListItemSpacing::Dense) + .child(Label::new("Dense spacing")) + .into_any_element(), + ), + single_example( + "Extra Dense", + ListItem::new("extra_dense") + .spacing(ListItemSpacing::ExtraDense) + .child(Label::new("Extra dense spacing")) + .into_any_element(), + ), + single_example( + "Sparse", + ListItem::new("sparse") + .spacing(ListItemSpacing::Sparse) + .child(Label::new("Sparse spacing")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "With Slots", + vec![ + single_example( + "End Slot", + ListItem::new("end_slot") + .child(Label::new("Item with end slot")) + .end_slot(Icon::new(IconName::ChevronRight)) + .into_any_element(), + ), + single_example( + "With Toggle", + ListItem::new("with_toggle") + .toggle(Some(true)) + .child(Label::new("Expandable item")) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Disabled", + ListItem::new("disabled") + .disabled(true) + .child(Label::new("Disabled item")) + .into_any_element(), + ), + single_example( + "Non-selectable", + ListItem::new("non_selectable") + .selectable(false) + .child(Label::new("Non-selectable item")) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/list/list_sub_header.rs b/crates/ui/src/components/list/list_sub_header.rs index b4a82fb2edf..6c0f8df3aa1 100644 --- a/crates/ui/src/components/list/list_sub_header.rs +++ b/crates/ui/src/components/list/list_sub_header.rs @@ -91,59 +91,54 @@ impl Component for ListSubHeader { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "A sub-header component for organizing list content into subsections with optional icons and end slots.", - ) + fn description() -> &'static str { + "A sub-header component for organizing list content into subsections \ + with optional icons and end slots." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Sub-headers", - vec![ - single_example( - "Simple", - ListSubHeader::new("Subsection").into_any_element(), - ), - single_example( - "With Icon", - ListSubHeader::new("Documents") - .left_icon(Some(IconName::File)) - .into_any_element(), - ), - single_example( - "With End Slot", - ListSubHeader::new("Recent") - .end_slot( - Label::new("3").color(Color::Muted).into_any_element(), - ) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![ - single_example( - "Selected", - ListSubHeader::new("Selected") - .toggle_state(true) - .into_any_element(), - ), - single_example( - "Inset", - ListSubHeader::new("Inset Sub-header") - .inset(true) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Sub-headers", + vec![ + single_example( + "Simple", + ListSubHeader::new("Subsection").into_any_element(), + ), + single_example( + "With Icon", + ListSubHeader::new("Documents") + .left_icon(Some(IconName::File)) + .into_any_element(), + ), + single_example( + "With End Slot", + ListSubHeader::new("Recent") + .end_slot(Label::new("3").color(Color::Muted).into_any_element()) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "States", + vec![ + single_example( + "Selected", + ListSubHeader::new("Selected") + .toggle_state(true) + .into_any_element(), + ), + single_example( + "Inset", + ListSubHeader::new("Inset Sub-header") + .inset(true) + .into_any_element(), + ), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/notification/alert_modal.rs b/crates/ui/src/components/notification/alert_modal.rs index 52a084c8478..5c368a9a745 100644 --- a/crates/ui/src/components/notification/alert_modal.rs +++ b/crates/ui/src/components/notification/alert_modal.rs @@ -174,13 +174,12 @@ impl Component for AlertModal { ComponentStatus::WorkInProgress } - fn description() -> Option<&'static str> { - Some("A modal dialog that presents an alert message with primary and dismiss actions.") + fn description() -> &'static str { + "A modal dialog that presents an alert message with primary and dismiss actions." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { - Some( - v_flex() + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() .gap_6() .p_4() .children(vec![ @@ -246,7 +245,6 @@ Review .zed/settings.json for any extensions or commands configured by this proj .into_any_element(), )]), ]) - .into_any_element(), - ) + .into_any_element() } } diff --git a/crates/ui/src/components/notification/announcement_toast.rs b/crates/ui/src/components/notification/announcement_toast.rs index 215d8b9aedf..d47d264843c 100644 --- a/crates/ui/src/components/notification/announcement_toast.rs +++ b/crates/ui/src/components/notification/announcement_toast.rs @@ -160,11 +160,11 @@ impl Component for AnnouncementToast { ComponentScope::Notification } - fn description() -> Option<&'static str> { - Some("A special toast for announcing new and exciting features.") + fn description() -> &'static str { + "A special toast for announcing new and exciting features." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { let examples = vec![single_example( "Basic", div() @@ -188,11 +188,9 @@ impl Component for AnnouncementToast { .into_any_element(), )]; - Some( - v_flex() - .gap_6() - .child(example_group(examples).vertical()) - .into_any_element(), - ) + v_flex() + .gap_6() + .child(example_group(examples).vertical()) + .into_any_element() } } diff --git a/crates/ui/src/components/progress/circular_progress.rs b/crates/ui/src/components/progress/circular_progress.rs index f571332edae..69f93b740cd 100644 --- a/crates/ui/src/components/progress/circular_progress.rs +++ b/crates/ui/src/components/progress/circular_progress.rs @@ -175,49 +175,46 @@ impl Component for CircularProgress { ComponentScope::Status } - fn description() -> Option<&'static str> { - Some( - "A circular progress indicator that displays progress as an arc growing clockwise from the top.", - ) + fn description() -> &'static str { + "A circular progress indicator that displays progress as an arc \ + growing clockwise from the top." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let max_value = 100.0; let container = || v_flex().items_center().gap_1(); - Some( - example_group(vec![single_example( - "Examples", - h_flex() - .gap_6() - .child( - container() - .child(CircularProgress::new(0.0, max_value, px(48.0), cx)) - .child(Label::new("0%").size(LabelSize::Small)), - ) - .child( - container() - .child(CircularProgress::new(25.0, max_value, px(48.0), cx)) - .child(Label::new("25%").size(LabelSize::Small)), - ) - .child( - container() - .child(CircularProgress::new(50.0, max_value, px(48.0), cx)) - .child(Label::new("50%").size(LabelSize::Small)), - ) - .child( - container() - .child(CircularProgress::new(75.0, max_value, px(48.0), cx)) - .child(Label::new("75%").size(LabelSize::Small)), - ) - .child( - container() - .child(CircularProgress::new(100.0, max_value, px(48.0), cx)) - .child(Label::new("100%").size(LabelSize::Small)), - ) - .into_any_element(), - )]) - .into_any_element(), - ) + example_group(vec![single_example( + "Examples", + h_flex() + .gap_6() + .child( + container() + .child(CircularProgress::new(0.0, max_value, px(48.0), cx)) + .child(Label::new("0%").size(LabelSize::Small)), + ) + .child( + container() + .child(CircularProgress::new(25.0, max_value, px(48.0), cx)) + .child(Label::new("25%").size(LabelSize::Small)), + ) + .child( + container() + .child(CircularProgress::new(50.0, max_value, px(48.0), cx)) + .child(Label::new("50%").size(LabelSize::Small)), + ) + .child( + container() + .child(CircularProgress::new(75.0, max_value, px(48.0), cx)) + .child(Label::new("75%").size(LabelSize::Small)), + ) + .child( + container() + .child(CircularProgress::new(100.0, max_value, px(48.0), cx)) + .child(Label::new("100%").size(LabelSize::Small)), + ) + .into_any_element(), + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/progress/progress_bar.rs b/crates/ui/src/components/progress/progress_bar.rs index b46b0523681..b726b10b1c7 100644 --- a/crates/ui/src/components/progress/progress_bar.rs +++ b/crates/ui/src/components/progress/progress_bar.rs @@ -76,6 +76,7 @@ impl RenderOnce for ProgressBar { offset: point(px(0.), px(1.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }]) .child( div() @@ -93,53 +94,51 @@ impl Component for ProgressBar { ComponentScope::Status } - fn description() -> Option<&'static str> { - Some(Self::DOCS) + fn description() -> &'static str { + Self::DOCS } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let max_value = 180.0; let container = || v_flex().w_full().gap_1(); - Some( - example_group(vec![single_example( - "Examples", - v_flex() - .w_full() - .gap_2() - .child( - container() - .child( - h_flex() - .justify_between() - .child(Label::new("0%")) - .child(Label::new("Empty")), - ) - .child(ProgressBar::new("empty", 0.0, max_value, cx)), - ) - .child( - container() - .child( - h_flex() - .justify_between() - .child(Label::new("38%")) - .child(Label::new("Partial")), - ) - .child(ProgressBar::new("partial", max_value * 0.35, max_value, cx)), - ) - .child( - container() - .child( - h_flex() - .justify_between() - .child(Label::new("100%")) - .child(Label::new("Complete")), - ) - .child(ProgressBar::new("filled", max_value, max_value, cx)), - ) - .into_any_element(), - )]) - .into_any_element(), - ) + example_group(vec![single_example( + "Examples", + v_flex() + .w_full() + .gap_2() + .child( + container() + .child( + h_flex() + .justify_between() + .child(Label::new("0%")) + .child(Label::new("Empty")), + ) + .child(ProgressBar::new("empty", 0.0, max_value, cx)), + ) + .child( + container() + .child( + h_flex() + .justify_between() + .child(Label::new("38%")) + .child(Label::new("Partial")), + ) + .child(ProgressBar::new("partial", max_value * 0.35, max_value, cx)), + ) + .child( + container() + .child( + h_flex() + .justify_between() + .child(Label::new("100%")) + .child(Label::new("Complete")), + ) + .child(ProgressBar::new("filled", max_value, max_value, cx)), + ) + .into_any_element(), + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/tab.rs b/crates/ui/src/components/tab.rs index e6823f46b75..6a25ad43457 100644 --- a/crates/ui/src/components/tab.rs +++ b/crates/ui/src/components/tab.rs @@ -185,54 +185,51 @@ impl Component for Tab { ComponentScope::Navigation } - fn description() -> Option<&'static str> { - Some( - "A tab component that can be used in a tabbed interface, supporting different positions and states.", - ) + fn description() -> &'static str { + "A tab component that can be used in a tabbed interface, \ + supporting different positions and states." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![example_group_with_title( - "Variations", - vec![ - single_example( - "Default", - Tab::new("default").child("Default Tab").into_any_element(), - ), - single_example( - "Selected", - Tab::new("selected") - .toggle_state(true) - .child("Selected Tab") - .into_any_element(), - ), - single_example( - "First", - Tab::new("first") - .position(TabPosition::First) - .child("First Tab") - .into_any_element(), - ), - single_example( - "Middle", - Tab::new("middle") - .position(TabPosition::Middle(Ordering::Equal)) - .child("Middle Tab") - .into_any_element(), - ), - single_example( - "Last", - Tab::new("last") - .position(TabPosition::Last) - .child("Last Tab") - .into_any_element(), - ), - ], - )]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![example_group_with_title( + "Variations", + vec![ + single_example( + "Default", + Tab::new("default").child("Default Tab").into_any_element(), + ), + single_example( + "Selected", + Tab::new("selected") + .toggle_state(true) + .child("Selected Tab") + .into_any_element(), + ), + single_example( + "First", + Tab::new("first") + .position(TabPosition::First) + .child("First Tab") + .into_any_element(), + ), + single_example( + "Middle", + Tab::new("middle") + .position(TabPosition::Middle(Ordering::Equal)) + .child("Middle Tab") + .into_any_element(), + ), + single_example( + "Last", + Tab::new("last") + .position(TabPosition::Last) + .child("Last Tab") + .into_any_element(), + ), + ], + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 7ebbaab0719..0891b3ac1f4 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -161,47 +161,46 @@ impl Component for TabBar { "TabBar" } - fn description() -> Option<&'static str> { - Some("A horizontal bar containing tabs for navigation between different views or sections.") + fn description() -> &'static str { + "A horizontal bar containing tabs for navigation between different views \ + or sections." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Empty TabBar", - TabBar::new("empty_tab_bar").into_any_element(), - ), - single_example( - "With Tabs", - TabBar::new("tab_bar_with_tabs") - .child(Tab::new("tab1")) - .child(Tab::new("tab2")) - .child(Tab::new("tab3")) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Start and End Children", - vec![single_example( - "Full TabBar", - TabBar::new("full_tab_bar") - .start_child(Button::new("start_button", "Start")) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Usage", + vec![ + single_example( + "Empty TabBar", + TabBar::new("empty_tab_bar").into_any_element(), + ), + single_example( + "With Tabs", + TabBar::new("tab_bar_with_tabs") .child(Tab::new("tab1")) .child(Tab::new("tab2")) .child(Tab::new("tab3")) - .end_child(Button::new("end_button", "End")) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "With Start and End Children", + vec![single_example( + "Full TabBar", + TabBar::new("full_tab_bar") + .start_child(Button::new("start_button", "Start")) + .child(Tab::new("tab1")) + .child(Tab::new("tab2")) + .child(Tab::new("tab3")) + .end_child(Button::new("end_button", "End")) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index 0b1d7884687..85568dbb01b 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -696,131 +696,129 @@ impl Component for SwitchField { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some("A field component that combines a label, description, and switch") + fn description() -> &'static str { + "A field component that combines a label, description, and switch" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "States", - vec![ - single_example( - "Unselected", - SwitchField::new( - "switch_field_unselected", - Some("Enable notifications"), - Some("Receive notifications when new messages arrive.".into()), - ToggleState::Unselected, - |_, _, _| {}, - ) - .into_any_element(), - ), - single_example( - "Selected", - SwitchField::new( - "switch_field_selected", - Some("Enable notifications"), - Some("Receive notifications when new messages arrive.".into()), - ToggleState::Selected, - |_, _, _| {}, - ) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Colors", - vec![ - single_example( - "Default", - SwitchField::new( - "switch_field_default", - Some("Default color"), - Some("This uses the default switch color.".into()), - ToggleState::Selected, - |_, _, _| {}, - ) - .into_any_element(), - ), - single_example( - "Accent", - SwitchField::new( - "switch_field_accent", - Some("Accent color"), - Some("This uses the accent color scheme.".into()), - ToggleState::Selected, - |_, _, _| {}, - ) - .color(SwitchColor::Accent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Disabled", - vec![single_example( - "Disabled", + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "States", + vec![ + single_example( + "Unselected", SwitchField::new( - "switch_field_disabled", - Some("Disabled field"), - Some("This field is disabled and cannot be toggled.".into()), + "switch_field_unselected", + Some("Enable notifications"), + Some("Receive notifications when new messages arrive.".into()), + ToggleState::Unselected, + |_, _, _| {}, + ) + .into_any_element(), + ), + single_example( + "Selected", + SwitchField::new( + "switch_field_selected", + Some("Enable notifications"), + Some("Receive notifications when new messages arrive.".into()), ToggleState::Selected, |_, _, _| {}, ) - .disabled(true) .into_any_element(), - )], - ), - example_group_with_title( - "No Description", - vec![single_example( - "No Description", + ), + ], + ), + example_group_with_title( + "Colors", + vec![ + single_example( + "Default", SwitchField::new( - "switch_field_disabled", - Some("Disabled field"), + "switch_field_default", + Some("Default color"), + Some("This uses the default switch color.".into()), + ToggleState::Selected, + |_, _, _| {}, + ) + .into_any_element(), + ), + single_example( + "Accent", + SwitchField::new( + "switch_field_accent", + Some("Accent color"), + Some("This uses the accent color scheme.".into()), + ToggleState::Selected, + |_, _, _| {}, + ) + .color(SwitchColor::Accent) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Disabled", + vec![single_example( + "Disabled", + SwitchField::new( + "switch_field_disabled", + Some("Disabled field"), + Some("This field is disabled and cannot be toggled.".into()), + ToggleState::Selected, + |_, _, _| {}, + ) + .disabled(true) + .into_any_element(), + )], + ), + example_group_with_title( + "No Description", + vec![single_example( + "No Description", + SwitchField::new( + "switch_field_disabled", + Some("Disabled field"), + None, + ToggleState::Selected, + |_, _, _| {}, + ) + .into_any_element(), + )], + ), + example_group_with_title( + "With Tooltip", + vec![ + single_example( + "Tooltip with Description", + SwitchField::new( + "switch_field_tooltip_with_desc", + Some("Nice Feature"), + Some("Enable advanced configuration options.".into()), + ToggleState::Unselected, + |_, _, _| {}, + ) + .tooltip(Tooltip::text("This is content for this tooltip!")) + .into_any_element(), + ), + single_example( + "Tooltip without Description", + SwitchField::new( + "switch_field_tooltip_no_desc", + Some("Nice Feature"), None, ToggleState::Selected, |_, _, _| {}, ) + .tooltip(Tooltip::text("This is content for this tooltip!")) .into_any_element(), - )], - ), - example_group_with_title( - "With Tooltip", - vec![ - single_example( - "Tooltip with Description", - SwitchField::new( - "switch_field_tooltip_with_desc", - Some("Nice Feature"), - Some("Enable advanced configuration options.".into()), - ToggleState::Unselected, - |_, _, _| {}, - ) - .tooltip(Tooltip::text("This is content for this tooltip!")) - .into_any_element(), - ), - single_example( - "Tooltip without Description", - SwitchField::new( - "switch_field_tooltip_no_desc", - Some("Nice Feature"), - None, - ToggleState::Selected, - |_, _, _| {}, - ) - .tooltip(Tooltip::text("This is content for this tooltip!")) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + ]) + .into_any_element() } } @@ -829,112 +827,105 @@ impl Component for Checkbox { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some("A checkbox component that can be used for multiple choice selections") + fn description() -> &'static str { + "A checkbox component that can be used for multiple choice selections" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "States", - vec![ - single_example( - "Unselected", - Checkbox::new("checkbox_unselected", ToggleState::Unselected) - .into_any_element(), - ), - single_example( - "Placeholder", - Checkbox::new("checkbox_indeterminate", ToggleState::Selected) - .placeholder(true) - .into_any_element(), - ), - single_example( - "Indeterminate", - Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate) - .into_any_element(), - ), - single_example( - "Selected", - Checkbox::new("checkbox_selected", ToggleState::Selected) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Styles", - vec![ - single_example( - "Default", - Checkbox::new("checkbox_default", ToggleState::Selected) - .into_any_element(), - ), - single_example( - "Filled", - Checkbox::new("checkbox_filled", ToggleState::Selected) - .fill() - .into_any_element(), - ), - single_example( - "ElevationBased", - Checkbox::new("checkbox_elevation", ToggleState::Selected) - .style(ToggleStyle::ElevationBased( - ElevationIndex::EditorSurface, - )) - .into_any_element(), - ), - single_example( - "Custom Color", - Checkbox::new("checkbox_custom", ToggleState::Selected) - .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Disabled", - vec![ - single_example( - "Unselected", - Checkbox::new( - "checkbox_disabled_unselected", - ToggleState::Unselected, - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "States", + vec![ + single_example( + "Unselected", + Checkbox::new("checkbox_unselected", ToggleState::Unselected) + .into_any_element(), + ), + single_example( + "Placeholder", + Checkbox::new("checkbox_indeterminate", ToggleState::Selected) + .placeholder(true) + .into_any_element(), + ), + single_example( + "Indeterminate", + Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate) + .into_any_element(), + ), + single_example( + "Selected", + Checkbox::new("checkbox_selected", ToggleState::Selected) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Styles", + vec![ + single_example( + "Default", + Checkbox::new("checkbox_default", ToggleState::Selected) + .into_any_element(), + ), + single_example( + "Filled", + Checkbox::new("checkbox_filled", ToggleState::Selected) + .fill() + .into_any_element(), + ), + single_example( + "ElevationBased", + Checkbox::new("checkbox_elevation", ToggleState::Selected) + .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)) + .into_any_element(), + ), + single_example( + "Custom Color", + Checkbox::new("checkbox_custom", ToggleState::Selected) + .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Disabled", + vec![ + single_example( + "Unselected", + Checkbox::new("checkbox_disabled_unselected", ToggleState::Unselected) .disabled(true) .into_any_element(), - ), - single_example( - "Selected", - Checkbox::new("checkbox_disabled_selected", ToggleState::Selected) - .disabled(true) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Label", - vec![single_example( - "Default", - Checkbox::new("checkbox_with_label", ToggleState::Selected) - .label("Always save on quit") + ), + single_example( + "Selected", + Checkbox::new("checkbox_disabled_selected", ToggleState::Selected) + .disabled(true) .into_any_element(), - )], - ), - example_group_with_title( - "Extra", - vec![single_example( - "Visualization-Only", - Checkbox::new("viz_only", ToggleState::Selected) - .visualization_only(true) - .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "With Label", + vec![single_example( + "Default", + Checkbox::new("checkbox_with_label", ToggleState::Selected) + .label("Always save on quit") + .into_any_element(), + )], + ), + example_group_with_title( + "Extra", + vec![single_example( + "Visualization-Only", + Checkbox::new("viz_only", ToggleState::Selected) + .visualization_only(true) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } @@ -943,120 +934,115 @@ impl Component for Switch { ComponentScope::Input } - fn description() -> Option<&'static str> { - Some("A switch component that represents binary states like on/off") + fn description() -> &'static str { + "A switch component that represents binary states like on/off" } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "States", - vec![ - single_example( - "Off", - Switch::new("switch_off", ToggleState::Unselected) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "On", - Switch::new("switch_on", ToggleState::Selected) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Colors", - vec![ - single_example( - "Accent (Default)", - Switch::new("switch_accent_style", ToggleState::Selected) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Custom", - Switch::new("switch_custom_style", ToggleState::Selected) - .color(SwitchColor::Custom(hsla(300.0 / 360.0, 0.6, 0.6, 1.0))) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Disabled", - vec![ - single_example( - "Off", - Switch::new("switch_disabled_off", ToggleState::Unselected) - .disabled(true) - .into_any_element(), - ), - single_example( - "On", - Switch::new("switch_disabled_on", ToggleState::Selected) - .disabled(true) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Label", - vec![ - single_example( - "Start Label", - Switch::new("switch_with_label_start", ToggleState::Selected) - .label("Always save on quit") - .label_position(SwitchLabelPosition::Start) - .into_any_element(), - ), - single_example( - "End Label", - Switch::new("switch_with_label_end", ToggleState::Selected) - .label("Always save on quit") - .label_position(SwitchLabelPosition::End) - .into_any_element(), - ), - single_example( - "Default Size Label", - Switch::new( - "switch_with_label_default_size", - ToggleState::Selected, - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "States", + vec![ + single_example( + "Off", + Switch::new("switch_off", ToggleState::Unselected) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + single_example( + "On", + Switch::new("switch_on", ToggleState::Selected) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Colors", + vec![ + single_example( + "Accent (Default)", + Switch::new("switch_accent_style", ToggleState::Selected) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + single_example( + "Custom", + Switch::new("switch_custom_style", ToggleState::Selected) + .color(SwitchColor::Custom(hsla(300.0 / 360.0, 0.6, 0.6, 1.0))) + .on_click(|_, _, _cx| {}) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Disabled", + vec![ + single_example( + "Off", + Switch::new("switch_disabled_off", ToggleState::Unselected) + .disabled(true) + .into_any_element(), + ), + single_example( + "On", + Switch::new("switch_disabled_on", ToggleState::Selected) + .disabled(true) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "With Label", + vec![ + single_example( + "Start Label", + Switch::new("switch_with_label_start", ToggleState::Selected) + .label("Always save on quit") + .label_position(SwitchLabelPosition::Start) + .into_any_element(), + ), + single_example( + "End Label", + Switch::new("switch_with_label_end", ToggleState::Selected) + .label("Always save on quit") + .label_position(SwitchLabelPosition::End) + .into_any_element(), + ), + single_example( + "Default Size Label", + Switch::new("switch_with_label_default_size", ToggleState::Selected) .label("Always save on quit") .label_size(LabelSize::Default) .into_any_element(), - ), - single_example( - "Small Size Label", - Switch::new("switch_with_label_small_size", ToggleState::Selected) - .label("Always save on quit") - .label_size(LabelSize::Small) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Keybinding", - vec![single_example( - "Keybinding", - Switch::new("switch_with_keybinding", ToggleState::Selected) - .key_binding(Some(KeyBinding::from_keystrokes( - vec![KeybindingKeystroke::from_keystroke( - Keystroke::parse("cmd-s").unwrap(), - )] - .into(), - false, - ))) + ), + single_example( + "Small Size Label", + Switch::new("switch_with_label_small_size", ToggleState::Selected) + .label("Always save on quit") + .label_size(LabelSize::Small) .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) + ), + ], + ), + example_group_with_title( + "With Keybinding", + vec![single_example( + "Keybinding", + Switch::new("switch_with_keybinding", ToggleState::Selected) + .key_binding(Some(KeyBinding::from_keystrokes( + vec![KeybindingKeystroke::from_keystroke( + Keystroke::parse("cmd-s").unwrap(), + )] + .into(), + false, + ))) + .into_any_element(), + )], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 8124b4ecbaf..82e7d9b1118 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -274,21 +274,18 @@ impl Component for Tooltip { ComponentScope::DataDisplay } - fn description() -> Option<&'static str> { - Some( - "A tooltip that appears when hovering over an element, optionally showing a keybinding or additional metadata.", - ) + fn description() -> &'static str { + "A tooltip that appears when hovering over an element, \ + optionally showing a keybinding or additional metadata." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - example_group(vec![single_example( - "Text only", - Button::new("delete-example", "Delete") - .tooltip(Tooltip::text("This is a tooltip!")) - .into_any_element(), - )]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + example_group(vec![single_example( + "Text only", + Button::new("delete-example", "Delete") + .tooltip(Tooltip::text("This is a tooltip!")) + .into_any_element(), + )]) + .into_any_element() } } diff --git a/crates/ui/src/components/tree_view_item.rs b/crates/ui/src/components/tree_view_item.rs index f6d90fceff5..9474548675d 100644 --- a/crates/ui/src/components/tree_view_item.rs +++ b/crates/ui/src/components/tree_view_item.rs @@ -223,13 +223,12 @@ impl Component for TreeViewItem { ComponentScope::Navigation } - fn description() -> Option<&'static str> { - Some( - "A hierarchical list of items that may have a parent-child relationship where children can be toggled into view by expanding or collapsing their parent item.", - ) + fn description() -> &'static str { + "A hierarchical list of items that may have a parent-child relationship \ + where children can be toggled into view by expanding or collapsing their parent item." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container = || { v_flex() .p_2() @@ -239,58 +238,56 @@ impl Component for TreeViewItem { .bg(cx.theme().colors().panel_background) }; - Some( - example_group(vec![ - single_example( - "Basic Tree View", - container() - .child( - TreeViewItem::new("index-1", "Tree Item Root #1") - .root_item(true) - .toggle_state(true), - ) - .child(TreeViewItem::new("index-2", "Tree Item #2")) - .child(TreeViewItem::new("index-3", "Tree Item #3")) - .child(TreeViewItem::new("index-4", "Tree Item Root #2").root_item(true)) - .child(TreeViewItem::new("index-5", "Tree Item #5")) - .child(TreeViewItem::new("index-6", "Tree Item #6")) - .into_any_element(), - ), - single_example( - "Active Child", - container() - .child(TreeViewItem::new("index-1", "Tree Item Root #1").root_item(true)) - .child(TreeViewItem::new("index-2", "Tree Item #2").toggle_state(true)) - .child(TreeViewItem::new("index-3", "Tree Item #3")) - .into_any_element(), - ), - single_example( - "Focused Parent", - container() - .child( - TreeViewItem::new("index-1", "Tree Item Root #1") - .root_item(true) - .focused(true) - .toggle_state(true), - ) - .child(TreeViewItem::new("index-2", "Tree Item #2")) - .child(TreeViewItem::new("index-3", "Tree Item #3")) - .into_any_element(), - ), - single_example( - "Focused Child", - container() - .child( - TreeViewItem::new("index-1", "Tree Item Root #1") - .root_item(true) - .toggle_state(true), - ) - .child(TreeViewItem::new("index-2", "Tree Item #2").focused(true)) - .child(TreeViewItem::new("index-3", "Tree Item #3")) - .into_any_element(), - ), - ]) - .into_any_element(), - ) + example_group(vec![ + single_example( + "Basic Tree View", + container() + .child( + TreeViewItem::new("index-1", "Tree Item Root #1") + .root_item(true) + .toggle_state(true), + ) + .child(TreeViewItem::new("index-2", "Tree Item #2")) + .child(TreeViewItem::new("index-3", "Tree Item #3")) + .child(TreeViewItem::new("index-4", "Tree Item Root #2").root_item(true)) + .child(TreeViewItem::new("index-5", "Tree Item #5")) + .child(TreeViewItem::new("index-6", "Tree Item #6")) + .into_any_element(), + ), + single_example( + "Active Child", + container() + .child(TreeViewItem::new("index-1", "Tree Item Root #1").root_item(true)) + .child(TreeViewItem::new("index-2", "Tree Item #2").toggle_state(true)) + .child(TreeViewItem::new("index-3", "Tree Item #3")) + .into_any_element(), + ), + single_example( + "Focused Parent", + container() + .child( + TreeViewItem::new("index-1", "Tree Item Root #1") + .root_item(true) + .focused(true) + .toggle_state(true), + ) + .child(TreeViewItem::new("index-2", "Tree Item #2")) + .child(TreeViewItem::new("index-3", "Tree Item #3")) + .into_any_element(), + ), + single_example( + "Focused Child", + container() + .child( + TreeViewItem::new("index-1", "Tree Item Root #1") + .root_item(true) + .toggle_state(true), + ) + .child(TreeViewItem::new("index-2", "Tree Item #2").focused(true)) + .child(TreeViewItem::new("index-3", "Tree Item #3")) + .into_any_element(), + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/styles/animation.rs b/crates/ui/src/styles/animation.rs index aa64fc1907b..ea08b74ca9a 100644 --- a/crates/ui/src/styles/animation.rs +++ b/crates/ui/src/styles/animation.rs @@ -107,11 +107,11 @@ impl Component for Animation { ComponentScope::Utilities } - fn description() -> Option<&'static str> { - Some("Demonstrates various animation patterns and transitions available in the UI system.") + fn description() -> &'static str { + "Demonstrates various animation patterns and transitions available in the UI system." } - fn preview(_window: &mut Window, cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let container_size = 128.0; let element_size = 32.0; let offset = container_size / 2.0 - element_size / 2.0; @@ -126,152 +126,150 @@ impl Component for Animation { .rounded_sm() }; - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Animate In", - vec![ - single_example( - "From Bottom", - container() - .size(px(container_size)) - .child( - div() - .id("animate-in-from-bottom") - .absolute() - .size(px(element_size)) - .left(px(offset)) - .rounded_md() - .bg(gpui::red()) - .animate_in_from_bottom(false), - ) - .into_any_element(), - ), - single_example( - "From Top", - container() - .size(px(container_size)) - .child( - div() - .id("animate-in-from-top") - .absolute() - .size(px(element_size)) - .left(px(offset)) - .rounded_md() - .bg(gpui::blue()) - .animate_in_from_top(false), - ) - .into_any_element(), - ), - single_example( - "From Left", - container() - .size(px(container_size)) - .child( - div() - .id("animate-in-from-left") - .absolute() - .size(px(element_size)) - .top(px(offset)) - .rounded_md() - .bg(gpui::green()) - .animate_in_from_left(false), - ) - .into_any_element(), - ), - single_example( - "From Right", - container() - .size(px(container_size)) - .child( - div() - .id("animate-in-from-right") - .absolute() - .size(px(element_size)) - .top(px(offset)) - .rounded_md() - .bg(gpui::yellow()) - .animate_in_from_right(false), - ) - .into_any_element(), - ), - ], - ) - .grow(), - example_group_with_title( - "Fade and Animate In", - vec![ - single_example( - "From Bottom", - container() - .size(px(container_size)) - .child( - div() - .id("fade-animate-in-from-bottom") - .absolute() - .size(px(element_size)) - .left(px(offset)) - .rounded_md() - .bg(gpui::red()) - .animate_in_from_bottom(true), - ) - .into_any_element(), - ), - single_example( - "From Top", - container() - .size(px(container_size)) - .child( - div() - .id("fade-animate-in-from-top") - .absolute() - .size(px(element_size)) - .left(px(offset)) - .rounded_md() - .bg(gpui::blue()) - .animate_in_from_top(true), - ) - .into_any_element(), - ), - single_example( - "From Left", - container() - .size(px(container_size)) - .child( - div() - .id("fade-animate-in-from-left") - .absolute() - .size(px(element_size)) - .top(px(offset)) - .rounded_md() - .bg(gpui::green()) - .animate_in_from_left(true), - ) - .into_any_element(), - ), - single_example( - "From Right", - container() - .size(px(container_size)) - .child( - div() - .id("fade-animate-in-from-right") - .absolute() - .size(px(element_size)) - .top(px(offset)) - .rounded_md() - .bg(gpui::yellow()) - .animate_in_from_right(true), - ) - .into_any_element(), - ), - ], - ) - .grow(), - ]) - .into_any_element(), - ) + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Animate In", + vec![ + single_example( + "From Bottom", + container() + .size(px(container_size)) + .child( + div() + .id("animate-in-from-bottom") + .absolute() + .size(px(element_size)) + .left(px(offset)) + .rounded_md() + .bg(gpui::red()) + .animate_in_from_bottom(false), + ) + .into_any_element(), + ), + single_example( + "From Top", + container() + .size(px(container_size)) + .child( + div() + .id("animate-in-from-top") + .absolute() + .size(px(element_size)) + .left(px(offset)) + .rounded_md() + .bg(gpui::blue()) + .animate_in_from_top(false), + ) + .into_any_element(), + ), + single_example( + "From Left", + container() + .size(px(container_size)) + .child( + div() + .id("animate-in-from-left") + .absolute() + .size(px(element_size)) + .top(px(offset)) + .rounded_md() + .bg(gpui::green()) + .animate_in_from_left(false), + ) + .into_any_element(), + ), + single_example( + "From Right", + container() + .size(px(container_size)) + .child( + div() + .id("animate-in-from-right") + .absolute() + .size(px(element_size)) + .top(px(offset)) + .rounded_md() + .bg(gpui::yellow()) + .animate_in_from_right(false), + ) + .into_any_element(), + ), + ], + ) + .grow(), + example_group_with_title( + "Fade and Animate In", + vec![ + single_example( + "From Bottom", + container() + .size(px(container_size)) + .child( + div() + .id("fade-animate-in-from-bottom") + .absolute() + .size(px(element_size)) + .left(px(offset)) + .rounded_md() + .bg(gpui::red()) + .animate_in_from_bottom(true), + ) + .into_any_element(), + ), + single_example( + "From Top", + container() + .size(px(container_size)) + .child( + div() + .id("fade-animate-in-from-top") + .absolute() + .size(px(element_size)) + .left(px(offset)) + .rounded_md() + .bg(gpui::blue()) + .animate_in_from_top(true), + ) + .into_any_element(), + ), + single_example( + "From Left", + container() + .size(px(container_size)) + .child( + div() + .id("fade-animate-in-from-left") + .absolute() + .size(px(element_size)) + .top(px(offset)) + .rounded_md() + .bg(gpui::green()) + .animate_in_from_left(true), + ) + .into_any_element(), + ), + single_example( + "From Right", + container() + .size(px(container_size)) + .child( + div() + .id("fade-animate-in-from-right") + .absolute() + .size(px(element_size)) + .top(px(offset)) + .rounded_md() + .bg(gpui::yellow()) + .animate_in_from_right(true), + ) + .into_any_element(), + ), + ], + ) + .grow(), + ]) + .into_any_element() } } diff --git a/crates/ui/src/styles/color.rs b/crates/ui/src/styles/color.rs index 586b2ccc576..b28e40bff09 100644 --- a/crates/ui/src/styles/color.rs +++ b/crates/ui/src/styles/color.rs @@ -129,116 +129,114 @@ impl Component for Color { ComponentScope::Utilities } - fn description() -> Option<&'static str> { - Some(Color::DOCS) + fn description() -> &'static str { + Color::DOCS } - fn preview(_window: &mut gpui::Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Text Colors", - vec![ - single_example( - "Default", - Label::new("Default text color") - .color(Color::Default) - .into_any_element(), - ) - .description(Color::Default.get_variant_docs()), - single_example( - "Muted", - Label::new("Muted text color") - .color(Color::Muted) - .into_any_element(), - ) - .description(Color::Muted.get_variant_docs()), - single_example( - "Accent", - Label::new("Accent text color") - .color(Color::Accent) - .into_any_element(), - ) - .description(Color::Accent.get_variant_docs()), - single_example( - "Disabled", - Label::new("Disabled text color") - .color(Color::Disabled) - .into_any_element(), - ) - .description(Color::Disabled.get_variant_docs()), - ], - ), - example_group_with_title( - "Status Colors", - vec![ - single_example( - "Success", - Label::new("Success status") - .color(Color::Success) - .into_any_element(), - ) - .description(Color::Success.get_variant_docs()), - single_example( - "Warning", - Label::new("Warning status") - .color(Color::Warning) - .into_any_element(), - ) - .description(Color::Warning.get_variant_docs()), - single_example( - "Error", - Label::new("Error status") - .color(Color::Error) - .into_any_element(), - ) - .description(Color::Error.get_variant_docs()), - single_example( - "Info", - Label::new("Info status") - .color(Color::Info) - .into_any_element(), - ) - .description(Color::Info.get_variant_docs()), - ], - ), - example_group_with_title( - "Version Control Colors", - vec![ - single_example( - "Created", - Label::new("Created item") - .color(Color::Created) - .into_any_element(), - ) - .description(Color::Created.get_variant_docs()), - single_example( - "Modified", - Label::new("Modified item") - .color(Color::Modified) - .into_any_element(), - ) - .description(Color::Modified.get_variant_docs()), - single_example( - "Deleted", - Label::new("Deleted item") - .color(Color::Deleted) - .into_any_element(), - ) - .description(Color::Deleted.get_variant_docs()), - single_example( - "Conflict", - Label::new("Conflict item") - .color(Color::Conflict) - .into_any_element(), - ) - .description(Color::Conflict.get_variant_docs()), - ], - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut gpui::Window, _cx: &mut App) -> gpui::AnyElement { + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Text Colors", + vec![ + single_example( + "Default", + Label::new("Default text color") + .color(Color::Default) + .into_any_element(), + ) + .description(Color::Default.get_variant_docs()), + single_example( + "Muted", + Label::new("Muted text color") + .color(Color::Muted) + .into_any_element(), + ) + .description(Color::Muted.get_variant_docs()), + single_example( + "Accent", + Label::new("Accent text color") + .color(Color::Accent) + .into_any_element(), + ) + .description(Color::Accent.get_variant_docs()), + single_example( + "Disabled", + Label::new("Disabled text color") + .color(Color::Disabled) + .into_any_element(), + ) + .description(Color::Disabled.get_variant_docs()), + ], + ), + example_group_with_title( + "Status Colors", + vec![ + single_example( + "Success", + Label::new("Success status") + .color(Color::Success) + .into_any_element(), + ) + .description(Color::Success.get_variant_docs()), + single_example( + "Warning", + Label::new("Warning status") + .color(Color::Warning) + .into_any_element(), + ) + .description(Color::Warning.get_variant_docs()), + single_example( + "Error", + Label::new("Error status") + .color(Color::Error) + .into_any_element(), + ) + .description(Color::Error.get_variant_docs()), + single_example( + "Info", + Label::new("Info status") + .color(Color::Info) + .into_any_element(), + ) + .description(Color::Info.get_variant_docs()), + ], + ), + example_group_with_title( + "Version Control Colors", + vec![ + single_example( + "Created", + Label::new("Created item") + .color(Color::Created) + .into_any_element(), + ) + .description(Color::Created.get_variant_docs()), + single_example( + "Modified", + Label::new("Modified item") + .color(Color::Modified) + .into_any_element(), + ) + .description(Color::Modified.get_variant_docs()), + single_example( + "Deleted", + Label::new("Deleted item") + .color(Color::Deleted) + .into_any_element(), + ) + .description(Color::Deleted.get_variant_docs()), + single_example( + "Conflict", + Label::new("Conflict item") + .color(Color::Conflict) + .into_any_element(), + ) + .description(Color::Conflict.get_variant_docs()), + ], + ), + ]) + .into_any_element() } } diff --git a/crates/ui/src/styles/elevation.rs b/crates/ui/src/styles/elevation.rs index e6df718b8f7..2f0bc2618e4 100644 --- a/crates/ui/src/styles/elevation.rs +++ b/crates/ui/src/styles/elevation.rs @@ -52,12 +52,14 @@ impl ElevationIndex { offset: point(px(0.), px(2.)), blur_radius: px(3.), spread_radius: px(0.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., if is_light { 0.03 } else { 0.06 }), offset: point(px(0.), px(1.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }, ], @@ -67,24 +69,28 @@ impl ElevationIndex { offset: point(px(0.), px(2.)), blur_radius: px(3.), spread_radius: px(0.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., if is_light { 0.06 } else { 0.08 }), offset: point(px(0.), px(3.)), blur_radius: px(6.), spread_radius: px(0.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., 0.04), offset: point(px(0.), px(6.)), blur_radius: px(12.), spread_radius: px(0.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., if is_light { 0.04 } else { 0.12 }), offset: point(px(0.), px(1.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }, ], diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 69790d3d3da..9d384c37957 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -250,45 +250,43 @@ impl Component for Headline { ComponentScope::Typography } - fn description() -> Option<&'static str> { - Some("A headline element used to emphasize text and create visual hierarchy in the UI.") + fn description() -> &'static str { + "A headline element used to emphasize text and create visual hierarchy in the UI." } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_1() - .children(vec![ - single_example( - "XLarge", - Headline::new("XLarge Headline") - .size(HeadlineSize::XLarge) - .into_any_element(), - ), - single_example( - "Large", - Headline::new("Large Headline") - .size(HeadlineSize::Large) - .into_any_element(), - ), - single_example( - "Medium (Default)", - Headline::new("Medium Headline").into_any_element(), - ), - single_example( - "Small", - Headline::new("Small Headline") - .size(HeadlineSize::Small) - .into_any_element(), - ), - single_example( - "XSmall", - Headline::new("XSmall Headline") - .size(HeadlineSize::XSmall) - .into_any_element(), - ), - ]) - .into_any_element(), - ) + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + v_flex() + .gap_1() + .children(vec![ + single_example( + "XLarge", + Headline::new("XLarge Headline") + .size(HeadlineSize::XLarge) + .into_any_element(), + ), + single_example( + "Large", + Headline::new("Large Headline") + .size(HeadlineSize::Large) + .into_any_element(), + ), + single_example( + "Medium (Default)", + Headline::new("Medium Headline").into_any_element(), + ), + single_example( + "Small", + Headline::new("Small Headline") + .size(HeadlineSize::Small) + .into_any_element(), + ), + single_example( + "XSmall", + Headline::new("XSmall Headline") + .size(HeadlineSize::XSmall) + .into_any_element(), + ), + ]) + .into_any_element() } } diff --git a/crates/ui_input/src/input_field.rs b/crates/ui_input/src/input_field.rs index 16932b58e87..8a95651950a 100644 --- a/crates/ui_input/src/input_field.rs +++ b/crates/ui_input/src/input_field.rs @@ -224,7 +224,13 @@ impl Component for InputField { ComponentScope::Input } - fn preview(window: &mut Window, cx: &mut App) -> Option { + fn description() -> &'static str { + "A single-line text field used for search inputs, \ + form fields, and similar inputs, supporting labels, placeholders, \ + leading icons, and masked content." + } + + fn preview(window: &mut Window, cx: &mut App) -> AnyElement { let input_small = cx.new(|cx| InputField::new(window, cx, "placeholder").label("Small Label")); @@ -234,20 +240,18 @@ impl Component for InputField { .label_size(LabelSize::Default) }); - Some( - v_flex() - .gap_6() - .children(vec![example_group(vec![ - single_example( - "Small Label (Default)", - div().child(input_small).into_any_element(), - ), - single_example( - "Regular Label", - div().child(input_regular).into_any_element(), - ), - ])]) - .into_any_element(), - ) + v_flex() + .gap_6() + .children(vec![example_group(vec![ + single_example( + "Small Label (Default)", + div().child(input_small).into_any_element(), + ), + single_example( + "Regular Label", + div().child(input_regular).into_any_element(), + ), + ])]) + .into_any_element() } } diff --git a/crates/ui_macros/src/ui_macros.rs b/crates/ui_macros/src/ui_macros.rs index ce48a21c115..75c1db3a5ee 100644 --- a/crates/ui_macros/src/ui_macros.rs +++ b/crates/ui_macros/src/ui_macros.rs @@ -19,14 +19,20 @@ pub fn derive_dynamic_spacing(input: TokenStream) -> TokenStream { /// # Example /// /// ``` -/// use ui::Component; +/// use ui::{AnyElement, App, Component, div, IntoElement, Window}; /// use ui_macros::RegisterComponent; /// /// #[derive(RegisterComponent)] /// struct MyComponent; /// /// impl Component for MyComponent { -/// // Component implementation +/// fn description() -> &'static str { +/// "My component description" +/// } +/// +/// fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { +/// div().into_any_element() +/// } /// } /// ``` /// diff --git a/crates/util/src/command/darwin.rs b/crates/util/src/command/darwin.rs index a3d7561f4e3..c9d5bc40aed 100644 --- a/crates/util/src/command/darwin.rs +++ b/crates/util/src/command/darwin.rs @@ -524,15 +524,42 @@ fn spawn_posix_spawn( fn create_pipe() -> io::Result<(libc::c_int, libc::c_int)> { let mut fds: [libc::c_int; 2] = [0; 2]; - let result = unsafe { libc::pipe(fds.as_mut_ptr()) }; - if result == -1 { - return Err(io::Error::last_os_error()); + unsafe { + let result = libc::pipe(fds.as_mut_ptr()); + if result == -1 { + let error = io::Error::last_os_error(); + return Err(error); + } + + // Set close-on-exec on both ends of the pipe. + // + // Without this, unrelated spawns elsewhere in the process (e.g. + // `smol::process` or `async_process`, which on Apple platforms use + // `posix_spawn` *without* `POSIX_SPAWN_CLOEXEC_DEFAULT`) would inherit + // these file descriptors and keep the pipes open even after we drop our + // side. + for &fd in &fds { + let result = libc::ioctl(fd, libc::FIOCLEX); + if result == -1 { + let error = io::Error::last_os_error(); + libc::close(fds[0]); + libc::close(fds[1]); + return Err(error); + } + } + + Ok((fds[0], fds[1])) } - Ok((fds[0], fds[1])) } fn open_dev_null(flags: libc::c_int) -> io::Result { - let fd = unsafe { libc::open(c"/dev/null".as_ptr() as *const libc::c_char, flags) }; + // Set close-on-exec for this pipe, for the same reason as in `create_pipe`. + let fd = unsafe { + libc::open( + c"/dev/null".as_ptr() as *const libc::c_char, + flags | libc::O_CLOEXEC, + ) + }; if fd == -1 { return Err(io::Error::last_os_error()); } @@ -561,6 +588,49 @@ mod tests { use super::*; use futures_lite::AsyncWriteExt; + // Verifies that pipes returned by `create_pipe` aren't visible to unrelated + // child processes spawned via `std::process::Command`. On macOS, `std` + // uses `posix_spawn` without `POSIX_SPAWN_CLOEXEC_DEFAULT`, so any + // non-CLOEXEC fd in the parent leaks into the child. Without + // `FD_CLOEXEC` on our pipe fds, an unrelated spawn (a terminal, the crash + // handler, etc.) running concurrently with a piped git child would hold + // git's stdin write end open and deadlock the git child on `read()`. + #[test] + fn test_create_pipe_not_inherited_by_unrelated_spawn() { + let (read_fd, write_fd) = create_pipe().expect("create_pipe failed"); + + // Probe with the exact fds returned by `create_pipe` (no dup), since + // duping with `F_DUPFD` would lose CLOEXEC and `F_DUPFD_CLOEXEC` would + // unconditionally set it, either of which would defeat the test. + #[allow(clippy::disallowed_methods)] + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(format!( + "for fd in {read_fd} {write_fd}; do \ + if [ -e /dev/fd/$fd ]; then \ + echo $fd WAS INHERITED; \ + else \ + echo $fd WAS NOT INHERITED; \ + fi; \ + done; \ + echo DONE" + )) + .output() + .expect("failed to spawn sh"); + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + + unsafe { + libc::close(read_fd); + libc::close(write_fd); + } + + assert_eq!( + stdout, + format!("{read_fd} WAS NOT INHERITED\n{write_fd} WAS NOT INHERITED\nDONE\n") + ); + } + #[test] fn test_spawn_echo() { smol::block_on(async { diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 314327b0487..5c4b26cdaa0 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1004,8 +1004,9 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - let is_visual = self.mode.is_visual(); - let Some(data) = self.collect_helix_jump_data(is_visual, window, cx) else { + let allow_targets_in_selection = self.mode.has_selection(); + let Some(data) = self.collect_helix_jump_data(allow_targets_in_selection, window, cx) + else { return; }; @@ -1031,7 +1032,7 @@ impl Vim { fn collect_helix_jump_data( &mut self, - is_visual: bool, + allow_targets_in_selection: bool, window: &mut Window, cx: &mut Context, ) -> Option { @@ -1044,7 +1045,11 @@ impl Vim { let end_offset = buffer_snapshot.point_to_offset(visible_range.end); let selections = editor.selections.all::(&display_snapshot); - let skip_data = Self::selection_skip_offsets(buffer_snapshot, &selections, is_visual); + let skip_data = Self::selection_skip_offsets( + buffer_snapshot, + &selections, + allow_targets_in_selection, + ); // Get the primary cursor position for alternating forward/backward labeling let cursor_offset = selections @@ -1254,7 +1259,7 @@ impl Vim { fn selection_skip_offsets( buffer: &MultiBufferSnapshot, selections: &[Selection], - is_visual: bool, + allow_targets_in_selection: bool, ) -> HelixJumpSkipData { let mut skip_points = Vec::with_capacity(selections.len()); let mut skip_ranges = Vec::new(); @@ -1263,8 +1268,7 @@ impl Vim { let head_offset = buffer.point_to_offset(selection.head()); skip_points.push(head_offset); - // In visual mode, don't skip ranges so we can shrink the selection - if !is_visual && selection.start != selection.end { + if !allow_targets_in_selection && selection.start != selection.end { let mut start = buffer.point_to_offset(selection.start); let mut end = buffer.point_to_offset(selection.end); if start > end { @@ -2478,6 +2482,12 @@ mod test { cx.simulate_keystrokes("r x"); cx.assert_state("«xxˇ»", Mode::HelixNormal); + + cx.set_state("«aaˇ»", Mode::HelixSelect); + + cx.simulate_keystrokes("r x"); + + cx.assert_state("«xxˇ»", Mode::HelixNormal); } #[gpui::test] @@ -2562,6 +2572,16 @@ mod test { assert_eq!(cx.mode(), Mode::HelixNormal); } + #[gpui::test] + async fn test_helix_select_append(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state("aˇbcd", Mode::HelixNormal); + cx.simulate_keystrokes("v a"); + cx.assert_state("abˇcd", Mode::Insert); + } + #[gpui::test] async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; @@ -3489,6 +3509,19 @@ mod test { assert_eq!(cx.active_operator(), None); } + #[gpui::test] + async fn test_helix_jump_includes_line_selection_targets(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + cx.set_state("alpha beta\nˇfoo bar baz\nqux quux", Mode::HelixNormal); + + cx.simulate_keystrokes("x"); + jump_to_word(&mut cx, "bar"); + + cx.assert_state("alpha beta\nfoo «barˇ» baz\nqux quux", Mode::HelixNormal); + assert_eq!(cx.active_operator(), None); + } + #[gpui::test] async fn test_vim_jump_moves_to_target_word_start(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 387bca0912b..d1b79de4f57 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -457,6 +457,34 @@ mod test { cx.run_until_parked(); cx.simulate_shared_keystrokes(".").await; cx.shared_state().await.assert_eq("THE QUICK ˇbrown fox"); + + // "q l" (note after macro should be used last change made by macro) + cx.set_shared_state("ˇ").await; + cx.simulate_shared_keystrokes("q l shift-o h e l l o space w o r l d escape q") + .await; + cx.simulate_shared_keystrokes("@ l").await; + cx.shared_state() + .await + .assert_eq("hello worlˇd\nhello world\n"); + cx.simulate_shared_keystrokes(".").await; + cx.shared_state() + .await + .assert_eq("hello worlˇd\nhello world\nhello world\n"); + } + + #[gpui::test] + async fn test_dot_repeat_after_macro_change_motion(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇfoo foo", Mode::Normal); + cx.simulate_keystrokes("q l c f o x escape q"); + cx.assert_state("ˇxo foo", Mode::Normal); + + cx.simulate_keystrokes("w @ l"); + cx.assert_state("xo ˇxo", Mode::Normal); + + cx.simulate_keystrokes("."); + cx.assert_state("xo ˇx", Mode::Normal); } #[gpui::test] diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 1ebf51aa293..92baae1d37f 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -389,6 +389,15 @@ impl Vim { }; let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); + + if self.search.cmd_f_search { + self.search.cmd_f_search = false; + if self.mode.is_visual() { + self.switch_mode(Mode::Normal, false, window, cx); + } + self.sync_vim_settings(window, cx); + } + let prior_selections = self.editor_selections(window, cx); let success = pane.update(cx, |pane, cx| { @@ -433,6 +442,15 @@ impl Vim { }; let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); + + if self.search.cmd_f_search { + self.search.cmd_f_search = false; + if self.mode.is_visual() { + self.switch_mode(Mode::Normal, false, window, cx); + } + self.sync_vim_settings(window, cx); + } + let prior_selections = self.editor_selections(window, cx); let vim = cx.entity(); @@ -1017,6 +1035,46 @@ mod test { cx.assert_state("one «oneˇ» one one", Mode::Insert); } + #[gpui::test] + async fn test_n_after_cmd_f_search(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state("ˇone two one two one", Mode::Normal); + cx.run_until_parked(); + + // Use cmd+f to search (non-vim style) + cx.simulate_keystrokes("cmd-f"); + cx.run_until_parked(); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + + // Now use n to go to next match — should move cursor, not create selection + cx.simulate_keystrokes("n"); + cx.run_until_parked(); + cx.assert_state("one two ˇone two one", Mode::Normal); + + cx.simulate_keystrokes("n"); + cx.run_until_parked(); + cx.assert_state("one two one two ˇone", Mode::Normal); + } + + #[gpui::test] + async fn test_star_after_cmd_f_search(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state("ˇone two one two one", Mode::Normal); + cx.run_until_parked(); + + // Use cmd+f to search (non-vim style) + cx.simulate_keystrokes("cmd-f"); + cx.run_until_parked(); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + + // Now use * to search under cursor — should move cursor, not create selection + cx.simulate_keystrokes("*"); + cx.run_until_parked(); + cx.assert_state("one two ˇone two one", Mode::Normal); + } + #[gpui::test] async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 1c15c7d4866..b92adf4cb5d 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -80,6 +80,11 @@ impl Mode { matches!(self, Self::HelixNormal | Self::HelixSelect) } + /// `HelixNormal` qualifies because its cursor is itself a one-character selection. + pub fn has_selection(&self) -> bool { + self.is_visual() || matches!(self, Self::HelixNormal) + } + pub fn is_normal(&self) -> bool { matches!(self, Self::Normal | Self::HelixNormal) } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index b8e387e9728..e956e585687 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -2066,7 +2066,7 @@ impl Vim { Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { self.visual_replace(text, window, cx) } - Mode::HelixNormal => self.helix_replace(&text, window, cx), + Mode::HelixNormal | Mode::HelixSelect => self.helix_replace(&text, window, cx), _ => self.clear_operator(window, cx), }, Some(Operator::Digraph { first_char }) => { @@ -2233,7 +2233,7 @@ impl Vim { input_enabled: self.editor_input_enabled(), expects_character_input: self.expects_character_input(), autoindent: self.should_autoindent(), - cursor_offset_on_selection: self.mode.is_visual() || self.mode.is_helix(), + cursor_offset_on_selection: self.mode.has_selection(), line_mode: matches!(self.mode, Mode::VisualLine), hide_edit_predictions: !matches!(self.mode, Mode::Insert | Mode::Replace) && !(self.mode.is_normal() diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index bc53167b158..077e098bb3a 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -897,6 +897,9 @@ impl Vim { } }); if !match_exists { + self.update_editor(cx, |_, editor, _| { + editor.set_collapse_matches(true); + }); self.clear_operator(window, cx); self.stop_replaying(cx); return; diff --git a/crates/vim/test_data/test_dot_repeat.json b/crates/vim/test_data/test_dot_repeat.json index 331ef52ecb9..b22cd96981c 100644 --- a/crates/vim/test_data/test_dot_repeat.json +++ b/crates/vim/test_data/test_dot_repeat.json @@ -36,3 +36,25 @@ {"Put":{"state":"THE QUIˇck brown fox"}} {"Key":"."} {"Get":{"state":"THE QUICK ˇbrown fox","mode":"Normal"}} +{"Put":{"state":"ˇ"}} +{"Key":"q"} +{"Key":"l"} +{"Key":"shift-o"} +{"Key":"h"} +{"Key":"e"} +{"Key":"l"} +{"Key":"l"} +{"Key":"o"} +{"Key":"space"} +{"Key":"w"} +{"Key":"o"} +{"Key":"r"} +{"Key":"l"} +{"Key":"d"} +{"Key":"escape"} +{"Key":"q"} +{"Key":"@"} +{"Key":"l"} +{"Get":{"state":"hello worlˇd\nhello world\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello worlˇd\nhello world\nhello world\n","mode":"Normal"}} diff --git a/crates/watch/src/watch.rs b/crates/watch/src/watch.rs index 71dab748200..cffe3216c24 100644 --- a/crates/watch/src/watch.rs +++ b/crates/watch/src/watch.rs @@ -285,7 +285,7 @@ mod tests { futures::future::join_all(tasks).await; } - #[ctor::ctor] + #[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 0200f1b7d57..4bee4ba8c08 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -36,6 +36,7 @@ clock.workspace = true collections.workspace = true component.workspace = true db.workspace = true +dirs.workspace = true futures-lite.workspace = true fs.workspace = true futures.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 62655a90639..8bac0fcefde 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -1046,8 +1046,9 @@ impl Dock { dispatch_context } - pub fn clamp_panel_size(&mut self, max_size: Pixels, window: &Window, cx: &mut App) { + pub fn clamp_panel_size(&mut self, max_size: Pixels, window: &Window, cx: &mut Context) { let max_size = (max_size - RESIZE_HANDLE_SIZE).abs(); + let mut clamped = false; for entry in &mut self.panel_entries { let use_flexible = entry.panel.has_flexible_size(window, cx); if use_flexible { @@ -1060,8 +1061,12 @@ impl Dock { .unwrap_or_else(|| entry.panel.default_size(window, cx)); if size > max_size { entry.size_state.size = Some(max_size.max(RESIZE_HANDLE_SIZE)); + clamped = true; } } + if clamped { + cx.notify(); + } } pub(crate) fn load_persisted_size_state( diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 573a6d9ac0a..7b247a073c1 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -237,6 +237,24 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { fn buffer_kind(&self, _cx: &App) -> ItemBufferKind { ItemBufferKind::None } + + /// Returns the project path that should be treated as active for this item. + /// + /// Singleton items use their only project item by default. Items backed by + /// multiple buffers should override this to return the path for the buffer + /// under the primary cursor or otherwise selected sub-item. + fn active_project_path(&self, cx: &App) -> Option { + if self.buffer_kind(cx) != ItemBufferKind::Singleton { + return None; + } + + let mut result = None; + self.for_each_project_item(cx, &mut |_, item| { + result = item.project_path(cx); + }); + result + } + fn set_nav_history(&mut self, _: ItemNavHistory, _window: &mut Window, _: &mut Context) {} fn can_split(&self) -> bool { @@ -646,14 +664,7 @@ impl ItemHandle for Entity { } fn project_path(&self, cx: &App) -> Option { - let this = self.read(cx); - let mut result = None; - if this.buffer_kind(cx) == ItemBufferKind::Singleton { - this.for_each_project_item(cx, &mut |_, item| { - result = item.project_path(cx); - }); - } - result + ::active_project_path(self.read(cx), cx) } fn workspace_settings<'a>(&self, cx: &'a App) -> &'a WorkspaceSettings { @@ -910,6 +921,16 @@ impl ItemHandle for Entity { } } + ItemEvent::UpdateBreadcrumbs => { + if &pane == workspace.active_pane() + && pane.read(cx).active_item().is_some_and(|active_item| { + active_item.item_id() == item.item_id() + }) + { + workspace.active_item_path_changed(false, window, cx); + } + } + ItemEvent::Edit => { let autosave = item.workspace_settings(cx).autosave; @@ -932,8 +953,6 @@ impl ItemHandle for Entity { } pane.update(cx, |pane, cx| pane.handle_item_edit(item.item_id(), cx)); } - - _ => {} }); }, )); diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index b4ba998c771..61344668eb2 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -852,6 +852,46 @@ impl MultiWorkspace { } } + pub fn move_project_group_up(&mut self, key: &ProjectGroupKey, cx: &mut Context) -> bool { + let Some(index) = self + .project_groups + .iter() + .position(|group| group.key == *key) + else { + return false; + }; + if index == 0 { + return false; + } + self.project_groups.swap(index - 1, index); + cx.emit(MultiWorkspaceEvent::ProjectGroupsChanged); + self.serialize(cx); + cx.notify(); + true + } + + pub fn move_project_group_down( + &mut self, + key: &ProjectGroupKey, + cx: &mut Context, + ) -> bool { + let Some(index) = self + .project_groups + .iter() + .position(|group| group.key == *key) + else { + return false; + }; + if index + 1 >= self.project_groups.len() { + return false; + } + self.project_groups.swap(index, index + 1); + cx.emit(MultiWorkspaceEvent::ProjectGroupsChanged); + self.serialize(cx); + cx.notify(); + true + } + pub fn workspaces_for_project_group( &self, key: &ProjectGroupKey, diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index ce54765e3ff..ffe70b8301b 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -402,6 +402,7 @@ impl Render for LanguageServerPrompt { .text_size(TextSize::Small.rems(cx)) .code_block_renderer(markdown::CodeBlockRenderer::Default { copy_button_visibility: CopyButtonVisibility::Hidden, + wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }) .on_url_click(|link, _, cx| cx.open_url(&link)), @@ -696,7 +697,26 @@ impl RenderOnce for NotificationFrame { } } -impl Component for NotificationFrame {} +impl Component for NotificationFrame { + fn description() -> &'static str { + "The standard container used by workspace notifications, \ + providing a consistent title row, close and suppress affordances, \ + and a slot for the notification's contents." + } + + fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { + single_example( + "Default", + NotificationFrame::new() + .with_title(Some("Notification Title")) + .with_content(Label::new( + "This is the content of a workspace notification.", + )) + .into_any_element(), + ) + .into_any_element() + } +} pub mod simple_message_notification { use std::sync::Arc; diff --git a/crates/workspace/src/path_link.rs b/crates/workspace/src/path_link.rs new file mode 100644 index 00000000000..e707a7de603 --- /dev/null +++ b/crates/workspace/src/path_link.rs @@ -0,0 +1,422 @@ +use crate::Workspace; +use gpui::{App, AppContext, Task, WeakEntity}; +use itertools::Itertools; +use project::{Entry, Metadata}; +use std::path::{Path, PathBuf}; +use util::{ + paths::{PathStyle, PathWithPosition, normalize_lexically}, + rel_path::RelPath, +}; + +#[cfg(any(test, feature = "test-support"))] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum OpenTargetFoundBy { + WorktreeExact, + WorktreeScan, + FileSystemBackground, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum BackgroundFsChecks { + Enabled, + Disabled, +} + +#[derive(Debug, Clone)] +pub enum OpenTarget { + Worktree( + PathWithPosition, + Entry, + #[cfg(any(test, feature = "test-support"))] OpenTargetFoundBy, + ), + File(PathWithPosition, Metadata), +} + +impl OpenTarget { + pub fn is_file(&self) -> bool { + match self { + OpenTarget::Worktree(_, entry, ..) => entry.is_file(), + OpenTarget::File(_, metadata) => !metadata.is_dir, + } + } + + pub fn is_dir(&self) -> bool { + match self { + OpenTarget::Worktree(_, entry, ..) => entry.is_dir(), + OpenTarget::File(_, metadata) => metadata.is_dir, + } + } + + pub fn path(&self) -> &PathWithPosition { + match self { + OpenTarget::Worktree(path, ..) => path, + OpenTarget::File(path, _) => path, + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn found_by(&self) -> OpenTargetFoundBy { + match self { + OpenTarget::Worktree(.., found_by) => *found_by, + OpenTarget::File(..) => OpenTargetFoundBy::FileSystemBackground, + } + } +} + +pub fn sanitize_path_text(text: &str) -> &str { + let start = first_unbalanced_open_paren(text).unwrap_or(0); + let mut sanitized = &text[start..]; + let (open_parens, mut close_parens) = + sanitized + .chars() + .fold((0, 0), |(opens, closes), character| match character { + '(' => (opens + 1, closes), + ')' => (opens, closes + 1), + _ => (opens, closes), + }); + + while let Some(last_char) = sanitized.chars().last() { + let should_remove = match last_char { + '.' | ',' | ':' | ';' => true, + '(' => true, + ')' if close_parens > open_parens => { + close_parens -= 1; + true + } + _ => false, + }; + + if should_remove { + sanitized = &sanitized[..sanitized.len() - last_char.len_utf8()]; + } else { + break; + } + } + + sanitized +} + +/// Returns the byte offset just past the first unbalanced `(` in `text`, or +/// `None` if all parentheses are balanced. +pub fn first_unbalanced_open_paren(text: &str) -> Option { + let mut balance: i32 = 0; + let mut first_unmatched = None; + for (index, character) in text.char_indices() { + match character { + '(' => { + if balance == 0 { + first_unmatched = Some(index + character.len_utf8()); + } + balance += 1; + } + ')' => { + balance -= 1; + if balance <= 0 { + balance = 0; + first_unmatched = None; + } + } + _ => {} + } + } + first_unmatched.filter(|_| balance > 0) +} + +pub fn possible_open_target( + workspace: &WeakEntity, + maybe_path: &str, + cwd: Option<&Path>, + cx: &App, +) -> Task> { + possible_open_target_internal(workspace, maybe_path, cwd, cx, None) +} + +#[cfg(any(test, feature = "test-support"))] +pub fn possible_open_target_with_fs_checks( + workspace: &WeakEntity, + maybe_path: &str, + cwd: Option<&Path>, + cx: &App, + background_fs_checks: BackgroundFsChecks, +) -> Task> { + possible_open_target_internal(workspace, maybe_path, cwd, cx, Some(background_fs_checks)) +} + +fn possible_open_target_internal( + workspace: &WeakEntity, + maybe_path: &str, + cwd: Option<&Path>, + cx: &App, + background_fs_checks: Option, +) -> Task> { + let Some(workspace) = workspace.upgrade() else { + return Task::ready(None); + }; + + let mut potential_paths = Vec::new(); + let original_path = PathWithPosition::from_path(PathBuf::from(maybe_path)); + let path_with_position = PathWithPosition::parse_str(maybe_path); + let worktree_candidates = workspace + .read(cx) + .worktrees(cx) + .sorted_by_key(|worktree| { + let worktree_root = worktree.read(cx).abs_path(); + match cwd.and_then(|cwd| worktree_root.strip_prefix(cwd).ok()) { + Some(cwd_child) => cwd_child.components().count(), + None => usize::MAX, + } + }) + .collect::>(); + + const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"]; + for prefix_str in GIT_DIFF_PATH_PREFIXES.iter().chain(std::iter::once(&".")) { + if let Some(stripped) = original_path.path.strip_prefix(prefix_str).ok() { + potential_paths.push(PathWithPosition { + path: stripped.to_owned(), + row: original_path.row, + column: original_path.column, + }); + } + if let Some(stripped) = path_with_position.path.strip_prefix(prefix_str).ok() { + potential_paths.push(PathWithPosition { + path: stripped.to_owned(), + row: path_with_position.row, + column: path_with_position.column, + }); + } + } + + let insert_both_paths = original_path != path_with_position; + potential_paths.insert(0, original_path); + if insert_both_paths { + potential_paths.insert(1, path_with_position); + } + + let mut worktree_paths_to_check = Vec::new(); + let mut is_cwd_in_worktree = false; + let mut open_target = None; + 'worktree_loop: for worktree in &worktree_candidates { + let worktree_root = worktree.read(cx).abs_path(); + let mut paths_to_check = Vec::with_capacity(potential_paths.len()); + let relative_cwd = cwd + .and_then(|cwd| cwd.strip_prefix(&worktree_root).ok()) + .and_then(|cwd| RelPath::new(cwd, PathStyle::local()).ok()) + .and_then(|cwd_stripped| { + (cwd_stripped.as_ref() != RelPath::empty()).then(|| { + is_cwd_in_worktree = true; + cwd_stripped + }) + }); + + for path_with_position in &potential_paths { + let path_to_check = if worktree_root.ends_with(&path_with_position.path) { + let root_path_with_position = PathWithPosition { + path: worktree_root.to_path_buf(), + row: path_with_position.row, + column: path_with_position.column, + }; + match worktree.read(cx).root_entry() { + Some(root_entry) => { + open_target = Some(OpenTarget::Worktree( + root_path_with_position, + root_entry.clone(), + #[cfg(any(test, feature = "test-support"))] + OpenTargetFoundBy::WorktreeExact, + )); + break 'worktree_loop; + } + None => root_path_with_position, + } + } else { + PathWithPosition { + path: path_with_position + .path + .strip_prefix(&worktree_root) + .unwrap_or(&path_with_position.path) + .to_owned(), + row: path_with_position.row, + column: path_with_position.column, + } + }; + + let normalized_path = if path_to_check.path.is_relative() { + relative_cwd.as_ref().and_then(|relative_cwd| { + let joined = relative_cwd + .as_ref() + .as_std_path() + .join(&path_to_check.path); + normalize_lexically(&joined).ok().and_then(|path| { + RelPath::new(&path, PathStyle::local()) + .ok() + .map(std::borrow::Cow::into_owned) + }) + }) + } else { + None + }; + let original_path = RelPath::new(&path_to_check.path, PathStyle::local()).ok(); + + if !worktree.read(cx).is_single_file() + && let Some(entry) = normalized_path + .as_ref() + .and_then(|path| worktree.read(cx).entry_for_path(path)) + .or_else(|| { + original_path + .as_ref() + .and_then(|path| worktree.read(cx).entry_for_path(path.as_ref())) + }) + { + open_target = Some(OpenTarget::Worktree( + PathWithPosition { + path: worktree.read(cx).absolutize(&entry.path), + row: path_to_check.row, + column: path_to_check.column, + }, + entry.clone(), + #[cfg(any(test, feature = "test-support"))] + OpenTargetFoundBy::WorktreeExact, + )); + break 'worktree_loop; + } + + paths_to_check.push(path_to_check); + } + + if !paths_to_check.is_empty() { + worktree_paths_to_check.push((worktree.clone(), paths_to_check)); + } + } + + let enable_background_fs_checks = background_fs_checks + .map(|background_fs_checks| background_fs_checks == BackgroundFsChecks::Enabled) + .unwrap_or_else(|| workspace.read(cx).project().read(cx).is_local()); + + if open_target.is_some() { + if !enable_background_fs_checks || is_cwd_in_worktree { + return Task::ready(open_target); + } + } + + let fs_paths_to_check = if enable_background_fs_checks { + let fs_cwd_paths_to_check = cwd + .iter() + .flat_map(|cwd| { + let mut paths_to_check = Vec::new(); + for path_to_check in &potential_paths { + let maybe_path = &path_to_check.path; + if path_to_check.path.is_relative() { + paths_to_check.push(PathWithPosition { + path: cwd.join(maybe_path), + row: path_to_check.row, + column: path_to_check.column, + }); + } + } + paths_to_check + }) + .collect::>(); + fs_cwd_paths_to_check + .into_iter() + .chain( + potential_paths + .into_iter() + .flat_map(|path_to_check| { + let mut paths_to_check = Vec::new(); + let maybe_path = &path_to_check.path; + if maybe_path.starts_with("~") { + if let Some(home_path) = maybe_path + .strip_prefix("~") + .ok() + .and_then(|stripped| Some(dirs::home_dir()?.join(stripped))) + { + paths_to_check.push(PathWithPosition { + path: home_path, + row: path_to_check.row, + column: path_to_check.column, + }); + } + } else { + paths_to_check.push(PathWithPosition { + path: maybe_path.clone(), + row: path_to_check.row, + column: path_to_check.column, + }); + if maybe_path.is_relative() { + for worktree in &worktree_candidates { + if !worktree.read(cx).is_single_file() { + paths_to_check.push(PathWithPosition { + path: worktree.read(cx).abs_path().join(maybe_path), + row: path_to_check.row, + column: path_to_check.column, + }); + } + } + } + } + paths_to_check + }) + .collect::>(), + ) + .collect() + } else { + Vec::new() + }; + + let fs = workspace.read(cx).project().read(cx).fs().clone(); + let background_fs_checks_task = cx.background_spawn(async move { + for mut path_to_check in fs_paths_to_check { + if let Some(fs_path_to_check) = fs.canonicalize(&path_to_check.path).await.ok() + && let Some(metadata) = fs.metadata(&fs_path_to_check).await.ok().flatten() + { + if open_target + .as_ref() + .map(|open_target| open_target.path().path != fs_path_to_check) + .unwrap_or(true) + { + path_to_check.path = fs_path_to_check; + return Some(OpenTarget::File(path_to_check, metadata)); + } + + break; + } + } + + open_target + }); + + cx.spawn(async move |cx| { + background_fs_checks_task.await.or_else(|| { + for (worktree, worktree_paths_to_check) in worktree_paths_to_check { + if let Some(found_entry) = + worktree.update(cx, |worktree, _| -> Option { + let traversal = + worktree.traverse_from_path(true, true, false, RelPath::empty()); + for entry in traversal { + if let Some(path_in_worktree) = + worktree_paths_to_check.iter().find(|path_to_check| { + RelPath::new(&path_to_check.path, PathStyle::local()) + .is_ok_and(|path| entry.path.ends_with(&path)) + }) + { + return Some(OpenTarget::Worktree( + PathWithPosition { + path: worktree.absolutize(&entry.path), + row: path_in_worktree.row, + column: path_in_worktree.column, + }, + entry.clone(), + #[cfg(any(test, feature = "test-support"))] + OpenTargetFoundBy::WorktreeScan, + )); + } + } + None + }) + { + return Some(found_entry); + } + } + None + }) + }) +} diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs index 2130a1d1eca..378968fd1c0 100644 --- a/crates/workspace/src/security_modal.rs +++ b/crates/workspace/src/security_modal.rs @@ -56,11 +56,17 @@ impl ModalView for SecurityModal { fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context) -> DismissDecision { match self.trusted { - Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"), - Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"), - None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"), + Some(false) => { + telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"); + DismissDecision::Dismiss(true) + } + Some(true) => { + telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"); + DismissDecision::Dismiss(true) + } + // Block dismiss via escape or clicking outside; user must pick an action + None => DismissDecision::Dismiss(false), } - DismissDecision::Dismiss(true) } } @@ -358,7 +364,12 @@ impl SecurityModal { if self.restricted_paths != new_restricted_worktrees { self.trust_parents = false; self.restricted_paths = new_restricted_worktrees; - cx.notify(); + if self.restricted_paths.is_empty() { + self.trusted = Some(true); + self.dismiss(cx); + } else { + cx.notify(); + } } } } else if !self.restricted_paths.is_empty() { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 90e497eb4e2..dbb18703c53 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -13,9 +13,10 @@ pub mod pane_group; pub mod path_list { pub use util::path_list::{PathList, SerializedPathList}; } +pub mod path_link; mod persistence; pub mod searchable; -mod security_modal; +pub mod security_modal; pub mod shared_screen; pub use shared_screen::SharedScreen; pub mod focus_follows_mouse; @@ -670,7 +671,11 @@ fn prompt_and_open_paths( create_new_window: bool, cx: &mut App, ) { - if let Some(workspace_window) = local_workspace_windows(cx).into_iter().next() { + if let Some(workspace_window) = + workspace_windows_for_location(&SerializedWorkspaceLocation::Local, cx) + .into_iter() + .next() + { workspace_window .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); @@ -2118,6 +2123,15 @@ impl Workspace { .log_err(); } + // Auto-show the security modal if the project has restricted worktrees + window + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.show_worktree_trust_security_modal(false, window, cx); + }); + }) + .log_err(); + Ok(OpenResult { window, workspace, @@ -5944,7 +5958,7 @@ impl Workspace { self.follower_states.contains_key(&id.into()) } - fn active_item_path_changed( + pub(crate) fn active_item_path_changed( &mut self, focus_changed: bool, window: &mut Window, @@ -6672,6 +6686,12 @@ impl Workspace { ActiveCallEvent::LocalScreenShareStopped => { self.handle_auto_watch_local_share_stopped(window, cx); } + ActiveCallEvent::RoomLeft => { + if self.auto_watch.enabled() { + self.auto_watch = AutoWatch::Off; + cx.notify(); + } + } } } @@ -7797,6 +7817,12 @@ impl Workspace { .and_then(|state| state.size) .unwrap_or_else(|| panel.default_size(window, cx)); container = container.w(size); + // Allow the fixed-width dock to shrink when there isn't + // enough space (e.g. when the sidebar is open). The + // stored size is preserved so the dock expands back + // when space becomes available. + let style = container.style(); + style.flex_shrink = Some(1.0); } if let Some(min) = min_size { container = container.min_w(min); @@ -8010,13 +8036,10 @@ impl Workspace { }); } } else { - let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees - .read(cx) - .has_restricted_worktrees(&self.project().read(cx).worktree_store(), cx) - }) - .unwrap_or(false); + let has_restricted_worktrees = TrustedWorktrees::has_restricted_worktrees( + &self.project().read(cx).worktree_store(), + cx, + ); if has_restricted_worktrees { let project = self.project().read(cx); let remote_host = project @@ -8126,6 +8149,7 @@ pub enum ActiveCallEvent { RemoteVideoTracksChanged { participant_id: PeerId }, LocalScreenShareStarted, LocalScreenShareStopped, + RoomLeft, } fn leader_border_for_pane( @@ -9471,7 +9495,7 @@ pub async fn get_any_active_multi_workspace( activate_any_workspace_window(&mut cx).context("could not open zed") } -fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { +pub fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { cx.update(|cx| { if let Some(workspace_window) = cx .active_window() @@ -9492,10 +9516,6 @@ fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option Vec> { - workspace_windows_for_location(&SerializedWorkspaceLocation::Local, cx) -} - pub fn workspace_windows_for_location( serialized_location: &SerializedWorkspaceLocation, cx: &App, @@ -10623,6 +10643,7 @@ pub fn client_side_decorations( }, blur_radius: theme::CLIENT_SIDE_DECORATION_SHADOW / 2., spread_radius: px(0.), + inset: false, offset: point(px(0.0), px(0.0)), }]) }), diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index 5aac6f24173..9a95eec9697 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -13,7 +13,7 @@ test = false [[test]] name = "integration" required-features = ["test-support"] -path = "tests/integration/main.rs" +path = "tests/integration/worktree_tests.rs" [lints] workspace = true diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index a21ea8f639e..ce2f34bc78d 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -8,7 +8,8 @@ use clock::ReplicaId; use collections::{HashMap, HashSet, VecDeque}; use encoding_rs::Encoding; use fs::{ - Fs, MTime, PathEvent, RemoveOptions, TrashedEntry, Watcher, copy_recursive, read_dir_items, + Fs, MTime, PathEvent, PathEventKind, RemoveOptions, TrashedEntry, Watcher, copy_recursive, + read_dir_items, }; use futures::{ FutureExt as _, Stream, StreamExt, @@ -4374,6 +4375,8 @@ impl BackgroundScanner { events = Self::normalized_events_for_worktree(&state, &root_canonical_path, events); } + log::debug!("raw events for process_events: {events:?}"); + fn skip_ix(ranges: &mut SmallVec<[Range; 4]>, ix: usize) { if let Some(last_range) = ranges.last_mut() && last_range.end == ix @@ -4388,7 +4391,7 @@ impl BackgroundScanner { // // Certain directories may have FS changes, but do not lead to git data changes that Zed cares about. // Ignore these, to avoid Zed unnecessarily rescanning git metadata. - let skipped_files_in_dot_git = [COMMIT_MESSAGE, INDEX_LOCK]; + let skipped_file_names_in_dot_git = [COMMIT_MESSAGE, INDEX_LOCK]; let skipped_dirs_in_dot_git = [FSMONITOR_DAEMON, LFS_DIR]; let mut dot_git_abs_paths = Vec::new(); @@ -4418,22 +4421,36 @@ impl BackgroundScanner { } if let Some((dot_git_abs_path, path_in_git_dir)) = dot_git_paths { - let skip = skipped_files_in_dot_git.iter().any(|skipped| { - OsStr::new(skipped) == path_in_git_dir.as_path().as_os_str() + let is_ignored = skipped_file_names_in_dot_git.iter().any(|skipped| { + path_in_git_dir + .file_name() + .is_some_and(|file_name| file_name == OsStr::new(skipped)) }) || skipped_dirs_in_dot_git .iter() - .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)) - || path_in_git_dir == Path::new("") - && self.fs.is_dir(&dot_git_abs_path).await; - if skip { + .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)); + let is_dot_git = path_in_git_dir == Path::new("") + && matches!(event.kind, Some(PathEventKind::Changed)) + && self.fs.is_dir(&dot_git_abs_path).await; + if is_ignored { log::debug!( "ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories" ); skip_ix(&mut ranges_to_drop, ix); continue; } + if is_dot_git { + log::debug!( + "ignoring event {abs_path:?} for .git directory itself (kind: {:?})", + event.kind + ); + skip_ix(&mut ranges_to_drop, ix); + continue; + } if !dot_git_abs_paths.contains(&dot_git_abs_path) { + log::debug!( + "detected update within git repo at {dot_git_abs_path:?}: {abs_path:?}" + ); dot_git_abs_paths.push(dot_git_abs_path); } } @@ -4514,7 +4531,7 @@ impl BackgroundScanner { .is_some_and(|entry| entry.kind == EntryKind::Dir) }); if !parent_dir_is_loaded { - log::debug!("ignoring event {relative_path:?} within unloaded directory"); + log::debug!("filtering event {relative_path:?} within unloaded directory"); skip_ix(&mut ranges_to_drop, ix); continue; } @@ -4555,13 +4572,15 @@ impl BackgroundScanner { self.state.lock().await.snapshot.scan_id += 1; let (scan_job_tx, scan_job_rx) = async_channel::unbounded(); - log::debug!( - "received fs events {:?}", - relative_paths - .iter() - .map(|event_root| &event_root.path) - .collect::>() - ); + if !relative_paths.is_empty() { + log::debug!( + "will update project paths {:?}", + relative_paths + .iter() + .map(|event_root| &event_root.path) + .collect::>() + ); + } self.reload_entries_for_paths( &root_path, &root_canonical_path, diff --git a/crates/worktree/tests/integration/worktree_settings.rs b/crates/worktree/tests/integration/worktree_settings_tests.rs similarity index 100% rename from crates/worktree/tests/integration/worktree_settings.rs rename to crates/worktree/tests/integration/worktree_settings_tests.rs diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/worktree_tests.rs similarity index 97% rename from crates/worktree/tests/integration/main.rs rename to crates/worktree/tests/integration/worktree_tests.rs index ef5180a7083..2ae248ad0e4 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -1,4 +1,4 @@ -mod worktree_settings; +mod worktree_settings_tests; use anyhow::Result; use encoding_rs; @@ -3404,6 +3404,89 @@ async fn test_linked_worktree_git_file_event_does_not_panic( }); } +#[gpui::test] +async fn test_linked_worktree_index_lock_event_does_not_emit_git_repo_update( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + // Regression test: in a linked worktree, git operations like `git status` + // can touch the worktree-specific `index.lock` under the main repo's + // `.git/worktrees//`. We intend to ignore those events so they do not + // spuriously emit `UpdatedGitRepositories`. + init_test(cx); + + use git::repository::Worktree as GitWorktree; + + let fs = FakeFs::new(executor); + + fs.insert_tree( + path!("/main_repo"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new(path!("/main_repo/.git")), + false, + GitWorktree { + path: PathBuf::from(path!("/linked_worktree")), + ref_name: Some("refs/heads/feature".into()), + sha: "abc123".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + fs.write( + path!("/linked_worktree/file.txt").as_ref(), + "content".as_bytes(), + ) + .await + .unwrap(); + + let tree = Worktree::local( + path!("/linked_worktree").as_ref(), + true, + fs.clone(), + Arc::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + cx.run_until_parked(); + + let repo_update_count: Rc> = Rc::new(Cell::new(0)); + tree.update(cx, { + let repo_update_count = repo_update_count.clone(); + |_, cx| { + cx.subscribe(&cx.entity(), move |_, _, event, _| { + if matches!(event, Event::UpdatedGitRepositories(_)) { + repo_update_count.set(repo_update_count.get() + 1); + } + }) + .detach(); + } + }); + + fs.emit_fs_event( + path!("/main_repo/.git/worktrees/feature/index.lock"), + Some(PathEventKind::Changed), + ); + cx.run_until_parked(); + + assert_eq!( + repo_update_count.get(), + 0, + "linked-worktree index.lock events should not emit UpdatedGitRepositories" + ); +} + #[gpui::test] async fn test_linked_worktree_event_in_unregistered_common_git_dir_does_not_panic( executor: BackgroundExecutor, @@ -3584,6 +3667,34 @@ async fn test_dot_git_dir_event_does_not_suppress_children( "should emit UpdatedGitRepositories for a .git/index event" ); } + + { + let mut events = cx.events(&worktree); + fs.pause_events(); + fs.emit_fs_event(dot_git, Some(PathEventKind::Rescan)); + fs.unpause_events_and_flush(); + executor.run_until_parked(); + + let got_git_update = drain_git_repo_updates(&mut events); + assert!( + got_git_update, + "should emit UpdatedGitRepositories for a .git rescan event" + ); + } + + { + let mut events = cx.events(&worktree); + fs.pause_events(); + fs.emit_fs_event(project_dir, Some(PathEventKind::Rescan)); + fs.unpause_events_and_flush(); + executor.run_until_parked(); + + let got_git_update = drain_git_repo_updates(&mut events); + assert!( + got_git_update, + "should emit UpdatedGitRepositories for a .git rescan event" + ); + } } fn drain_git_repo_updates(events: &mut futures::channel::mpsc::UnboundedReceiver) -> bool { diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index 7ba13d83529..6d9c558de85 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -122,4 +122,11 @@ impl Model { Self::Custom { .. } => false, } } + + pub fn supports_reasoning_effort(&self) -> bool { + match self { + Self::Grok43 => true, + Self::Grok420Reasoning | Self::Grok420NonReasoning | Self::Custom { .. } => false, + } + } } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index d0b5227afb1..6bd33833b53 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "1.4.0" +version = "1.6.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -68,6 +68,7 @@ activity_indicator.workspace = true agent.workspace = true agent-client-protocol.workspace = true agent_settings.workspace = true +agent_skills.workspace = true agent_ui = { workspace = true, features = ["audio"] } anyhow.workspace = true askpass.workspace = true @@ -155,7 +156,6 @@ menu.workspace = true migrator.workspace = true miniprofiler_ui.workspace = true mimalloc = { version = "0.1", optional = true } -nc.workspace = true node_runtime.workspace = true notifications.workspace = true onboarding.workspace = true diff --git a/crates/zed/resources/flatpak/zed.metainfo.xml.in b/crates/zed/resources/flatpak/zed.metainfo.xml.in index b8a88d92213..ec31f4c285f 100644 --- a/crates/zed/resources/flatpak/zed.metainfo.xml.in +++ b/crates/zed/resources/flatpak/zed.metainfo.xml.in @@ -2,7 +2,7 @@ $APP_ID MIT - AGPL-3.0-or-later and Apache-2.0 and GPL-3.0-or-later + Apache-2.0 and GPL-3.0-or-later $APP_NAME High-performance, multiplayer code editor diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index affe1521a68..8476392b7af 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -85,6 +85,15 @@ use crate::zed::{CrashHandler, OpenRequestKind, eager_load_active_theme_and_icon #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; +fn build_application() -> Application { + let platform = gpui_platform::current_platform(false); + if std::env::var("ZED_EXPERIMENTAL_A11Y").as_deref() == Ok("1") { + Application::with_platform(platform) + } else { + Application::new_inaccessible(platform) + } +} + fn files_not_created_on_launch(errors: HashMap>) { let message = "Zed failed to launch"; let error_details = errors @@ -113,7 +122,7 @@ fn files_not_created_on_launch(errors: HashMap>) { .collect::>().join("\n\n"); eprintln!("{message}: {error_details}"); - Application::with_platform(gpui_platform::current_platform(false)) + build_application() .with_quit_mode(QuitMode::Explicit) .run(move |cx| { if let Ok(window) = cx.open_window(gpui::WindowOptions::default(), |_, cx| { @@ -235,17 +244,6 @@ fn main() { return; } - // `zed --nc` Makes zed operate in nc/netcat mode for use with MCP - if let Some(socket) = &args.nc { - match nc::main(socket) { - Ok(()) => return, - Err(err) => { - eprintln!("Error: {}", err); - process::exit(1); - } - } - } - #[cfg(all(not(debug_assertions), target_os = "windows"))] unsafe { use windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole}; @@ -338,8 +336,7 @@ fn main() { #[cfg(windows)] check_for_conpty_dll(); - let app = - Application::with_platform(gpui_platform::current_platform(false)).with_assets(Assets); + let app = build_application().with_assets(Assets); let app_db = db::AppDatabase::new(); let system_id = app.background_executor().spawn(system_id()); @@ -909,6 +906,7 @@ fn main() { wsl, diff_all: diff_all_mode, dev_container: args.dev_container, + ..Default::default() }) } @@ -925,6 +923,14 @@ fn main() { .ok() .and_then(|request| OpenRequest::parse(request, cx).log_err()) { + Some(request) if request.is_focus_app_only() => cx.spawn({ + let app_state = app_state.clone(); + async move |cx| { + if let Err(e) = restore_or_create_workspace(app_state, cx).await { + fail_to_open_window_async(e, cx) + } + } + }), Some(request) => { handle_open_request(request, app_state.clone(), cx); Task::ready(()) @@ -978,6 +984,15 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut cx.spawn(async move |cx| handle_cli_connection(connection, app_state, cx).await) .detach(); } + OpenRequestKind::FocusApp => { + cx.spawn(async move |cx| { + if workspace::activate_any_workspace_window(cx).is_some() { + return anyhow::Ok(()); + } + restore_or_create_workspace(app_state, cx).await + }) + .detach_and_log_err(cx); + } OpenRequestKind::Extension { extension_id } => { cx.spawn(async move |cx| { let workspace = @@ -1001,6 +1016,15 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let multi_workspace = workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; + let panels_task = multi_workspace.update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .update(cx, |workspace, _| workspace.take_panels_task()) + })?; + if let Some(task) = panels_task { + task.await.log_err(); + } + multi_workspace.update(cx, |multi_workspace, window, cx| { multi_workspace.workspace().update(cx, |workspace, cx| { if let Some(panel) = workspace.focus_panel::(window, cx) { @@ -1011,6 +1035,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut cx, ); }); + } else { + log::warn!( + "zed://agent received but the AgentPanel is not registered \ + (is `disable_ai` enabled?)" + ); } }); }) @@ -1117,6 +1146,28 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }) .detach_and_log_err(cx); } + OpenRequestKind::InstallSkill { content } => { + cx.spawn(async move |cx| { + let multi_workspace = + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; + + multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + if let Some(panel) = workspace.focus_panel::(window, cx) { + panel.update(cx, |panel, cx| { + panel.install_shared_skill(content, cx); + }); + } else { + log::warn!( + "zed://skill received but the AgentPanel is not registered \ + (is `disable_ai` enabled?)" + ); + } + }); + }) + }) + .detach_and_log_err(cx); + } OpenRequestKind::DockMenuAction { index } => { cx.perform_dock_menu_action(index); } @@ -1233,6 +1284,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }); } OpenRequestKind::GitCommit { sha } => { + let base_open_options = zed::open_options_for_request( + request.open_behavior, + &workspace::SerializedWorkspaceLocation::Local, + cx, + ); cx.spawn(async move |cx| { let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), request.open_paths).await; @@ -1241,7 +1297,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut &[], false, app_state, - workspace::OpenOptions::default(), + base_open_options, cx, ) .await?; @@ -1283,16 +1339,12 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut } if let Some(connection_options) = request.remote_connection { + let open_behavior = request.open_behavior; + let location = workspace::SerializedWorkspaceLocation::Remote(connection_options.clone()); + let base_open_options = zed::open_options_for_request(open_behavior, &location, cx); cx.spawn(async move |cx| { let paths: Vec = request.open_paths.into_iter().map(PathBuf::from).collect(); - open_remote_project( - connection_options, - paths, - app_state, - workspace::OpenOptions::default(), - cx, - ) - .await + open_remote_project(connection_options, paths, app_state, base_open_options, cx).await }) .detach_and_log_err(cx); return; @@ -1302,6 +1354,11 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let dev_container = request.dev_container; if !request.open_paths.is_empty() || !request.diff_paths.is_empty() { let app_state = app_state.clone(); + let base_open_options = zed::open_options_for_request( + request.open_behavior, + &workspace::SerializedWorkspaceLocation::Local, + cx, + ); task = Some(cx.spawn(async move |cx| { let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), request.open_paths).await; @@ -1312,7 +1369,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut app_state, workspace::OpenOptions { open_in_dev_container: dev_container, - ..Default::default() + ..base_open_options }, cx, ) @@ -1763,11 +1820,6 @@ struct Args { #[arg(long)] system_specs: bool, - /// Used for the MCP Server, to remove the need for netcat as a dependency, - /// by having Zed act like netcat communicating over a Unix socket. - #[arg(long, hide = true)] - nc: Option, - /// Used for recording minidumps on crashes by having Zed run a separate /// process communicating over a socket. #[arg(long, hide = true)] diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 8f85fcd3c86..d8553c1bd14 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -2734,6 +2734,7 @@ fn run_multi_workspace_sidebar_visual_tests( thinking_effort: None, ui_scroll_position: None, draft_prompt: None, + sandboxed_terminal_temp_dir: None, }, path_list, cx, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index fedf9d8cf24..f9c04b7c910 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -15,7 +15,7 @@ pub mod visual_tests; #[cfg(target_os = "windows")] pub(crate) mod windows_only_instance; -use agent::{UserAgentsMdState, init_user_agents_md}; +use agent_settings::{UserAgentsMdState, init_user_agents_md}; use agent_ui::AgentDiffToolbar; use anyhow::Context as _; pub use app_menus::*; @@ -34,6 +34,7 @@ use futures::{StreamExt, channel::mpsc, select_biased}; use git_ui::commit_view::CommitViewToolbar; use git_ui::git_panel::GitPanel; use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar}; +use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar}; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement, @@ -49,7 +50,6 @@ use language_tools::lsp_log_view::LspLogToolbarItemView; use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType}; use migrator::migrate_keymap; -use onboarding::DOCS_URL; use onboarding::multibuffer_hint::MultibufferHint; pub use open_listener::*; use outline_panel::OutlinePanel; @@ -100,9 +100,12 @@ use workspace::{ use workspace::{Pane, notifications::DetachAndPromptErr}; use zed_actions::{ About, OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettingsFile, - OpenZedUrl, Quit, + OpenStatusPage, OpenZedUrl, Quit, }; +const DOCS_URL: &str = "https://zed.dev/docs/"; +const STATUS_URL: &str = "https://status.zed.dev"; + pub struct CrashHandler(pub Arc); impl gpui::Global for CrashHandler {} @@ -860,6 +863,7 @@ fn register_actions( ) { workspace .register_action(|_, _: &OpenDocs, _, cx| cx.open_url(DOCS_URL)) + .register_action(|_, _: &OpenStatusPage, _, cx| cx.open_url(STATUS_URL)) .register_action( |workspace: &mut Workspace, _: &input_latency_ui::DumpInputLatencyHistogram, @@ -1302,6 +1306,8 @@ fn initialize_pane( pane.toolbar().update(cx, |toolbar, cx| { let multibuffer_hint = cx.new(|_| MultibufferHint::new()); toolbar.add_item(multibuffer_hint, window, cx); + let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new); + toolbar.add_item(solo_diff_style_toolbar, window, cx); let breadcrumbs = cx.new(|_| Breadcrumbs::new()); toolbar.add_item(breadcrumbs, window, cx); let buffer_search_bar = cx.new(|cx| { @@ -1340,6 +1346,8 @@ fn initialize_pane( toolbar.add_item(project_diff_toolbar, window, cx); let branch_diff_toolbar = cx.new(BranchDiffToolbar::new); toolbar.add_item(branch_diff_toolbar, window, cx); + let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new); + toolbar.add_item(solo_diff_git_toolbar, window, cx); let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new()); toolbar.add_item(commit_view_toolbar, window, cx); let agent_diff_toolbar = cx.new(AgentDiffToolbar::new); @@ -1551,7 +1559,7 @@ fn open_about_window(cx: &mut App) { window_bounds: Some(WindowBounds::centered(window_size, cx)), is_resizable: false, is_minimizable: false, - kind: WindowKind::Normal, + kind: WindowKind::Floating, app_id: Some(ReleaseChannel::global(cx).app_id().to_owned()), ..Default::default() }, @@ -1879,8 +1887,8 @@ fn init_cursor_hide_mode(cx: &mut App) { /// Starts watching `~/.config/zed/AGENTS.md` (or the platform equivalent) and /// surfaces any read errors using the same notification UI as settings errors. /// -/// The file itself is loaded into [`agent::UserAgentsMd`] for inclusion in the -/// native agent's system prompt. +/// The file itself is loaded into [`agent_settings::UserAgentsMd`] for inclusion +/// in prompts. pub fn watch_user_agents_md(fs: Arc, cx: &mut App) { struct UserAgentsMdParseError; let notification_id = NotificationId::unique::(); @@ -4175,7 +4183,7 @@ mod tests { let (editor_1, buffer) = workspace.update_in(cx, |_, window, cx| { pane_1.update(cx, |pane_1, cx| { let editor = pane_1.active_item().unwrap().downcast::().unwrap(); - assert_eq!(editor.project_path(cx), Some(file1.clone())); + assert_eq!(editor.read(cx).active_project_path(cx), Some(file1.clone())); let buffer = editor.update(cx, |editor, cx| { editor.insert("dirt", window, cx); editor.buffer().downgrade() @@ -4731,7 +4739,7 @@ mod tests { let scroll_position = editor_ref.scroll_position(cx); ( - editor_ref.project_path(cx).unwrap(), + editor_ref.active_project_path(cx).unwrap(), selections[0].start, scroll_position.y, ) @@ -5249,10 +5257,10 @@ mod tests { "recent_projects", "remote_debug", "repl", - "rules_library", "search", "settings_editor", "settings_profile_selector", + "skill_creator", "snippets", "stash_picker", "svg", diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 0ac86e9d2b5..a68d827373e 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -42,6 +42,7 @@ pub struct OpenRequest { pub open_channel_notes: Vec<(u64, Option)>, pub join_channel: Option, pub remote_connection: Option, + pub open_behavior: Option, } pub enum OpenRequestKind { @@ -51,6 +52,7 @@ pub enum OpenRequestKind { Box, ), ), + FocusApp, Extension { extension_id: String, }, @@ -60,6 +62,10 @@ pub enum OpenRequestKind { SharedAgentThread { session_id: String, }, + InstallSkill { + /// Full `SKILL.md` contents embedded in a `zed://skill` share link. + content: String, + }, DockMenuAction { index: usize, }, @@ -82,6 +88,7 @@ impl std::fmt::Debug for OpenRequestKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::CliConnection(_) => write!(f, "CliConnection(..)"), + Self::FocusApp => write!(f, "FocusApp"), Self::Extension { extension_id } => f .debug_struct("Extension") .field("extension_id", extension_id) @@ -96,6 +103,10 @@ impl std::fmt::Debug for OpenRequestKind { .debug_struct("SharedAgentThread") .field("session_id", session_id) .finish(), + Self::InstallSkill { content } => f + .debug_struct("InstallSkill") + .field("content_len", &content.len()) + .finish(), Self::DockMenuAction { index } => f .debug_struct("DockMenuAction") .field("index", index) @@ -118,12 +129,22 @@ impl std::fmt::Debug for OpenRequestKind { } impl OpenRequest { + pub fn is_focus_app_only(&self) -> bool { + matches!(self.kind, Some(OpenRequestKind::FocusApp)) + && self.open_paths.is_empty() + && self.diff_paths.is_empty() + && self.remote_connection.is_none() + && self.join_channel.is_none() + && self.open_channel_notes.is_empty() + } + pub fn parse(request: RawOpenRequest, cx: &App) -> Result { let mut this = Self::default(); this.diff_paths = request.diff_paths; this.diff_all = request.diff_all; this.dev_container = request.dev_container; + this.open_behavior = request.open_behavior; if let Some(wsl) = request.wsl { let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') { if user.is_empty() { @@ -165,8 +186,12 @@ impl OpenRequest { } else { log::error!("Invalid session ID in URL: {}", session_id_str); } + } else if url.starts_with(agent_skills::SKILL_SHARE_LINK_PREFIX) { + this.parse_skill_install_url(&url)? } else if let Some(agent_path) = url.strip_prefix("zed://agent") { this.parse_agent_url(agent_path) + } else if url == "zed://" || url == "zed://open" || url == "zed://open/" { + this.kind = Some(OpenRequestKind::FocusApp); } else if let Some(schema_path) = url.strip_prefix("zed://schemas/") { this.kind = Some(OpenRequestKind::BuiltinJsonSchema { schema_path: schema_path.to_string(), @@ -210,7 +235,8 @@ impl OpenRequest { } fn parse_agent_url(&mut self, agent_path: &str) { - // Format: "" or "?prompt=" + // Format: "" or "?prompt=". + let agent_path = agent_path.strip_prefix('/').unwrap_or(agent_path); let external_source_prompt = agent_path.strip_prefix('?').and_then(|query| { url::form_urlencoded::parse(query.as_bytes()) .find_map(|(key, value)| (key == "prompt").then_some(value)) @@ -221,6 +247,13 @@ impl OpenRequest { }); } + fn parse_skill_install_url(&mut self, url: &str) -> Result<()> { + // Format: zed://skill?data= + let content = agent_skills::decode_skill_share_link(url)?; + self.kind = Some(OpenRequestKind::InstallSkill { content }); + Ok(()) + } + fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> { // Format: /?repo= or ?repo= let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path); @@ -368,6 +401,7 @@ pub struct RawOpenRequest { pub diff_all: bool, pub dev_container: bool, pub wsl: Option, + pub open_behavior: Option, } impl Global for OpenListener {} @@ -557,6 +591,7 @@ pub async fn handle_cli_connection( diff_all, dev_container, wsl, + open_behavior: Some(open_behavior), }, cx, ) { @@ -721,6 +756,52 @@ async fn resolve_open_behavior( None } +pub(crate) fn open_options_for_request( + open_behavior: Option, + location: &SerializedWorkspaceLocation, + cx: &App, +) -> workspace::OpenOptions { + open_behavior.map_or_else(workspace::OpenOptions::default, |open_behavior| { + open_options_for_behavior(open_behavior, location, cx) + }) +} + +pub(crate) fn open_options_for_behavior( + open_behavior: cli::OpenBehavior, + location: &SerializedWorkspaceLocation, + cx: &App, +) -> workspace::OpenOptions { + // If reuse flag is passed, open a new workspace in an existing window. + let requesting_window = if open_behavior == cli::OpenBehavior::Reuse { + workspace::workspace_windows_for_location(location, cx) + .into_iter() + .next() + } else { + None + }; + workspace::OpenOptions { + workspace_matching: match open_behavior { + cli::OpenBehavior::AlwaysNew | cli::OpenBehavior::Reuse => { + workspace::WorkspaceMatching::None + } + cli::OpenBehavior::Add => workspace::WorkspaceMatching::MatchSubdirectory, + _ => workspace::WorkspaceMatching::MatchExact, + }, + add_dirs_to_sidebar: match open_behavior { + cli::OpenBehavior::ExistingWindow => true, + // For the default value, we consult the settings to decide + // whether to open in a new window or existing window. + cli::OpenBehavior::Default => { + workspace::WorkspaceSettings::get_global(cx).cli_default_open_behavior + == settings::CliDefaultOpenBehavior::ExistingWindow + } + _ => false, + }, + requesting_window, + ..Default::default() + } +} + async fn open_workspaces( paths: Vec, diff_paths: Vec<[String; 2]>, @@ -773,39 +854,13 @@ async fn open_workspaces( let mut errored = false; for (location, workspace_paths) in grouped_locations { - // If reuse flag is passed, open a new workspace in an existing window. - let replace_window = if open_behavior == cli::OpenBehavior::Reuse { - cx.update(|cx| { - workspace::workspace_windows_for_location(&location, cx) - .into_iter() - .next() - }) - } else { - None - }; + let base_open_options = + cx.update(|cx| open_options_for_behavior(open_behavior, &location, cx)); let open_options = workspace::OpenOptions { - workspace_matching: match open_behavior { - cli::OpenBehavior::AlwaysNew | cli::OpenBehavior::Reuse => { - workspace::WorkspaceMatching::None - } - cli::OpenBehavior::Add => workspace::WorkspaceMatching::MatchSubdirectory, - _ => workspace::WorkspaceMatching::MatchExact, - }, - add_dirs_to_sidebar: match open_behavior { - cli::OpenBehavior::ExistingWindow => true, - // For the default value, we consult the settings to decide - // whether to open in a new window or existing window. - cli::OpenBehavior::Default => cx.update(|cx| { - workspace::WorkspaceSettings::get_global(cx).cli_default_open_behavior - == settings::CliDefaultOpenBehavior::ExistingWindow - }), - _ => false, - }, - requesting_window: replace_window, wait, env: env.clone(), open_in_dev_container: dev_container, - ..Default::default() + ..base_open_options }; match location { @@ -1146,6 +1201,25 @@ mod tests { } } + #[gpui::test] + fn test_parse_ssh_url_preserves_open_behavior(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["ssh://me@host:/".into()], + open_behavior: Some(cli::OpenBehavior::AlwaysNew), + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + assert_eq!(request.open_behavior, Some(cli::OpenBehavior::AlwaysNew)); + } + #[gpui::test] fn test_reject_ssh_urls(cx: &mut TestAppContext) { let _app_state = init_test(cx); @@ -1168,6 +1242,24 @@ mod tests { } } + #[gpui::test] + fn test_open_options_for_behavior_always_new(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + let options = cx.update(|cx| { + open_options_for_behavior( + cli::OpenBehavior::AlwaysNew, + &SerializedWorkspaceLocation::Local, + cx, + ) + }); + assert_eq!( + options.workspace_matching, + workspace::WorkspaceMatching::None + ); + assert!(!options.add_dirs_to_sidebar); + assert!(options.requesting_window.is_none()); + } + #[gpui::test] fn test_parse_agent_url(cx: &mut TestAppContext) { let _app_state = init_test(cx); @@ -1193,6 +1285,52 @@ mod tests { } } + #[gpui::test] + fn test_parse_skill_install_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let content = + "---\nname: my-skill\ndescription: Does a thing.\n---\n\nDo the thing.\n".to_string(); + let link = agent_skills::encode_skill_share_link(&content); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec![link], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::InstallSkill { + content: parsed_content, + }) => { + assert_eq!(parsed_content, content); + } + _ => panic!("Expected InstallSkill kind"), + } + } + + #[gpui::test] + fn test_parse_malformed_skill_install_url_errors(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let result = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://skill?data=!!!notbase64".into()], + ..Default::default() + }, + cx, + ) + }); + + assert!(result.is_err()); + } + fn agent_url_with_prompt(prompt: &str) -> String { let mut serializer = url::form_urlencoded::Serializer::new("zed://agent?".to_string()); serializer.append_pair("prompt", prompt); @@ -1230,6 +1368,63 @@ mod tests { } } + #[gpui::test] + fn test_parse_agent_url_with_trailing_slash(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://agent/?prompt=hello".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::AgentPanel { + external_source_prompt, + }) => { + assert_eq!( + external_source_prompt + .as_ref() + .map(ExternalSourcePrompt::as_str), + Some("hello") + ); + } + _ => panic!("Expected AgentPanel kind"), + } + } + + #[gpui::test] + fn test_parse_focus_app_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + for url in ["zed://", "zed://open", "zed://open/"] { + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec![url.into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + assert!( + matches!(request.kind, Some(OpenRequestKind::FocusApp)), + "expected FocusApp for {url}, got {:?}", + request.kind + ); + assert!( + request.is_focus_app_only(), + "expected is_focus_app_only for {url}" + ); + } + } + #[gpui::test] fn test_parse_agent_url_with_empty_prompt(cx: &mut TestAppContext) { let _app_state = init_test(cx); @@ -2095,6 +2290,25 @@ mod tests { } } + fn make_cli_url_open_request( + urls: Vec, + open_behavior: cli::OpenBehavior, + ) -> CliRequest { + CliRequest::Open { + paths: vec![], + urls, + diff_paths: vec![], + diff_all: false, + wsl: None, + wait: false, + open_behavior, + env: None, + user_data_dir: None, + dev_container: false, + cwd: None, + } + } + /// Runs the real [`cli::run_cli_response_loop`] on an OS thread against /// the Zed-side `handle_cli_connection` on the GPUI foreground executor, /// using `allow_parking` so the test scheduler tolerates cross-thread @@ -2401,6 +2615,35 @@ mod tests { assert_eq!(cx.windows().len(), 2); } + #[gpui::test] + async fn test_e2e_explicit_new_flag_with_file_url_opens_new_window(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree(path!("/project"), json!({ "file.txt": "content" })) + .await; + + open_workspace_file(path!("/project"), Default::default(), app_state.clone(), cx).await; + assert_eq!(cx.windows().len(), 1); + + let file_url = format!( + "file://{}", + urlencoding::encode(path!("/project/file.txt")).into_owned() + ); + let (status, prompt_shown) = run_cli_with_zed_handler( + cx, + app_state, + make_cli_url_open_request(vec![file_url], cli::OpenBehavior::AlwaysNew), + None, + ); + + assert_eq!(status, 0); + assert!(!prompt_shown, "no prompt should be shown with -n flag"); + assert_eq!(cx.windows().len(), 2); + } + #[gpui::test] async fn test_e2e_paths_in_existing_workspace_no_prompt(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/zed/src/zed/telemetry_log.rs b/crates/zed/src/zed/telemetry_log.rs index 7df7e83d258..062fd5f0f28 100644 --- a/crates/zed/src/zed/telemetry_log.rs +++ b/crates/zed/src/zed/telemetry_log.rs @@ -12,7 +12,10 @@ use gpui::{ StyleRefinement, Task, TextStyleRefinement, Window, list, prelude::*, }; use language::LanguageRegistry; -use markdown::{CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle}; +use markdown::{ + CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle, + WrapButtonVisibility, +}; use project::Project; use settings::Settings; use telemetry_events::{Event, EventWrapper}; @@ -429,6 +432,7 @@ impl TelemetryLogView { } else { CopyButtonVisibility::Hidden }, + wrap_button_visibility: WrapButtonVisibility::Hidden, border: false, }), ), diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 671b772ddf4..1cf0b7e0daa 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -67,6 +67,8 @@ actions!( OpenDocs, /// Views open source licenses. OpenLicenses, + /// Opens the Zed status page. + OpenStatusPage, /// Opens the telemetry log. OpenTelemetryLog, /// Opens the performance profiler. @@ -87,7 +89,6 @@ pub enum ExtensionCategoryFilter { Grammars, LanguageServers, ContextServers, - AgentServers, Snippets, DebugAdapters, } @@ -263,6 +264,11 @@ pub enum NewWorktreeBranchTarget { CurrentBranch, /// Create a detached worktree at the tip of an existing branch. ExistingBranch { name: String }, + /// Create a detached worktree at the tip of a remote-tracking branch. + RemoteBranch { + remote_name: String, + branch_name: String, + }, } /// Creates a new git worktree and switches the workspace to it. @@ -505,6 +511,8 @@ pub mod agent { ToggleModelSelector, /// Triggers re-authentication on Gemini ReauthenticateAgent, + /// Logs out of the current external agent + LogoutAgent, /// Add the current selection as context for threads in the agent panel. #[action(deprecated_aliases = ["assistant::QuoteSelection", "agent::QuoteSelection"])] AddSelectionToThread, @@ -512,10 +520,6 @@ pub mod agent { ResetAgentZoom, /// Pastes clipboard content without any formatting. PasteRaw, - /// Opens the "Skills have replaced Rules" explainer modal, - /// describing the one-time migration of non-Default Rules to - /// global Skills. Dispatched from the title-bar banner. - OpenRulesToSkillsMigrationInfo, ] ); @@ -572,6 +576,16 @@ pub mod assistant { #[action(deprecated_aliases = ["assistant::ToggleFocus"])] ToggleFocus, FocusAgent, + /// Opens the skill creator window for creating a new skill. + OpenSkillCreator, + /// Opens the skill creator window to import a skill from a GitHub URL. + CreateSkillFromUrl, + /// Opens the user-global AGENTS.md rules file. + #[action(name = "OpenGlobalAGENTS.mdRules")] + OpenGlobalAgentsMdRules, + /// Opens the project AGENTS.md rules file. + #[action(name = "OpenProjectAGENTS.mdRules")] + OpenProjectAgentsMdRules, ] ); diff --git a/crates/ztracing/LICENSE-AGPL b/crates/ztracing/LICENSE-AGPL deleted file mode 120000 index 5f5cf25dc45..00000000000 --- a/crates/ztracing/LICENSE-AGPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-AGPL \ No newline at end of file diff --git a/crates/ztracing_macro/LICENSE-AGPL b/crates/ztracing_macro/LICENSE-AGPL deleted file mode 120000 index 5f5cf25dc45..00000000000 --- a/crates/ztracing_macro/LICENSE-AGPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-AGPL \ No newline at end of file diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 606a75542a5..eca2ca57ae4 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -18,6 +18,7 @@ - [Parallel Agents](./ai/parallel-agents.md) - [Inline Assistant](./ai/inline-assistant.md) - [Edit Prediction](./ai/edit-prediction.md) +- [Skills](./ai/skills.md) - [Rules](./ai/rules.md) - [Model Context Protocol](./ai/mcp.md) - [Configuration](./ai/configuration.md) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 0187d3a0b24..e657f6b6179 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -40,6 +40,7 @@ From the "New Thread…" menu you can: - Pick **Zed Agent** or any installed [external agent](./external-agents.md) to start a new thread with that agent. - Choose **New From Summary** to start a fresh Zed Agent thread seeded with a summary of the current conversation — useful for compacting long threads as you approach the context window limit. +- Choose **Terminal** to open a terminal thread directly in the Agent Panel — see [Terminal Threads](#terminal-threads) for details. {#action agent::NewExternalAgentThread} creates a new thread with the specified external agent id. @@ -119,16 +120,120 @@ To see which files specifically have been edited, expand the accordion bar that You can accept or reject each individual change hunk, or the whole set of changes made by the agent. -Edit diffs also appear in singleton buffers. -If your active tab had edits made by the AI, you'll see diffs with the same accept/reject controls as in the multi-buffer. -You can turn this off, though, through the `agent.single_file_review` setting. +Edit diffs can also appear inline in individual files with the same +keep/reject hunk controls as the multi-buffer review pane. This temporarily overrides the buffer's git diff while review is active. Enable it by setting `agent.single_file_review` to `true` in your settings. + +## Terminal Threads {#terminal-threads} + +The Agent Panel can host terminal threads alongside your agent threads. Each terminal thread appears as its own entry in the [Threads Sidebar](./parallel-agents.md#threads-sidebar) with a terminal icon, letting you switch between conversations and shell sessions from the same list. + +External agents like Claude Agent and Codex can also run as terminal threads. Some support terminal signals — such as bell notifications or title updates — that Zed uses to show useful context in the sidebar. + +### Opening a Terminal Thread {#opening-a-terminal-thread} + +Open the menu using the agent selector button on the left (in the empty state) or the `+` icon in the top-right of the panel toolbar, and choose **Terminal**. The terminal thread opens in the panel body, just like switching to a thread. You can open as many as you like — each gets its own sidebar entry. + +### Terminal Thread Titles {#terminal-thread-titles} + +The terminal title in the toolbar updates automatically to reflect the running shell or process. You can also set a custom name by clicking the title or the pencil icon that appears on hover. + +### Notifications {#terminal-thread-notifications} + +When a terminal produces a bell character while not in focus, Zed notifies you the same way it does when an agent finishes — with a visual pop-up and an optional sound. Clicking the notification brings the terminal into focus and clears the indicator. The same `agent.notify_when_agent_waiting` and `agent.play_sound_when_agent_done` settings apply. + +### Closing Terminal Threads {#closing-terminal-threads} + +Unlike agent threads, terminal threads are closed rather than archived — they don't go to Thread History. To close one, hover over it in the Threads Sidebar and click the **×** button, or select it and press {#kb agent::ArchiveSelectedThread}. + +### Claude Code Notifications {#claude-code-notifications} + +Claude Code can notify you when it finishes a task or pauses for permission. To enable this, set `preferredNotifChannel` to `"terminal_bell"` in your Claude Code user settings: + +```json +{ + "preferredNotifChannel": "terminal_bell" +} +``` + +You can also set this from within Claude Code by running `/config`, selecting `Local Notifications`, and choosing `Terminal Bell`. + +> If you run Claude Code inside tmux, bell notifications may not reach the outer terminal unless passthrough is enabled. Add this to `~/.tmux.conf`: +> +> ``` +> set -g allow-passthrough on +> ``` + +For more, see the [Claude Code documentation](https://code.claude.com/docs/en/terminal-config). + +### Amp Notifications {#amp-notifications} + +Amp updates terminal titles automatically and can also notify you when it needs your attention. To enable notifications in Zed terminal threads, add `AMP_FORCE_BEL=1` to your terminal environment settings: + +```json [settings] +{ + "terminal": { + "env": { + "AMP_FORCE_BEL": "1" + } + } +} +``` + +Restart Amp after adding the environment variable. + +### OpenCode Notifications {#opencode-notifications} + +OpenCode can update terminal titles automatically. For Zed notifications, add an OpenCode plugin that emits a terminal bell when OpenCode needs your attention. + +Create `.opencode/plugins/zed-bell.js` in your project, or `~/.config/opencode/plugins/zed-bell.js` to use it globally: + +```js +export const ZedBell = async () => { + return { + event: async ({ event }) => { + if (event.type === "session.idle" || event.type === "permission.asked") { + process.stdout.write("\x07"); + } + }, + }; +}; +``` + +Restart OpenCode after adding the plugin. + +### Pi Notifications {#pi-notifications} + +Pi can use an extension to emit a notification when it finishes a turn. Create `.pi/extensions/zed-bell.ts` in your project, or `~/.pi/agent/extensions/zed-bell.ts` to use it globally: + +```ts +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.on("agent_end", async () => { + process.stdout.write("\x07"); + }); +} +``` + +Restart Pi after adding the extension, or run `/reload` if the extension is in one of Pi's auto-discovered extension locations. + +### Codex Terminal Titles {#codex-terminal-titles} + +Codex can update the terminal title as it works, which Zed uses to show useful context for Codex terminal threads in the sidebar — such as the project, current status, branch, model, or task progress. + +To configure this from within Codex, run `/title` and use the picker to choose which fields appear and in what order. Codex saves the selection to `tui.terminal_title` in `~/.codex/config.toml`. You can also edit it directly: + +```toml +[tui] +terminal_title = ["spinner", "project-name", "run-state", "thread-title"] +``` ## Adding Context {#adding-context} The agent can search your codebase to find relevant context, but providing it explicitly improves response quality and reduces latency. Add context by typing `@` in the message editor. -You can mention files, directories, symbols, previous threads, rules files, and diagnostics. +You can mention files, directories, symbols, previous threads, skills, and diagnostics. When you paste multi-line code selections copied from a buffer, Zed automatically formats them as @-mentions with the file context. To paste content without this automatic formatting, use {#kb agent::PasteRaw} to paste raw text directly. diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index 219f2fae1da..d5fc6750e83 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -30,6 +30,8 @@ For example, - You use $12 of incremental tokens in the month of February, with the first $10 spent on February 15. You'll receive an invoice for $10 on February 15. - On March 1, you receive an invoice for $12: $10 (March Pro subscription) and $2 in leftover token spend, since your usage didn't cross the $10 threshold. +For high-volume users, the threshold automatically scales up over time to keep invoicing manageable, so subsequent invoices may trigger at larger increments rather than every $10. + ### Payment failures {#payment-failures} If payment of an invoice fails, Zed will block usage of our hosted models until the payment is complete. Email [billing-support@zed.dev](mailto:billing-support@zed.dev) for assistance. diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 3a8455a327c..3c08a960da8 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -656,6 +656,8 @@ By default, models from all subscription types are shown. Optionally, you can hi } ``` +**Note:** Zed only bundles configuration for long-term OpenCode Free models! Free models that are only available for a limited time are not included in Zed. To use such models, create a Custom Model using the configuration settings published on [the OpenCode website](https://opencode.ai/docs/zen#pricing) and on [models.dev](https://github.com/anomalyco/models.dev/tree/dev/providers/opencode/models). + #### Custom Models {#opencode-custom-models} The Zed agent comes pre-configured with OpenCode models. If you wish to use newer models or models with custom endpoints, you can do so by adding the following to your Zed settings file ([how to edit](../configuring-zed.md#settings-files)): diff --git a/docs/src/ai/models.md b/docs/src/ai/models.md index e2d0c1c83cd..72ef43239f5 100644 --- a/docs/src/ai/models.md +++ b/docs/src/ai/models.md @@ -23,6 +23,10 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir | | Anthropic | Output | $25.00 | $27.50 | | | Anthropic | Input - Cache Write | $6.25 | $6.875 | | | Anthropic | Input - Cache Read | $0.50 | $0.55 | +| Claude Opus 4.8 | Anthropic | Input | $5.00 | $5.50 | +| | Anthropic | Output | $25.00 | $27.50 | +| | Anthropic | Input - Cache Write | $6.25 | $6.875 | +| | Anthropic | Input - Cache Read | $0.50 | $0.55 | | Claude Sonnet 4.5 | Anthropic | Input | $3.00 | $3.30 | | | Anthropic | Output | $15.00 | $16.50 | | | Anthropic | Input - Cache Write | $3.75 | $4.125 | @@ -81,7 +85,7 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir As of February 19, 2026, Zed Pro serves newer model versions in place of the retired models below: -- Claude Opus 4.1 → Claude Opus 4.5, Claude Opus 4.6, or Claude Opus 4.7 +- Claude Opus 4.1 → Claude Opus 4.5, Claude Opus 4.6, Claude Opus 4.7, or Claude Opus 4.8 - Claude Sonnet 4 → Claude Sonnet 4.5 or Claude Sonnet 4.6 - Claude Sonnet 3.7 (retired Feb 19) → Claude Sonnet 4.5 or Claude Sonnet 4.6 - GPT-5.1 and GPT-5 → GPT-5.2 or GPT-5.2-Codex @@ -91,6 +95,8 @@ As of February 19, 2026, Zed Pro serves newer model versions in place of the ret ## Usage {#usage} +Because Zed-hosted Gemini models do not use Google context caching, Gemini usage is billed only as input and output tokens; there is no separate cached-input price for these models. This preserves zero-data-retention behavior for hosted Gemini requests. For background, see Google's Vertex AI documentation on [context caching](https://cloud.google.com/vertex-ai/generative-ai/docs/context-cache/context-cache-overview) and [zero data retention](https://cloud.google.com/vertex-ai/generative-ai/docs/vertex-ai-zero-data-retention). + Any usage of a Zed-hosted model will be billed at the Zed Price (rightmost column above). See [Plans and Usage](./plans-and-usage.md) for details on Zed's plans and limits for use of hosted models. > LLMs can enter unproductive loops that require user intervention. Monitor longer-running tasks and interrupt if needed. @@ -104,6 +110,7 @@ A context window is the maximum span of text and code an LLM can consider at onc | Claude Opus 4.5 | Anthropic | 200k | | Claude Opus 4.6 | Anthropic | 1M | | Claude Opus 4.7 | Anthropic | 1M | +| Claude Opus 4.8 | Anthropic | 1M | | Claude Sonnet 4.5 | Anthropic | 200k | | Claude Sonnet 4.6 | Anthropic | 1M | | Claude Haiku 4.5 | Anthropic | 200k | diff --git a/docs/src/ai/overview.md b/docs/src/ai/overview.md index 0859d47c127..413b67ba595 100644 --- a/docs/src/ai/overview.md +++ b/docs/src/ai/overview.md @@ -18,7 +18,7 @@ Zed's AI features run inside a native, GPU-accelerated application built in Rust ## Agentic editing -The [Threads Sidebar](./parallel-agents.md#threads-sidebar) is where you organize agent work. Start a thread, give it a task, and the agent reads, edits, and runs code in your project. You can run multiple threads at once, each using a different agent and working against different projects. See [Tools](./tools.md) for the capabilities available to Zed's built-in agent. +The [Threads Sidebar](./parallel-agents.md#threads-sidebar) is where you organize agent work. Start a thread, give it a task, and the agent reads, edits, and runs code in your project. You can also open terminal threads directly in the sidebar alongside your agent threads. Run multiple agent threads and terminal threads at once, each using a different agent and working against different projects. See [Tools](./tools.md) for the capabilities available to Zed's built-in agent. The [Agent Panel](./agent-panel.md) is the conversation view for the active thread. Use it to send prompts, review changes, add context, and interact with the agent as it works. diff --git a/docs/src/ai/parallel-agents.md b/docs/src/ai/parallel-agents.md index 17d61528eea..6c8fccea8d5 100644 --- a/docs/src/ai/parallel-agents.md +++ b/docs/src/ai/parallel-agents.md @@ -1,11 +1,11 @@ --- title: Parallel Agents - Zed -description: Run multiple agent threads concurrently using the Threads Sidebar, manage them across projects, and isolate work using Git worktrees. +description: Run multiple agent threads and terminal threads concurrently using the Threads Sidebar, manage them across projects, and isolate work using Git worktrees. --- # Parallel Agents -Parallel Agents lets you run multiple agent threads at once, each working independently with its own agent, context window, and conversation history. The Threads Sidebar is where you start, manage, and switch between them. +Parallel Agents lets you run multiple agent threads and terminal threads at once from the Threads Sidebar. Each thread works independently with its own agent, context window, and conversation history. Terminal threads appear alongside agent threads in the same sidebar, so you can switch between them without leaving the Agent Panel. Open the Threads Sidebar with {#kb multi_workspace::ToggleWorkspaceSidebar}. @@ -15,6 +15,8 @@ Open the Threads Sidebar with {#kb multi_workspace::ToggleWorkspaceSidebar}. The sidebar shows your threads grouped by project. Each project gets its own section with a header. Threads appear below with their title, status indicator, and which agent is running them. Threads running in linked Git worktrees appear under the same project as their main worktree. See [Worktree Isolation](#worktree-isolation). +Terminal threads also appear as entries in the sidebar alongside agent threads, identified by a terminal icon. Click one to switch to it. See [terminal threads](./agent-panel.md#terminal-threads) for details. + To focus the sidebar without toggling it, use {#kb multi_workspace::FocusWorkspaceSidebar}. To search your threads, press {#kb agents_sidebar::FocusSidebarFilter} while the sidebar is focused. ### Switching Threads {#switching-threads} diff --git a/docs/src/ai/rules.md b/docs/src/ai/rules.md index 1fb47aa562d..87f6008ce67 100644 --- a/docs/src/ai/rules.md +++ b/docs/src/ai/rules.md @@ -3,10 +3,12 @@ title: AI Rules in Zed - .rules, .cursorrules, CLAUDE.md description: Configure AI behavior in Zed with .rules files, .cursorrules, CLAUDE.md, AGENTS.md, and the Rules Library for project-level instructions. --- -# Using Rules {#using-rules} +# Rules {#rules} Rules are prompts that can be inserted either automatically at the beginning of each [Agent Panel](./agent-panel.md) interaction, through `.rules` files available in your project's file tree, or on-demand, through @-mentioning, via the Rules Library. +> **Note:** Starting in Zed v1.4.0, on-demand rules (and the rules library) have been replaced by [Skills](./skills.md). Skills are the recommended way to package reusable agent instructions. Learn more about [the rules -> skills migration](#migrating-to-skills). + ## `.rules` files Zed supports including `.rules` files at the root of a project's file tree, and they act as project-level instructions that are auto-included in all of your interactions with the Agent Panel. @@ -30,6 +32,8 @@ It's a full editor with syntax highlighting and all standard keybindings. You can also use the inline assistant right in the rules editor, allowing you to get quick LLM support for writing rules. +> **Note:** Starting in Zed v1.4.0, the rules library has been replaced by [Skills](./skills.md). Skills are the recommended way to package reusable agent instructions. Learn more about [the rules -> skills migration](#migrating-to-skills). + ### Opening the Rules Library 1. Open the Agent Panel. @@ -66,12 +70,12 @@ All rules in the Rules Library can be set as a default rule, which means they’ You can set any rule as the default by clicking the paper clip icon button in the top-right of the rule editor in the Rules Library. -## Migrating from Prompt Library +## Migrating to Skills {#migrating-to-skills} -Previously, the Rules Library was called the "Prompt Library". -The new rules system replaces the Prompt Library except in a few specific cases, which are outlined below. +As of Zed v1.4.0, your existing Rules are migrated to Skills automatically: -### Slash Commands in Rules +- **Non-default Rules** become global skills in `~/.agents/skills/`, each with `disable-model-invocation: true`. They remain user-invocable via `/skill-name` or `@`-mention. +- **Default Rules** are appended to your global `AGENTS.md` file (`~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows), preserving their behavior of being included in every conversation. +- **Git Commit** prompt customizations are also appended to the global `AGENTS.md` file. -Previously, it was possible to use slash commands (now @-mentions) in custom prompts (now rules). -There is currently no support for using @-mentions in rules files. +Lastly, note that all of the content you had available in the Rules Library hasn't been deleted, so downgrading to an earlier version of Zed leaves your Rules intact. diff --git a/docs/src/ai/skills.md b/docs/src/ai/skills.md new file mode 100644 index 00000000000..39ee6844d24 --- /dev/null +++ b/docs/src/ai/skills.md @@ -0,0 +1,212 @@ +--- +title: Agent Skills - Zed +description: Extend Zed's AI agent with reusable, on-demand skill files for specialized tasks. +--- + +# Skills {#skills} + +Skills are reusable instruction packages that give the agent specialized knowledge for specific tasks: test-driven development workflows, document processing, database integrations, or your team's internal coding standards. + +A skill is a folder containing a `SKILL.md` file with metadata and instructions. The agent sees a catalog of all installed skills and can load one on demand, or you can invoke any skill directly from the message editor with a slash command. + +## Adding Skills {#adding-skills} + +### Create your own {#create-your-own} + +Zed includes a built-in `create-skill` skill — invoke it with `/create-skill` and the agent walks you through the process. + +You can also open the Skill Creator from the Agent Panel using {#kb agent::OpenRulesLibrary}, or by clicking `...` and selecting **Skills**. Outside the panel, use the {#action agent::OpenSkillCreator} action from the command palette. It opens a window where you fill in the skill's name, description, scope (global or project-local), body, and optionally toggle `disable-model-invocation`. + +Lastly, it's also possible to add a skill through importing it from an existing GitHub Markdown file. Open the command palette and look for the {#action agent::CreateSkillFromUrl} action. If your clipboard contains a supported GitHub `.md` URL, Zed pre-fills and fetches it automatically. + +See [Skill format](#skill-format) below for the full format reference. + +### From the skills.sh Registry {#from-the-registry} + +[skills.sh](https://skills.sh) is a community registry of open-source skills. You'll find skills for popular frameworks, tools, workflows, and more: + +- [`find-skills`](https://skills.sh/vercel-labs/skills/find-skills): discover and install skills from the open ecosystem +- [`frontend-design`](https://skills.sh/anthropics/skills/frontend-design): production-grade frontend interfaces with design polish +- [`pdf`](https://skills.sh/anthropics/skills/pdf): PDF text extraction, merging, splitting, form filling, and OCR + +To install a skill, copy the skill's folder into `~/.agents/skills/` for global use, or into your project's `.agents/skills/` folder for project-local use. + +## Managing Skills {#managing-skills} + +Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) and navigate to **AI > Skills**, or go directly to [agent.skills](zed://settings/agent.skills). + +The **User** tab shows your global skills. The **Project** tab shows skills for the current project. + +For each skill you can: + +- **Copy Share Link** — copies a `zed://skill` link that embeds the skill, ready to send to someone else (see [Sharing Skills](#sharing-skills)) +- **Open** — opens the skill's `SKILL.md` file in the editor +- **Delete** — removes the skill folder from disk + +If no skills are installed, the page shows a **Create a Skill** button that opens the Skill Creator. + +## Sharing Skills {#sharing-skills} + +You can hand a skill to a teammate without hosting it anywhere. In the Skills settings page, click the **link** icon on a skill row to copy a `zed://skill?data=…` link to your clipboard. The link is self-contained: it embeds the full `SKILL.md` contents (base64url-encoded), so the recipient doesn't need access to your project or any registry. + +When someone opens that link (for example by pasting it into their browser or clicking it in a chat), Zed launches the Skill Creator pre-filled with the shared skill. The recipient can review the name, description, and full body, choose a scope (global or project-local), and click **Save** to install it. Nothing is written to disk until they explicitly save, so a shared link can never silently install instructions into someone's agent. + +## Managing Skills {#managing-skills} + +Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) and navigate to **AI > Skills**, or go directly to [agent.skills](zed://settings/agent.skills). + +The **User** tab shows your global skills. The **Project** tab shows skills for the current project. + +For each skill you can: + +- **Open** — opens the skill's `SKILL.md` file in the editor +- **Delete** — removes the skill folder from disk + +If no skills are installed, the page shows a **Create a Skill** button that opens the Skill Creator. + +## Using Skills {#using-skills} + +By default, the agent picks up skills autonomously. It sees a catalog of every installed skill (name and description) in its system prompt, and calls the `skill` tool when a task matches a skill's description. + +When the agent invokes a skill, Zed prompts you to allow or deny it, using the same permission flow as other tools. You can set per-skill defaults in [Tool Permissions](./tool-permissions.md) so you're not prompted for skills you always trust. + +### Manual Invocation {#manual-invocation} + +You can also load a skill manually: + +- **Slash command**: type `/` in the message editor and select a skill by name +- **@-mention**: type `@skill` in the message editor and select a skill from the completion menu + +Both inject the skill's instructions as context. The loaded skill appears as a crease button in the thread, which you can click to open the skill file. + +### Preventing Autonomous Invocation {#disable-model-invocation} + +Add `disable-model-invocation: true` to a skill's frontmatter to stop the agent from picking it up autonomously. +The skill still appears as a slash command, so you stay in control of when it runs. + +This is useful for workflows you don't want the agent triggering automatically, like deploy or release procedures. + +```yaml +--- +name: deploy +description: Deploy the current branch to production. +disable-model-invocation: true +--- +``` + +## Skill Format {#skill-format} + +### Folder Structure {#folder-structure} + +A skill is a named folder containing a `SKILL.md` file: + +``` +my-skill/ +├── SKILL.md # Required: metadata and instructions +├── scripts/ # Optional: scripts the agent can run +├── references/ # Optional: additional documentation +└── assets/ # Optional: templates and static files +``` + +The folder name must match the `name` field in `SKILL.md`. + +### SKILL.md format {#skill-md-format} + +`SKILL.md` starts with YAML frontmatter, followed by Markdown instructions. + +**Minimal example:** + +```markdown +--- +name: my-skill +description: What this skill does and when to use it. +--- + +## Instructions + +Step-by-step instructions for the agent... +``` + +#### Frontmatter Fields {#frontmatter-fields} + +| Field | Required | Description | +| -------------------------- | -------- | -------------------------------------------------------------------------------------------- | +| `name` | Yes | Lowercase letters, numbers, and hyphens only. Max 64 characters. Must match the folder name. | +| `description` | Yes | What the skill does and when to use it. Max 1024 characters. | +| `disable-model-invocation` | No | Set to `true` to hide from the agent's catalog (slash command only). | + +> **Tip:** Write descriptions that help the agent recognize when a skill is relevant. Include specific task types and trigger phrases: "Use when handling PDFs, extracting text, or filling forms" is better than "Helps with PDFs." + +We plan to include other fields promoted by [the Agent Skills specification](https://agentskills.io/specification) in the near future. + +#### Name Validation {#name-validation} + +The `name` field must: + +- Contain only lowercase letters (`a-z`), numbers, and hyphens +- Not start or end with a hyphen +- Not contain consecutive hyphens (`--`) +- Be 1 to 64 characters + +Skills with invalid names fail to load and surface an error in the UI. + +### Bundled Resources {#bundled-resources} + +Keep the body of `SKILL.md` under 500 lines. Move detailed material to reference files and link to them from the body: + +```markdown +See [reference guide](references/REFERENCE.md) for complete API details. + +Run the extraction script: +scripts/extract.py +``` + +The agent loads these files on demand using the `read_file` and `list_directory` tools. Global skills under `~/.agents/skills/` are accessible to the agent even though they're outside your project. + +### Writing Effective Instructions {#writing-instructions} + +Skills use [progressive disclosure](https://agentskills.io/specification#progressive-disclosure): the agent sees only the name and description until it activates a skill, then loads the full body. Structure your skill to take advantage of this: + +- Put the most important instructions near the top of the body +- Keep `SKILL.md` under 500 lines; move detailed references to `references/` +- Scripts that the agent needs to run go in `scripts/` + +See the [Agent Skills specification](https://agentskills.io/specification) for the full format reference. + +## Where Skills Live {#where-skills-live} + +Zed loads skills from two locations: + +| Scope | Path | When it applies | +| ------------- | ---------------------------- | ------------------------ | +| Global | `~/.agents/skills/` | Every project | +| Project-local | `/.agents/skills/` | Only the current project | + +Each skill is a direct child of the skills root. Nesting skills inside subfolders is not supported. + +### Project-local Skills and Trust {#project-local-trust} + +Project-local skills only load from [trusted worktrees](../worktree-trust.md). Skills from a freshly cloned or untrusted project are excluded from the catalog and slash commands until you grant trust. + +This prevents a malicious project from injecting instructions into your agent's system prompt before you've reviewed what the project ships. + +### Override Behavior {#override-behavior} + +If a global and a project-local skill share the same name, the project-local skill takes precedence. This lets a project customize or replace a global skill for its own context. + +### Editing Skill Files {#editing-skill-files} + +The agent cannot edit `SKILL.md` files or their bundled resources without your explicit authorization, even in a trusted project. This prevents a compromised conversation from modifying the skills that govern future conversations. + +## Limitations {#limitations} + +- **Flat layout only.** Skills must be direct children of the skills root. Nested folders like `~/.agents/skills/group/my-skill/` are not discovered. +- **50KB catalog budget.** The total size of all skill names and descriptions is capped at 50KB. Skills that don't fit are dropped from the catalog with a warning in the UI. Keep descriptions concise. +- **No remote registry.** Zed does not fetch skills from URLs or support custom search paths. Skills come from `~/.agents/skills/` and `/.agents/skills/` only. Use a symlink if you need to point at another location. +- **Live reload.** Adding, removing, or editing a `SKILL.md` takes effect immediately without restarting your session. Changes to a skill's `name` or `description` invalidate the model's prompt cache for the current session. + +## See also + +- [Agent Panel](./agent-panel.md) +- [Tool Permissions](./tool-permissions.md) +- [Agent Skills specification](https://agentskills.io/specification) diff --git a/docs/src/dev-containers.md b/docs/src/dev-containers.md index 6d19e8e529d..3b0874812bf 100644 --- a/docs/src/dev-containers.md +++ b/docs/src/dev-containers.md @@ -41,12 +41,33 @@ If you modify `.devcontainer/devcontainer.json`, Zed does not currently rebuild Once connected, Zed operates inside the container environment for tasks, terminals, and language servers. Files are linked from your workspace into the container according to the dev container specification. +## Extensions + +You can specify extensions in `.devcontainer/devcontainer.json` under the "customizations" field like so: + +```json +{ + ... + "customizations": { + "zed": { + "extensions": ["vue", "ruby"], + }, + "vscode": { + ... + }, + "codespaces": { + ... + }, + } +} +``` + +Note that extensions load for the Zed session, so these extensions will exist on your local Zed instances as well. + ## Known Limitations > **Note:** This feature is still in development. -- **Extensions:** Zed does not yet manage extensions separately for container environments. The host's extensions are used as-is. -- **Port forwarding:** Only the `appPort` field is supported. `forwardPorts` and other advanced port-forwarding features are not implemented. - **Configuration changes:** Updates to `devcontainer.json` do not trigger automatic rebuilds or reloads; containers must be manually restarted. ## See also diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index 1b500352eb1..d341684f89c 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -66,7 +66,9 @@ my-extension/ rust.json ``` -## WebAssembly +## Rust and WebAssembly + +> Please note that most extensions will work properly without any Rust code present. In particular, only language server, context server and debugger extensions require the presence custom Rust in order to function properly. Procedural parts of extensions are written in Rust and compiled to WebAssembly. To develop an extension that includes custom code, include a `Cargo.toml` like this: @@ -101,7 +103,11 @@ impl zed::Extension for MyExtension { zed::register_extension!(MyExtension); ``` -> `stdout`/`stderr` is forwarded directly to the Zed process. In order to see `println!`/`dbg!` output from your extension, you can start Zed in your terminal with a `--foreground` flag. +> Since your extension will be compiled to WebAssembly, some Rust features might not work like you would expect them to. For example, `cfg` - directives will not work and `std::env::var` will also not yield the expected results. Instead, use the [`zed_extension_api::current_platform`](https://docs.rs/zed_extension_api/latest/zed_extension_api/fn.current_platform.html) method to get information about the current environment and familiarize yourself with the [`Worktree` struct and its methods](https://docs.rs/zed_extension_api/latest/zed_extension_api/struct.Worktree.html) for reading environment variables and finding binaries in the users `PATH`. + +### Debugging your Rust extension + +`stdout`/`stderr` is forwarded directly to the Zed process. In order to see `println!`/`dbg!` output from your extension, you can start Zed in your terminal with a `--foreground` flag. ## Forking and cloning the repo @@ -153,6 +159,10 @@ Furthermore, please make sure that your extension fulfills the following precond - Extension IDs and names must not contain the words `zed`, `Zed` or `extension`, since they are all Zed extensions. - Your extension ID should provide some information on what your extension tries to accomplish. E.g. for themes, it should be suffixed with `-theme`, snippet extensions should be suffixed with `-snippets` and so on. An exception to that rule are extension that provide support for languages or popular tooling that people would expect to find under that ID. You can take a look at the list of [existing extensions](https://github.com/zed-industries/extensions/blob/main/extensions.toml) to get a grasp on how this usually is enforced. +- Your extension must only include the resources it requires to function and nothing else. + - See the [directory structure of a Zed extension](#directory-structure-of-a-zed-extension) and the [Rust and WebAssembly](#rust-and-webassembly) sections for more information. +- Extensions must in no way attempt to read nor modify the environment outside of the environment designated to them by Zed. Should they need to read the environment, they should use methods as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/) and may fall back to appropriate methods from the Rust standard library. Should they need changes to the environment, they must instead ask the user to perform these for them using an appropriate method within the context (e.g. provide information for doing so using the `ContextServerConfiguration` for context servers). + - Please make sure to have read the [Rust and WebAssembly section above](#rust-and-webassembly) for more information and help regarding this topic. - Extensions should provide something that is not yet available in the marketplace as opposed to fixing something that could be resolved within an existing extension. For example, if you find that an existing extension's support for a language server is not functioning properly, first try contributing a fix to the existing extension as opposed to submitting a new extension immediately. - If you receive no response or reaction within the upstream repository within a reasonable amount of time, feel free to submit a pull request that aims to fix said issue. Please ensure that you provide your previous efforts within the pull request to the extensions repository for adding your extension. Zed maintainers will then decide on how to proceed on a case by case basis. - Extensions that intend to provide a language, debugger or MCP server must not ship the language server as part of the extension. Instead, the extension should either download the language server or check for the availability of the language server in the users environment using the APIs as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/). diff --git a/docs/src/git.md b/docs/src/git.md index c2e19e3d88d..0d0fcc1a4e8 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -272,12 +272,12 @@ To view a stash's contents, select it in the stash picker and press {#kb stash_p ## AI Support in Git Zed currently supports LLM-powered commit message generation. -You can ask AI to generate a commit message by focusing on the message editor within the Git Panel and either clicking on the pencil icon in the bottom left, or reaching for the {#action git::GenerateCommitMessage} ({#kb git::GenerateCommitMessage}) keybinding. +You can ask AI to generate a commit message by focusing on the message editor within the Git Panel and either clicking on the pencil icon in the bottom left, or reaching for the {#action git::GenerateCommitMessage}, or through the {#kb git::GenerateCommitMessage} keybinding. > Note that you need to have an LLM provider configured either via your own API keys or through Zed's hosted AI models. > Visit [the AI configuration page](./ai/configuration.md) to learn how to do so. -You can specify your preferred model to use by providing a `commit_message_model` agent setting. +You can specify your preferred model for this task by adding a `commit_message_model` field to your agent settings. See [Feature-specific models](./ai/agent-settings.md#feature-specific-models) for more information. ```json [settings] @@ -285,18 +285,16 @@ See [Feature-specific models](./ai/agent-settings.md#feature-specific-models) fo "agent": { "commit_message_model": { "provider": "anthropic", - "model": "claude-3-5-haiku" + "model": "claude-4-5-haiku" } } } ``` -To customize the format of generated commit messages, run {#action agent::OpenRulesLibrary} and select the "Commit message" rule on the left side. -From there, you can modify the prompt to match your desired format. +To add custom commit instructions for the model, use the global `AGENTS.md` file located `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows. - - -Any specific instructions for commit messages added to [Rules files](./ai/rules.md) are also picked up by the model tasked with writing your commit message. +> Before Zed v1.4.0, this was done through the Rules Library, which has been removed. +> See [the "Migrating to Skills" docs](./ai/rules.md#migrating-to-skills) in the Rules page for more information. ## Git Integrations diff --git a/docs/src/languages/opentofu.md b/docs/src/languages/opentofu.md index 7809099c055..e6bcdd2c3ef 100644 --- a/docs/src/languages/opentofu.md +++ b/docs/src/languages/opentofu.md @@ -7,7 +7,7 @@ description: "Configure OpenTofu language support in Zed, including language ser OpenTofu support is available through the [OpenTofu extension](https://github.com/ashpool37/zed-extension-opentofu). -- Tree-sitter: [MichaHoffmann/tree-sitter-hcl](https://github.com/MichaHoffmann/tree-sitter-hcl) +- Tree-sitter: [tree-sitter-grammars/tree-sitter-hcl](https://github.com/tree-sitter-grammars/tree-sitter-hcl) - Language Server: [opentofu/tofu-ls](https://github.com/opentofu/tofu-ls) ## Configuration diff --git a/docs/src/languages/terraform.md b/docs/src/languages/terraform.md index a7f87d20f46..a7e12b68187 100644 --- a/docs/src/languages/terraform.md +++ b/docs/src/languages/terraform.md @@ -7,7 +7,7 @@ description: "Configure Terraform language support in Zed, including language se Terraform support is available through the [Terraform extension](https://github.com/zed-extensions/terraform). -- Tree-sitter: [MichaHoffmann/tree-sitter-hcl](https://github.com/MichaHoffmann/tree-sitter-hcl) +- Tree-sitter: [tree-sitter-grammars/tree-sitter-hcl](https://github.com/tree-sitter-grammars/tree-sitter-hcl) - Language Server: [hashicorp/terraform-ls](https://github.com/hashicorp/terraform-ls) ## Configuration diff --git a/docs/src/migrate/rustrover.md b/docs/src/migrate/rustrover.md index f4a8bccd6e3..883b3915d26 100644 --- a/docs/src/migrate/rustrover.md +++ b/docs/src/migrate/rustrover.md @@ -339,7 +339,7 @@ Here's what RustRover offers that Zed doesn't have: On licensing and telemetry: -- **Zed is open source** (MIT licensed for the editor, AGPL for collaboration services) +- **Zed is open source** (primarily GPL-licensed, with Apache-licensed components) - **Telemetry is optional** and can be disabled during onboarding or in settings ## Collaboration in Zed vs. RustRover diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 6591a47b353..b103b8f61fc 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -2098,6 +2098,7 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files ```json [settings] { "diagnostics": { + "button": true, "include_warnings": true, "inline": { "enabled": false @@ -2106,6 +2107,11 @@ To interpret all `.c` files as C++, files called `MyLockFile` as TOML and files } ``` +**Options** + +- `button`: Whether to show the project diagnostics button in the status bar +- `include_warnings`: Whether to show warnings or not by default + ### Inline Diagnostics - Description: Whether or not to show diagnostics information inline. @@ -3004,6 +3010,16 @@ Configuration for various AI model providers including API URLs and authenticati 3. `border`: Draw a border around the color text. 4. `none`: Do not query and render document colors. +## LSP Document Links + +- Description: Whether to query and display LSP `textDocument/documentLink` links in the editor +- Setting: `lsp_document_links` +- Default: `true` + +**Options** + +`boolean` values + ## Max Tabs - Description: Maximum number of tabs to show in the tab bar diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 6baa5cb2724..528086d0491 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -281,7 +281,7 @@ TBD: Centered layout related settings // Minimap related settings "minimap": { "show": "never", // When to show (auto, always, never) - "display_in": "active_editor", // Where to show (active_editor, all_editor) + "display_in": "active_editor", // Where to show (active_editor, all_editors) "thumb": "always", // When to show thumb (always, hover) "thumb_border": "left_open", // Thumb border (left_open, right_open, full, none) "max_width_columns": 80, // Maximum width of minimap @@ -382,6 +382,8 @@ TBD: Centered layout related settings // How to render LSP `textDocument/documentColor` colors in the editor. "lsp_document_colors": "inlay", // none, inlay, border, background + // Whether to query and display LSP document links in the editor. + "lsp_document_links": true, // When to show the scrollbar in the completion menu. "completion_menu_scrollbar": "never", // auto, system, always, never diff --git a/flake.nix b/flake.nix index 3a9744fe1ee..1902a3ee434 100644 --- a/flake.nix +++ b/flake.nix @@ -31,11 +31,9 @@ nixConfig = { extra-substituters = [ "https://zed.cachix.org" - "https://cache.garnix.io" ]; extra-trusted-public-keys = [ "zed.cachix.org-1:/pHQ6dpMsAZk2DiP4WCL0p9YDNKWj2Q5FL20bNmw1cU=" - "cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=" ]; }; } diff --git a/legal/terms.md b/legal/terms.md index ed90fd36c83..5b4b87bd5ad 100644 --- a/legal/terms.md +++ b/legal/terms.md @@ -37,7 +37,7 @@ The Service uses technology provided by multiple third party AI subprocessors (t ### 2.4. Restrictions -Customer will not (and will not permit anyone else to), directly or indirectly, do any of the following: (a) provide access to, distribute, sell, or sublicense the Service to a third party; (b) seek to access non-public APIs associated with the Service; (c) copy any element of the Service; (d) interfere with the operation of the Service, circumvent any access restrictions, or conduct any security or vulnerability test of the Service; (e) transmit any viruses or other harmful materials to the Service or others; (f) take any action that risks harm to others or to the security, availability, or integrity of the Service except for the purposes of legitimate security or malware research; or (g) access or use the Service or Output in a manner that violates any applicable relevant local, state, federal or international laws, regulations, or conventions, including those related to data privacy or data transfer, international communications, or export of data (collectively, “**Laws**”), or the Terms. The Service incorporates functionality provided by third-party services, the use of which is subject to additional terms. Customer agrees that if Customer accesses or uses services, features or functionality in the Software or Service that are provided by a third party, Customer will comply with any applicable terms promulgated by that third party, including as set forth at [https://zed.dev/acceptable-use-policies](/acceptable-use-policies) (as may be updated from time to time). Customer further acknowledges that certain components of the Software or Service may be covered by open source licenses ("**Open Source Component**"), including but not limited to Apache License, Version 2.0, GNU General Public License v3.0, and the GNU Affero General Public License v3.0. To the extent required by such open source license for the applicable Open Source Component, the terms of such license will apply to such Open Source Component in lieu of the relevant provisions of these Terms. If such open source license prohibits any of the restrictions in these Terms, such restrictions will not apply to such Open Source Component. Zed shall provide Customer with a list of Open Source Components upon Customer's request. +Customer will not (and will not permit anyone else to), directly or indirectly, do any of the following: (a) provide access to, distribute, sell, or sublicense the Service to a third party; (b) seek to access non-public APIs associated with the Service; (c) copy any element of the Service; (d) interfere with the operation of the Service, circumvent any access restrictions, or conduct any security or vulnerability test of the Service; (e) transmit any viruses or other harmful materials to the Service or others; (f) take any action that risks harm to others or to the security, availability, or integrity of the Service except for the purposes of legitimate security or malware research; or (g) access or use the Service or Output in a manner that violates any applicable relevant local, state, federal or international laws, regulations, or conventions, including those related to data privacy or data transfer, international communications, or export of data (collectively, “**Laws**”), or the Terms. The Service incorporates functionality provided by third-party services, the use of which is subject to additional terms. Customer agrees that if Customer accesses or uses services, features or functionality in the Software or Service that are provided by a third party, Customer will comply with any applicable terms promulgated by that third party, including as set forth at [https://zed.dev/acceptable-use-policies](/acceptable-use-policies) (as may be updated from time to time). Customer further acknowledges that certain components of the Software or Service may be covered by open source licenses ("**Open Source Component**"), including but not limited to Apache License, Version 2.0 and GNU General Public License v3.0. To the extent required by such open source license for the applicable Open Source Component, the terms of such license will apply to such Open Source Component in lieu of the relevant provisions of these Terms. If such open source license prohibits any of the restrictions in these Terms, such restrictions will not apply to such Open Source Component. Zed shall provide Customer with a list of Open Source Components upon Customer's request. ## 3. General Payment Terms diff --git a/nix/build.nix b/nix/build.nix index 2f283f83a4d..0375b0de5f8 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -38,6 +38,7 @@ libxfixes, libxkbcommon, libxrandr, + lld, libx11, libxcb, nodejs_22, @@ -137,6 +138,8 @@ let ] ++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ] ++ lib.optionals stdenv'.hostPlatform.isDarwin [ + # Provides `ld64.lld` for clang's `-fuse-ld=lld`. + lld (cargo-bundle.overrideAttrs ( new: old: { version = "0.6.1-zed"; @@ -246,6 +249,11 @@ let }"; NIX_OUTPATH_USED_AS_RANDOM_SEED = "norebuilds"; + } + // lib.optionalAttrs stdenv'.hostPlatform.isDarwin { + # Link with lld on Darwin. nixpkgs' classic open-source ld64 fails to insert + # ARM64 branch thunks for this binary, producing `b(l) ARM64 branch out of range`. + NIX_CFLAGS_LINK = "-fuse-ld=lld"; }; # prevent nix from removing the "unused" wayland/gpu-lib rpaths diff --git a/nix/modules/devshells.nix b/nix/modules/devshells.nix index ab58d37fff2..da6ba6fd113 100644 --- a/nix/modules/devshells.nix +++ b/nix/modules/devshells.nix @@ -37,21 +37,32 @@ name = "zed-editor-dev"; inputsFrom = [ zed-editor ]; - packages = with pkgs; [ - wrappedCargo # must be first, to shadow the `cargo` provided by `rustToolchain` - rustToolchain # cargo, rustc, and rust-toolchain.toml components included - cargo-nextest - cargo-hakari - cargo-machete - cargo-zigbuild - # TODO: package protobuf-language-server for editing zed.proto - # TODO: add other tools used in our scripts + packages = + with pkgs; + [ + wrappedCargo # must be first, to shadow the `cargo` provided by `rustToolchain` + rustToolchain # cargo, rustc, and rust-toolchain.toml components included + cargo-nextest + cargo-hakari + cargo-machete + cargo-zigbuild + # TODO: package protobuf-language-server for editing zed.proto + # TODO: add other tools used in our scripts - # `build.nix` adds this to the `zed-editor` wrapper (see `postFixup`) - # we'll just put it on `$PATH`: - nodejs_22 - zig - ]; + # `build.nix` adds this to the `zed-editor` wrapper (see `postFixup`) + # we'll just put it on `$PATH`: + nodejs_22 + zig + + # A11y testing infra + gobject-introspection + at-spi2-core + (python3.withPackages (ps: [ + ps.pyatspi + ps.pygobject3 + ])) + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ accerciser ]; env = (removeAttrs baseEnv [ diff --git a/nix/modules/packages.nix b/nix/modules/packages.nix index 1d2228c8cfa..9c6cf5e10ce 100644 --- a/nix/modules/packages.nix +++ b/nix/modules/packages.nix @@ -1,7 +1,12 @@ { inputs, ... }: { perSystem = - { pkgs, ... }: + { + pkgs, + lib, + system, + ... + }: let mkZed = import ../toolchain.nix { inherit inputs; }; zed-editor = mkZed pkgs; @@ -11,5 +16,10 @@ default = zed-editor; debug = zed-editor.override { profile = "dev"; }; }; + } + // lib.optionalAttrs (lib.hasSuffix "linux" system) { + checks.a11y-test = import ../tests/a11y.nix { + inherit pkgs inputs; + }; }; } diff --git a/nix/tests/a11y.nix b/nix/tests/a11y.nix new file mode 100644 index 00000000000..f16deac8660 --- /dev/null +++ b/nix/tests/a11y.nix @@ -0,0 +1,296 @@ +# NixOS VM integration test for GPUI AccessKit (X11). +# +# Interactive use: +# nix run .#checks.x86_64-linux.a11y-test.driverInteractive +# +# Then in the Python REPL: +# start_all() +# machine.wait_for_x() +# machine.succeed("su - user -c 'DISPLAY=:0 gpui-a11y-example &'") +# +# Automated run: +# nix build .#checks.x86_64-linux.a11y-test +{ + pkgs, + inputs, +}: +let + lib = pkgs.lib; + + rustBin = inputs.rust-overlay.lib.mkRustBin { } pkgs; + rustToolchain = rustBin.fromRustupToolchainFile ../../rust-toolchain.toml; + craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustToolchain; + + gpui-a11y-example = + let + src = builtins.path { + path = ../../.; + filter = + path: type: + let + root = toString ../../. + "/"; + relPath = lib.removePrefix root path; + firstComp = builtins.head (lib.path.subpath.components relPath); + in + builtins.elem firstComp [ + "crates" + "assets" + "extensions" + "script" + "tooling" + "Cargo.toml" + ".config" + ".cargo" + ]; + name = "gpui-a11y-source"; + }; + commonArgs = { + pname = "gpui-a11y-example"; + version = "0.0.0"; + inherit src; + cargoLock = ../../Cargo.lock; + cargoExtraArgs = "-p gpui --example a11y --locked --features=gpui_platform/runtime_shaders"; + CARGO_PROFILE = "dev"; + + nativeBuildInputs = with pkgs; [ + cmake + pkg-config + rustPlatform.bindgenHook + ]; + + buildInputs = with pkgs; [ + fontconfig + freetype + openssl + zlib + zstd + alsa-lib + libxkbcommon + wayland + vulkan-loader + libglvnd + libx11 + libxcb + libdrm + libgbm + libxcomposite + libxdamage + libxext + libxfixes + libxrandr + ]; + + cargoVendorDir = craneLib.vendorCargoDeps { + inherit src; + cargoLock = ../../Cargo.lock; + }; + + env = { + ZSTD_SYS_USE_PKG_CONFIG = true; + FONTCONFIG_FILE = pkgs.makeFontsConf { + fontDirectories = [ + ../../assets/fonts/lilex + ../../assets/fonts/ibm-plex-sans + ]; + }; + }; + + doCheck = false; + + stdenv = + let + base = pkgs.llvmPackages.stdenv; + addBinTools = old: { + cc = old.cc.override { + inherit (pkgs.llvmPackages) bintools; + }; + }; + in + lib.pipe base [ + (s: s.override addBinTools) + pkgs.stdenvAdapters.useMoldLinker + ]; + }; + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + in + craneLib.buildPackage ( + lib.recursiveUpdate commonArgs { + inherit cargoArtifacts; + dontUseCmakeConfigure = true; + + installPhase = '' + runHook preInstall + mkdir -p $out/bin + cp target/debug/examples/a11y $out/bin/gpui-a11y-example + runHook postInstall + ''; + + NIX_LDFLAGS = "-rpath ${ + lib.makeLibraryPath [ + pkgs.vulkan-loader + pkgs.wayland + ] + }"; + dontPatchELF = true; + + meta = { + description = "GPUI accessibility (AccessKit) example app"; + platforms = lib.platforms.linux; + }; + } + ); + + atspiTestScript = pkgs.writeTextFile { + name = "a11y-atspi-test"; + text = builtins.readFile ./a11y_atspi_test.py; + destination = "/bin/a11y-atspi-test"; + executable = true; + checkPhase = '' + ${pkgs.python3.interpreter} -m py_compile $target + ''; + }; + + testPython = pkgs.python3.withPackages (ps: [ ps.pyatspi ps.pygobject3 ]); + + giTypelibPath = lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.at-spi2-core + pkgs.glib + pkgs.gtk3 + pkgs.gobject-introspection + ]; +in +pkgs.testers.nixosTest { + name = "gpui-a11y-x11"; + + nodes.machine = + { pkgs, ... }: + { + imports = [ ]; + + # Minimal X11 desktop + services.xserver = { + enable = true; + desktopManager.xfce.enable = true; + displayManager.lightdm.enable = true; + }; + + # Auto-login so the test doesn't need to type a password + services.displayManager.autoLogin = { + enable = true; + user = "user"; + }; + + # AT-SPI2 accessibility bus + services.gnome.at-spi2-core.enable = true; + + # dconf + GSettings schemas required for Orca / AT-SPI + programs.dconf = { + enable = true; + profiles.user.databases = [ + { + settings = { + "org/gnome/desktop/interface".toolkit-accessibility = true; + "org/gnome/desktop/a11y/applications".screen-reader-enabled = true; + }; + } + ]; + }; + + # Environment variables for debugging + environment.variables = { + RUST_BACKTRACE = "1"; + }; + + # Start Orca automatically on login + systemd.user.services.orca = { + description = "Orca screen reader"; + wantedBy = [ "graphical-session.target" ]; + partOf = [ "graphical-session.target" ]; + after = [ "graphical-session.target" ]; + serviceConfig = { + ExecStart = "${pkgs.orca}/bin/orca --debug"; + Restart = "on-failure"; + }; + environment = { + DISPLAY = ":0"; + }; + }; + + # Accessibility tools available in the VM + environment.systemPackages = [ + gpui-a11y-example + atspiTestScript + testPython + pkgs.accerciser + pkgs.gsettings-desktop-schemas + pkgs.orca + pkgs.xdotool + ]; + + # Test user + users.users.user = { + isNormalUser = true; + password = "pass"; + extraGroups = [ "wheel" ]; + }; + + # Give the VM enough resources for a GUI + virtualisation = { + memorySize = 4096; + cores = 2; + qemu.options = [ + "-vga virtio" + ]; + }; + }; + + testScript = '' +machine.wait_for_x() +machine.wait_for_unit("graphical.target") + +# Let the desktop and Orca settle +machine.sleep(5) + +# Launch the a11y example, capturing logs to a file +machine.succeed( + "su - user -c 'DISPLAY=:0 WAYLAND_DISPLAY= RUST_LOG=gpui=info gpui-a11y-example > /tmp/gpui.log 2>&1 &'" +) + +# Wait for the window to appear +machine.wait_until_succeeds("su - user -c 'DISPLAY=:0 xdotool search --name \"GPUI Accessibility Demo\"'", timeout=15) + +# Wait for accessibility activation +machine.wait_until_succeeds("grep -q 'Accessibility activated' /tmp/gpui.log", timeout=15) +machine.log("Accessibility activation confirmed in logs") + +# Give AccessKit time to register on AT-SPI +machine.sleep(3) + +# Run the AT-SPI test script +machine.succeed( + "su - user -c 'DISPLAY=:0 GI_TYPELIB_PATH=${giTypelibPath} ${testPython}/bin/python3 ${atspiTestScript}/bin/a11y-atspi-test'" +) +machine.log("AT-SPI tests passed (first run)") + +# Kill the app, restart Orca, and re-run +machine.execute("pkill -f gpui-a11y-example") +machine.sleep(1) +machine.succeed("su - user -c 'XDG_RUNTIME_DIR=/run/user/1000 systemctl --user restart orca'") +machine.sleep(3) + +# Relaunch the app +machine.succeed( + "su - user -c 'DISPLAY=:0 WAYLAND_DISPLAY= RUST_LOG=gpui=info gpui-a11y-example > /tmp/gpui2.log 2>&1 &'" +) +machine.wait_until_succeeds("su - user -c 'DISPLAY=:0 xdotool search --name \"GPUI Accessibility Demo\"'", timeout=15) +machine.wait_until_succeeds("grep -q 'Accessibility activated' /tmp/gpui2.log", timeout=15) +machine.log("Accessibility activation confirmed after Orca restart") +machine.sleep(3) + +# Run the AT-SPI test script again +machine.succeed( + "su - user -c 'DISPLAY=:0 GI_TYPELIB_PATH=${giTypelibPath} ${testPython}/bin/python3 ${atspiTestScript}/bin/a11y-atspi-test'" +) +machine.log("AT-SPI tests passed (second run, after Orca restart)") + ''; +} diff --git a/nix/tests/a11y_atspi_test.py b/nix/tests/a11y_atspi_test.py new file mode 100644 index 00000000000..b9caf59a1db --- /dev/null +++ b/nix/tests/a11y_atspi_test.py @@ -0,0 +1,205 @@ +"""AT-SPI integration test for the GPUI a11y example app. + +Walks the AT-SPI tree, finds the GPUI app, and exercises the counter +(spin button with increment/decrement), reset button, and toggle switch +— asserting accessible state after each interaction. +""" + +import sys +import time +import pyatspi + + +def find_app(): + """Find the GPUI a11y example in the AT-SPI desktop.""" + desktop = pyatspi.Registry.getDesktop(0) + for app in desktop: + if "gpui" in app.name.lower() or "a11y" in app.name.lower(): + return app + names = [a.name for a in desktop] + raise AssertionError(f"GPUI app not found in AT-SPI desktop. Apps: {names}") + + +def find_by_role_and_label(root, role, label_substring): + """Depth-first search for a node matching role and label substring.""" + for child in root: + if child.getRole() == role and label_substring in (child.name or ""): + return child + result = find_by_role_and_label(child, role, label_substring) + if result is not None: + return result + return None + + +def find_by_role(root, role): + """Depth-first search for all nodes matching role.""" + results = [] + for child in root: + if child.getRole() == role: + results.append(child) + results.extend(find_by_role(child, role)) + return results + + +def do_action_by_name(node, action_name): + """Perform a named action on a node.""" + actions = node.queryAction() + for i in range(actions.nActions): + if actions.getName(i).lower() == action_name.lower(): + actions.doAction(i) + time.sleep(0.5) + return + available = [actions.getName(i) for i in range(actions.nActions)] + raise AssertionError( + f"No '{action_name}' action on node: {node.name} " + f"(role={node.getRoleName()}). Available: {available}" + ) + + +def click(node): + """Perform the Click action on a node.""" + do_action_by_name(node, "click") + + +def get_toggled_state(node): + """Return whether the node is in a 'checked'/'pressed' state.""" + state_set = node.getState() + return state_set.contains(pyatspi.STATE_CHECKED) or state_set.contains(pyatspi.STATE_PRESSED) + + +def get_counter(app): + counter = find_by_role_and_label(app, pyatspi.ROLE_SPIN_BUTTON, "Counter:") + assert counter is not None, "Counter (spin button) not found" + return counter + + +def get_reset_button(app): + button = find_by_role_and_label(app, pyatspi.ROLE_PUSH_BUTTON, "Reset counter") + assert button is not None, "Reset button not found" + return button + + +def get_toggle_switch(app): + switches = find_by_role(app, pyatspi.ROLE_TOGGLE_BUTTON) + if not switches: + raise AssertionError( + f"No toggle switch found. Roles present: " + f"{[(c.getRoleName(), c.name) for c in find_by_role(app, None) if True]}" + ) + switch = None + for s in switches: + if "feature" in (s.name or "").lower() or "enable" in (s.name or "").lower(): + switch = s + break + if switch is None: + switch = switches[0] + return switch + + +def assert_count(app, expected): + """Assert the counter's label contains the expected count.""" + counter = get_counter(app) + expected_str = f"Counter: {expected}" + assert expected_str in counter.name, ( + f"Expected label to contain '{expected_str}', got: '{counter.name}'" + ) + print(f" OK: count is {expected}") + + +def get_numeric_value(node): + """Get the current numeric value from the AT-SPI Value interface.""" + value = node.queryValue() + return value.currentValue + + +def run_tests(): + print("Finding GPUI app in AT-SPI tree...") + app = find_app() + print(f"Found app: {app.name}") + + # --- Counter (spin button) --- + print("\n--- Counter spin button tests ---") + + print("Checking initial count is 0...") + assert_count(app, 0) + + # Verify the Value interface reports 0 + counter = get_counter(app) + val = get_numeric_value(counter) + print(f" Value interface reports: {val}") + assert val == 0.0, f"Expected numeric value 0.0, got {val}" + print(" OK: numeric value is 0") + + # Test click (increments) + for i in range(1, 4): + print(f"Clicking counter (expecting {i})...") + counter = get_counter(app) + click(counter) + assert_count(app, i) + + # Verify the Value interface tracks the count + counter = get_counter(app) + val = get_numeric_value(counter) + assert val == 3.0, f"Expected numeric value 3.0, got {val}" + print(" OK: numeric value is 3 after 3 clicks") + + # List available actions for diagnostics + counter = get_counter(app) + actions = counter.queryAction() + available = [actions.getName(i) for i in range(actions.nActions)] + print(f" Available actions on counter: {available}") + + # Test reset button + print("Clicking reset...") + reset = get_reset_button(app) + click(reset) + assert_count(app, 0) + + # --- Toggle switch --- + print("\n--- Toggle switch tests ---") + + switch = get_toggle_switch(app) + print(f"Switch: role={switch.getRoleName()}, name={switch.name}") + + toggled = get_toggled_state(switch) + print(f"Initial toggle state: {toggled}") + assert not toggled, f"Expected switch to be OFF initially, got {toggled}" + print(" OK: switch is OFF") + + print("Toggling switch ON...") + click(switch) + switch = get_toggle_switch(app) + toggled = get_toggled_state(switch) + assert toggled, f"Expected switch to be ON after toggle, got {toggled}" + print(" OK: switch is ON") + + print("Toggling switch OFF...") + click(switch) + switch = get_toggle_switch(app) + toggled = get_toggled_state(switch) + assert not toggled, f"Expected switch to be OFF after second toggle, got {toggled}" + print(" OK: switch is OFF") + + # --- Window bounds / Component extents --- + print("\n--- Component extents tests ---") + + counter = get_counter(app) + component = counter.queryComponent() + extents = component.getExtents(pyatspi.DESKTOP_COORDS) + print(f" Counter extents (desktop coords): x={extents.x}, y={extents.y}, " + f"width={extents.width}, height={extents.height}") + assert extents.width > 0 and extents.height > 0, ( + f"Expected non-zero extents from Component interface, got {extents}. " + f"This likely means a11y_update_window_bounds is not reporting bounds." + ) + print(" OK: counter has non-zero extents") + + print("\n=== ALL TESTS PASSED ===") + + +if __name__ == "__main__": + try: + run_tests() + except Exception as e: + print(f"\nFAILED: {e}", file=sys.stderr) + sys.exit(1) diff --git a/script/bundle-linux b/script/bundle-linux index 3487feaf32b..b510365acb4 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -151,10 +151,12 @@ cp "${target_dir}/${target_triple}/release/zed" "${zed_dir}/libexec/zed-editor" cp "${target_dir}/${target_triple}/release/cli" "${zed_dir}/bin/zed" # Libs +# Bundle libstdc++ so older supported systems can run binaries built with our +# toolchain even when their system libstdc++.so.6 lacks required GLIBCXX symbols. find_libs() { ldd ${target_dir}/${target_triple}/release/zed |\ cut -d' ' -f3 |\ - grep -v '\<\(libstdc++.so\|libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\|libasound.so\)' + grep -v '\<\(libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\|libasound.so\)' } mkdir -p "${zed_dir}/lib" diff --git a/script/check-licenses b/script/check-licenses index 61db9c15d52..02f893709fd 100755 --- a/script/check-licenses +++ b/script/check-licenses @@ -2,8 +2,33 @@ set -euo pipefail -AGPL_CRATES=("collab") -RELEASE_CRATES=("cli" "remote_server" "zed") +check_manifest_for_agpl () { + local cargo_toml="$1" + + if grep -Eiq '(agpl|affero)' "$cargo_toml"; then + echo "Error: $cargo_toml references an AGPL license. First-party crates must use LICENSE-GPL or LICENSE-APACHE." + exit 1 + fi + + if grep -Eiq '^[[:space:]]*license-file[[:space:]]*=' "$cargo_toml"; then + echo "Error: $cargo_toml uses license-file. First-party crates must declare LICENSE-GPL or LICENSE-APACHE via the license field and symlink." + exit 1 + fi +} + +check_no_agpl_license_file () { + if [[ -e "LICENSE-AGPL" || -L "LICENSE-AGPL" ]]; then + echo "Error: LICENSE-AGPL exists. First-party code must use LICENSE-GPL or LICENSE-APACHE." + exit 1 + fi + + while IFS= read -r -d '' license_file; do + if [[ -e "$license_file" || -L "$license_file" ]]; then + echo "Error: $license_file exists. First-party crates must use LICENSE-GPL or LICENSE-APACHE." + exit 1 + fi + done < <(git ls-files -z -- "*/LICENSE-AGPL") +} check_symlink_target () { local symlink_path="$1" @@ -32,21 +57,7 @@ check_symlink_target () { check_license () { local dir="$1" - local allowed_licenses=() - - local is_agpl=false - for agpl_crate in "${AGPL_CRATES[@]}"; do - if [[ "$dir" == "crates/$agpl_crate" ]]; then - is_agpl=true - break - fi - done - - if [[ "$is_agpl" == true ]]; then - allowed_licenses=("LICENSE-AGPL") - else - allowed_licenses=("LICENSE-GPL" "LICENSE-APACHE") - fi + local allowed_licenses=("LICENSE-GPL" "LICENSE-APACHE") for license in "${allowed_licenses[@]}"; do if [[ -L "$dir/$license" ]]; then @@ -58,31 +69,18 @@ check_license () { fi done - if [[ "$is_agpl" == true ]]; then - echo "Error: $dir does not contain a LICENSE-AGPL symlink" - else - echo "Error: $dir does not contain a LICENSE-GPL or LICENSE-APACHE symlink" - fi + echo "Error: $dir does not contain a LICENSE-GPL or LICENSE-APACHE symlink" exit 1 } -git ls-files "**/*/Cargo.toml" | while read -r cargo_toml; do - check_license "$(dirname "$cargo_toml")" -done +check_no_agpl_license_file +git ls-files -z -- "Cargo.toml" "**/Cargo.toml" | while IFS= read -r -d '' cargo_toml; do + check_manifest_for_agpl "$cargo_toml" -# Make sure the AGPL server crates are included in the release tarball. -for release_crate in "${RELEASE_CRATES[@]}"; do - tree_output=$(cargo tree --package "$release_crate") - for agpl_crate in "${AGPL_CRATES[@]}"; do - # Look for lines that contain the crate name followed by " v" (version) - # This matches patterns like "├── collab v0.44.0" - if echo "$tree_output" | grep -E "(^|[^a-zA-Z_])${agpl_crate} v" > /dev/null; then - echo "Error: crate '${agpl_crate}' is AGPL and is a dependency of crate '${release_crate}'." >&2 - echo "AGPL licensed code should not be used in the release distribution, only in servers." >&2 - exit 1 - fi - done + if [[ "$cargo_toml" == */Cargo.toml ]]; then + check_license "$(dirname "$cargo_toml")" + fi done echo "check-licenses succeeded" diff --git a/script/community-pr-track-mapping.json b/script/community-pr-track-mapping.json index 3b3bfe6168d..274c65d403d 100644 --- a/script/community-pr-track-mapping.json +++ b/script/community-pr-track-mapping.json @@ -27,7 +27,7 @@ }, { "name": "Markdown Preview", - "labels": ["area:preview/markdown", "area:preview/mermaid"] + "labels": ["area:preview/markdown", "area:preview/mermaid", "area:preview/csv"] }, { "name": "NixOS", @@ -88,9 +88,11 @@ "labels": [ "area:command palette", "area:file finder", + "area:fs", "area:navigation", "area:outline", "area:project panel", + "area:scanning", "area:workspace" ] }, @@ -100,6 +102,7 @@ "area:code folding", "area:editor", "area:editor/brackets", + "area:editor/bookmarks", "area:editor/linked edits", "area:multi-buffer", "area:multi-cursor", @@ -135,6 +138,7 @@ "area:ai", "area:ai/acp", "area:ai/agent thread", + "area:ai/agent thread/skills", "area:ai/anthropic", "area:ai/assistant", "area:ai/bedrock", @@ -154,6 +158,7 @@ "area:ai/opencode", "area:ai/openrouter", "area:ai/qwen", + "area:ai/terminal threads", "area:ai/text thread" ] }, @@ -222,6 +227,7 @@ "name": "Performance & Catch-all", "labels": [ "area:cli", + "area:crashes", "area:discoverability", "area:installer-updater", "area:internationalization", @@ -236,6 +242,7 @@ "area:performance", "area:performance/memory leak", "area:release notes", + "area:scripts", "area:security & privacy", "area:security & privacy/workspace trust", "area:serialization", diff --git a/script/github-check-new-issue-for-duplicates.py b/script/github-check-new-issue-for-duplicates.py index 245d9aaa9a8..8a4eb8ec22a 100644 --- a/script/github-check-new-issue-for-duplicates.py +++ b/script/github-check-new-issue-for-duplicates.py @@ -1,15 +1,17 @@ #!/usr/bin/env python3 """ -Comment on newly opened issues that might be duplicates of an existing issue. +Comment on newly opened issues with possible duplicates and triage hints. -This script is run by a GitHub Actions workflow when a new bug or crash report -is opened. It: -1. Checks eligibility (must be bug/crash type, non-staff author) +This script is run by a GitHub Actions workflow when a new issue is opened. It: +1. Checks eligibility (bug/crash type or untyped, non-staff author) 2. Detects relevant areas using Claude + the area label taxonomy 3. Parses known "duplicate magnets" from tracking issue #46355 -4. Searches for similar recent issues by title keywords, area labels, and error patterns -5. Asks Claude to analyze potential duplicates (magnets + search results) -6. Posts a comment on the issue if high-confidence duplicates are found +4. Searches for similar issues — open (last 60 days) and recently closed (last 30 days) +5. Asks Claude to sort open candidates into likely and possible duplicates, and + surface recently closed issues that may be useful triage context +6. Posts a comment if anything is found: a user-facing duplicate alert for likely + duplicates, and/or a collapsed triager-facing section for possible duplicates + and recently closed related issues Requires: requests (pip install requests) @@ -28,6 +30,7 @@ import json import os import re import sys +import time from datetime import datetime, timedelta import requests @@ -48,6 +51,9 @@ STOPWORDS = { "the", "this", "when", "while", "with", "won't", "work", "working", "zed", } +# HTTP statuses we'll retry on for GET requests +TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504} + def log(message): """Print to stderr so it doesn't interfere with JSON output on stdout.""" @@ -55,11 +61,22 @@ def log(message): def github_api_get(path, params=None): - """Fetch JSON from the GitHub API. Raises on non-2xx status.""" + """Fetch JSON from the GitHub API, retrying transient failures. Raises on non-2xx status.""" url = f"{GITHUB_API}/{path.lstrip('/')}" - response = requests.get(url, headers=GITHUB_HEADERS, params=params) - response.raise_for_status() - return response.json() + for attempt in range(3): + try: + response = requests.get(url, headers=GITHUB_HEADERS, params=params) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or ( + isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES + ) + if not transient or attempt == 2: + raise + wait = 2 ** attempt + log(f" Transient GitHub API error ({e}); retrying in {wait}s") + time.sleep(wait) def github_search_issues(query, per_page=15): @@ -86,17 +103,23 @@ def post_comment(issue_number: int, body): log(f" Posted comment on #{issue_number}") -def build_duplicate_comment(matches): - """Build the comment body for potential duplicates.""" - match_list = "\n".join(f"- #{m['number']}" for m in matches) - explanations = "\n\n".join( - f"**#{m['number']}:** {m['explanation']}\n\n**Shared root cause:** {m['shared_root_cause']}" - if m.get('shared_root_cause') - else f"**#{m['number']}:** {m['explanation']}" - for m in matches - ) +def build_comment(likely_duplicates, possible_duplicates, related_closed_issues): + """Compose the full comment body. Returns empty string if there's nothing to post. - return f"""This issue appears to be a duplicate of: + The comment has two sections, each optional: + - User-facing duplicate alert, rendered when likely_duplicates is non-empty. + - Collapsed triage context, rendered when there are possible duplicates or + related closed issues to surface for triagers. + """ + sections = [] + + if likely_duplicates: + match_list = "\n".join(f"- #{m['number']}" for m in likely_duplicates) + explanations = "\n\n".join( + f"**#{m['number']}:** {m['explanation']}\n\n**Shared root cause:** {m['shared_root_cause']}" + for m in likely_duplicates + ) + sections.append(f"""This issue appears to be a duplicate of: {match_list} @@ -111,10 +134,40 @@ No action needed. A maintainer will review this shortly. {explanations} - +""") ---- -This is an automated analysis and might be incorrect.""" + if possible_duplicates or related_closed_issues: + parts = [] + if possible_duplicates: + lines = [ + f"- #{m['number']} — {m['explanation']}\n" + f" - Possible shared root cause: {m['shared_root_cause']}" + for m in possible_duplicates + ] + parts.append("**Possibly related open issues:**\n\n" + "\n".join(lines)) + if related_closed_issues: + # state_reason is shown only for "duplicate" (the close type is otherwise + # already visible from GitHub's icon next to the issue number on render). + lines = [ + f"- #{m['number']}" + f"{' (closed as duplicate)' if m['state_reason'] == 'duplicate' else ''}" + f" — {m['explanation']}" + for m in related_closed_issues + ] + parts.append("**Recently closed, possibly the same bug:**\n\n" + "\n".join(lines)) + body = "\n\n".join(parts) + sections.append(f"""
+Additional recent context for triagers + +{body} + +
""") + + if not sections: + return "" + + sections.append("---\nThis is an automated analysis and might be incorrect.") + return "\n\n".join(sections) def call_claude(api_key, system, user_content, max_tokens=1024): @@ -165,8 +218,8 @@ def fetch_issue(issue_number: int): def should_skip(issue): """Check if issue should be skipped in duplicate detection process.""" - if issue["type"] not in ["Bug", "Crash"]: - log(f" Skipping: issue type '{issue['type']}' is not a bug/crash report") + if issue["type"] and issue["type"] not in ["Bug", "Crash"]: + log(f" Skipping: issue type '{issue['type']}' is not blank and not a bug/crash report") return True if issue["author"] and check_team_membership(REPO_OWNER, STAFF_TEAM_SLUG, issue["author"]): @@ -218,23 +271,38 @@ def format_taxonomy_for_claude(area_labels): return "\n".join(sorted(lines)) -def detect_areas(anthropic_key, issue, taxonomy): - """Use Claude to detect relevant areas for the issue.""" +def detect_areas(anthropic_key, issue, area_labels): + """Use Claude to detect which area labels apply to the issue. + + Claude may ignore the format instruction or hallucinate names, so the response + is validated against the canonical set of area labels. + """ log("Detecting areas with Claude") + taxonomy = format_taxonomy_for_claude(area_labels) + valid_areas = {label["name"] for label in area_labels} + system_prompt = """You analyze GitHub issues to identify which area labels apply. -Given an issue and a taxonomy of areas, output ONLY a comma-separated list of matching area names. +Decide the area from the user's stated symptom and reproduction steps. Issue bodies routinely +contain pasted log output, crash dumps, stack traces, settings files, and template headers like +"Attach Zed log file" or "Relevant Zed settings" — these are evidence about the symptom and +should not push you toward labels like "logging" or "settings" unless the bug itself is about +how that subsystem works. + +Respond with ONLY a comma-separated list of matching area names. No prose, no explanation, +no markdown, no preamble — just the names. + - Output at most 3 areas, ranked by relevance - Use exact area names from the taxonomy -- If no areas clearly match, output: none +- If no areas clearly match, respond with: none - For languages/*, tooling/*, or parity/*, use the specific sub-label (e.g., "languages/rust", -tooling/eslint, parity/vscode) + tooling/eslint, parity/vscode) -Example outputs: -- "editor, parity/vim" -- "ai, ai/agent panel" -- "none" +Examples of valid responses (each line is a complete response on its own): + editor, parity/vim + ai, ai/agent panel + none """ user_content = f"""## Area Taxonomy @@ -251,7 +319,14 @@ Example outputs: if response.lower() == "none": return [] - return [area.strip() for area in response.split(",")] + + valid, dropped = [], [] + for area in response.split(","): + area = area.strip() + (valid if area in valid_areas else dropped).append(area) + if dropped: + log(f" Dropped {len(dropped)} unknown area(s) from Claude response: {dropped}") + return valid def parse_duplicate_magnets(): @@ -344,53 +419,76 @@ def filter_magnets_by_areas(magnets, detected_areas): return list(filter(matches, magnets)) -def search_for_similar_issues(issue, detected_areas, max_searches=6): - """Search for similar issues that might be duplicates. +def search_for_similar_issues(issue, detected_areas, max_searches_per_state=6): + """Search for similar issues — both open and recently closed. - Searches by title keywords, area labels (last 60 days), and error patterns. - max_searches caps the total number of queries to keep token usage and context size under control. + Runs two passes: + - Open issues: title keywords / error pattern unrestricted, area searches last 60 days. + - Closed issues: closed within the last 30 days (across all query types). + + max_searches_per_state caps queries per state to keep token usage and context size bounded. """ log("Searching for similar issues") sixty_days_ago = (datetime.now() - timedelta(days=60)).strftime("%Y-%m-%d") - base_query = f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open" - seen_issues = {} - queries = [] + thirty_days_ago = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") title_keywords = [word for word in issue["title"].split() if word.lower() not in STOPWORDS and len(word) > 2] - - if title_keywords: - keywords_query = " ".join(title_keywords) - queries.append(("title_keywords", f"{base_query} {keywords_query}")) - - for area in detected_areas: - queries.append(("area_label", f'{base_query} label:"area:{area}" created:>{sixty_days_ago}')) + keywords_query = " ".join(title_keywords) if title_keywords else None # error pattern search: capture 5–90 chars after keyword, colon optional error_pattern = r"(?i:\b(?:error|panicked|panic|failed)\b)\s*([^\n]{5,90})" - match = re.search(error_pattern, issue["body"]) - if match: - error_snippet = match.group(1).strip() - queries.append(("error_pattern", f'{base_query} in:body "{error_snippet}"')) + error_match = re.search(error_pattern, issue["body"]) + error_snippet = error_match.group(1).strip() if error_match else None - for search_type, query in queries[:max_searches]: - log(f" Search ({search_type}): {query}") - try: - results = github_search_issues(query, per_page=15) - for item in results: - number = item["number"] - if number != issue["number"] and number not in seen_issues: - body = item.get("body") or "" - seen_issues[number] = { - "number": number, - "title": item["title"], - "state": item.get("state", ""), - "created_at": item.get("created_at", ""), - "body_preview": body[:1000], - "source": search_type, - } - except requests.RequestException as e: - log(f" Search failed: {e}") + def build_queries(base, area_window=None): + queries = [] + if keywords_query: + queries.append(("title_keywords", f"{base} {keywords_query}")) + for area in detected_areas: + area_q = f'{base} label:"area:{area}"' + if area_window: + area_q += f" created:>{area_window}" + queries.append(("area_label", area_q)) + if error_snippet: + queries.append(("error_pattern", f'{base} in:body "{error_snippet}"')) + return queries + + open_queries = build_queries( + f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open", + area_window=sixty_days_ago, + ) + # closed pass: filter by close date so we catch issues closed recently regardless of + # when they were opened. closed:> already restricts the result set, so the per-query + # area window is unnecessary. + closed_queries = build_queries( + f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:closed closed:>{thirty_days_ago}", + ) + + seen_issues = {} + for state_label, queries in ( + ("open", open_queries[:max_searches_per_state]), + ("closed", closed_queries[:max_searches_per_state]), + ): + for search_type, query in queries: + log(f" Search ({state_label} / {search_type}): {query}") + try: + results = github_search_issues(query, per_page=15) + for item in results: + number = item["number"] + if number != issue["number"] and number not in seen_issues: + body = item.get("body") or "" + seen_issues[number] = { + "number": number, + "title": item["title"], + "state": item.get("state", ""), + "state_reason": item.get("state_reason"), + "created_at": item.get("created_at", ""), + "body_preview": body[:1000], + "source": search_type, + } + except requests.RequestException as e: + log(f" Search failed: {e}") similar_issues = list(seen_issues.values()) log(f" Found {len(similar_issues)} similar issues") @@ -398,29 +496,47 @@ def search_for_similar_issues(issue, detected_areas, max_searches=6): def analyze_duplicates(anthropic_key, issue, magnets, search_results): - """Use Claude to analyze potential duplicates.""" - log("Analyzing duplicates with Claude") + """Use Claude to identify duplicates (open) and surface related closed issues. + Returns (likely_duplicates, possible_duplicates, related_closed_issues). + """ top_magnets = magnets[:10] - enrich_magnets(top_magnets) magnet_numbers = {m["number"] for m in top_magnets} + open_results = [r for r in search_results if r["state"] == "open" and r["number"] not in magnet_numbers] + closed_results = [r for r in search_results if r["state"] == "closed" and r["number"] not in magnet_numbers] + + if not top_magnets and not open_results and not closed_results: + return [], [], [] + + log("Analyzing candidates with Claude") + log(f" Candidate pool: {len(top_magnets)} magnets, {len(open_results)} open search results, " + f"{len(closed_results)} closed search results (will pass {min(len(closed_results), 5)} closed)") + enrich_magnets(top_magnets) + + closed_candidates_for_claude = closed_results[:5] + if closed_candidates_for_claude: + log(f" Closed candidates given to proposer: {[r['number'] for r in closed_candidates_for_claude]}") + candidates = [ - {"number": m["number"], "title": m["title"], "body_preview": m["body_preview"], "source": "known_duplicate_magnet"} + {"number": m["number"], "title": m["title"], "body_preview": m["body_preview"], + "state": "open", "state_reason": None, "source": "known_duplicate_magnet"} for m in top_magnets ] + [ - {"number": r["number"], "title": r["title"], "body_preview": r["body_preview"], "source": "search_result"} - for r in search_results[:10] - if r["number"] not in magnet_numbers + {"number": r["number"], "title": r["title"], "body_preview": r["body_preview"], + "state": r["state"], "state_reason": r["state_reason"], "source": "search_result"} + for r in open_results[:10] + closed_candidates_for_claude ] - if not candidates: - return [], "No candidates to analyze" + system_prompt = """You analyze GitHub issues to (a) identify duplicates among OPEN candidates +and (b) surface recently CLOSED candidates that are useful triage context. - system_prompt = """You analyze GitHub issues to identify potential duplicates. +Each candidate has a "state" field ("open" or "closed"), and closed candidates carry a +"state_reason" ("completed", "not_planned", or "duplicate"). -Given a new issue and a list of existing issues, identify which existing issues are duplicates — meaning -they are caused by the SAME BUG in the code, not just similar symptoms. +# (a) Duplicates — OPEN candidates only + +A duplicate means: caused by the SAME BUG in the code, not just similar symptoms. CRITICAL DISTINCTION — shared symptoms vs shared root cause: - "models missing", "can't sign in", "editor hangs", "venv not detected" are SYMPTOMS that many @@ -428,13 +544,14 @@ CRITICAL DISTINCTION — shared symptoms vs shared root cause: identify a specific shared root cause. - A duplicate means: if a developer fixed the existing issue, the new issue would also be fixed. - If the issues just happen to be in the same feature area, or describe similar-sounding problems - with different specifics (different error messages, different triggers, different platforms, different - configurations), they are NOT duplicates. + with different specifics (different error messages, different triggers, different platforms, + different configurations), they are NOT duplicates. -For each potential duplicate, assess confidence: -- "high": Almost certainly the same bug. You can name a specific shared root cause, and the - reproduction steps / error messages / triggers are consistent. -- "medium": Likely the same bug based on specific technical details, but some uncertainty remains. +Sort duplicates into two buckets: +- "likely_duplicates": Almost certainly the same bug. You can name a specific shared root cause, and + the reproduction steps / error messages / triggers are consistent. +- "possible_duplicates": Likely the same bug based on specific technical details, but some + uncertainty remains. - Do NOT include issues that merely share symptoms, affect the same feature area, or sound similar at a surface level. @@ -444,24 +561,94 @@ Examples of things that are NOT duplicates: - Two issues about "Zed hangs" — one triggered by network drives, the other by large projects. - Two issues about "can't sign in" — one caused by a missing system package, the other by a server-side error. -Output only valid JSON (no markdown code blocks) with this structure: +For OPEN duplicates (either bucket), false positives are MUCH worse than false negatives — they +waste the time of both the issue author and the maintainers. When in doubt, omit. + +# (b) Closed candidates that may be the same bug — CLOSED candidates only + +The goal is NOT a "related reading" list. The goal is to surface closed issues where the +new issue is plausibly the SAME bug — a duplicate that just happens to be filed against a +closed predecessor instead of an open one. Empty is preferable to weak filler — triagers +lose trust in this section quickly if it's stretched. The same false-positives-are-worse +asymmetry as for duplicates applies here. + +The bar: a triager reading this should be able to act — ask the reporter to retest a fix, +point at a known design decision that already declined this request, or point at the +canonical bug this is a duplicate of. "Useful context" or "shared area" is NOT a reason +to include. + +Omit a candidate if ANY of these apply (in observed practice, almost everything does): + +1. Self-contradiction. If you find yourself writing "while focused on X rather than Y", + "although this is about A, the new issue is about B", "this issue focuses on... rather + than...", or any acknowledgment that the candidate isn't on the same topic — STOP. + You've already made the case for omitting it. + +2. Fabricated specifics. Every concrete claim about the candidate (its trigger, its scope, + its conditions) must be visible in the candidate's title or body preview. Specifics + like "when X happens", "under Y conditions", "specifically affecting Z" that aren't + supported by the candidate's actual text mean you're inventing details to fit the new + issue. Omit. + +3. Weasel phrases. Paraphrases of these all indicate you don't have a real claim: + "may indicate similar...", "could provide context for...", "shows / demonstrates recent + attention to...", "indicates the team has considered...", "demonstrates a pattern + of...", "may provide useful context...". STOP and omit. + +4. Retest by default. The "reporter may need to retest on the latest build" framing only + applies when the candidate's symptom is literally the same as the new issue's. It is + NOT a default justification for "this was a recent fix in roughly the same area." + +5. Same area / feature, different mechanism. Examples to omit: + - "ARM compile failure" alongside "ARM runtime perf" — same area, different mechanism. + - "Worktree path bug" alongside "worktree display label confusion" — same feature, + unrelated. + +6. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent + panel UX" that could be cited next to almost any new bug is filler. If you'd reuse the + same closed issue across many unrelated new issues, omit. + +7. Label or single-keyword overlap. A closed issue whose only connection is a shared + area:* label or one shared keyword is not relevant. + +Worth surfacing — strict examples: +- A recently fixed ("completed") issue with the SAME specific trigger as the new issue — + triager can ask the reporter to retest on the latest build. +- A cluster of "not_planned" closures about the EXACT same request — known design choice + the triager can point to. +- A previously triaged "duplicate" pointing at the same canonical issue, or sharing the + same specific mechanism. + +Count: typically 0 or 1. Never more than 2 unless there's an obvious cluster of identical +"not_planned" reports. 0 is a normal outcome. + +# Output format + +Output only valid JSON (no markdown code blocks): { - "matches": [ + "likely_duplicates": [ { "number": 12345, - "confidence": "high|medium", "shared_root_cause": "The specific bug/root cause shared by both issues", "explanation": "Brief explanation with concrete evidence from both issues" } ], - "summary": "One sentence summary of findings" + "possible_duplicates": [ + { + "number": 12345, + "shared_root_cause": "The specific bug/root cause shared by both issues", + "explanation": "Brief explanation with concrete evidence from both issues" + } + ], + "related_closed_issues": [ + { + "number": 12345, + "explanation": "Brief explanation of why this is useful triage context" + } + ] } -When in doubt, return an empty matches array. A false positive (flagging a non-duplicate) is much -worse than a false negative (missing a real duplicate), because it wastes the time of both the -issue author and the maintainers. - -Return empty matches array if none found or if you can only identify shared symptoms.""" +Return empty arrays where nothing relevant is found.""" user_content = f"""## New Issue #{issue['number']} **Title:** {issue['title']} @@ -474,17 +661,177 @@ Return empty matches array if none found or if you can only identify shared symp response = call_claude(anthropic_key, system_prompt, user_content, max_tokens=2048) + # Claude sometimes wraps JSON in a ```json ... ``` fence despite the prompt forbidding it + fence = re.match(r"^\s*```(?:json)?\s*\n?(.*?)\n?```\s*$", response, re.DOTALL) + if fence: + response = fence.group(1) + try: data = json.loads(response) except json.JSONDecodeError as e: - log(f" Failed to parse response: {e}") - log(f" Raw response: {response}") - return [], "Failed to parse analysis" + log(f" Failed to parse Claude response as JSON: {e}") + log(f" Raw response:\n{response}") + sys.exit(1) - matches = data.get("matches", []) - summary = data.get("summary", "Analysis complete") - log(f" Found {len(matches)} potential matches") - return matches, summary + likely = data.get("likely_duplicates", []) + possible = data.get("possible_duplicates", []) + closed = data.get("related_closed_issues", []) + + # Claude occasionally places a closed candidate in the duplicate buckets, or vice + # versa. Enforce that each match lives in the bucket consistent with the canonical + # state of the candidate we passed in. + candidate_states = {c["number"]: c["state"] for c in candidates} + + def filter_by_state(items, expected_state, label): + kept, wrong = [], [] + for m in items: + (kept if candidate_states.get(m["number"]) == expected_state else wrong).append(m) + if wrong: + log(f" Dropped {len(wrong)} from {label} with wrong/unknown state: {[m['number'] for m in wrong]}") + return kept + + likely = filter_by_state(likely, "open", "likely_duplicates") + possible = filter_by_state(possible, "open", "possible_duplicates") + closed = filter_by_state(closed, "closed", "related_closed_issues") + + # Avoid showing the same issue in both the user-facing alert and the triage section. + likely_numbers = {m["number"] for m in likely} + overlap = [m["number"] for m in possible if m["number"] in likely_numbers] + if overlap: + log(f" Dropped {len(overlap)} from possible_duplicates already in likely_duplicates: {overlap}") + possible = [m for m in possible if m["number"] not in likely_numbers] + + log(f" Found {len(likely) + len(possible) + len(closed)} potential matches") + return likely, possible, closed + + +CRITIQUE_SYSTEM_PROMPT = """You are evaluating ONE recently closed GitHub issue to decide whether a triager looking +at a brand-new bug report would find it useful to be told about that closed issue. + +There is no slate to fill. There is no quota. You will be shown exactly one candidate. +The default verdict is OMIT. Zero is the expected outcome for most candidates. + +A candidate is worth surfacing ONLY if the new issue is plausibly the SAME BUG as the +closed one — a duplicate that happens to be filed against a closed predecessor. Concretely, +the legitimate cases are exactly three: + +- The candidate was closed as "completed" (a fix shipped) AND the new issue has the same + specific trigger / symptom. The triager will ask the reporter to retest. +- The candidate was closed as "not_planned" AND the new issue is the EXACT same request + (a feature decision the team already declined). The triager will point at it. +- The candidate was closed as "duplicate" AND it pointed at the same canonical bug the new + issue describes, or it shares the same specific mechanism. + +"Same broad area", "similar-sounding symptom", or "recent attention to this subsystem" are +NOT reasons to include. Omit them. + +Return "omit" if ANY of the following apply (in observed practice, almost everything does): + +1. Self-contradiction. If your reasoning includes "while focused on X rather than Y", + "although this is about A, the new issue is about B", "this issue focuses on... rather + than...", or any acknowledgment the candidate is on a different topic — you've already + decided to omit. +2. Fabricated specifics. Every concrete claim about the candidate (its trigger, scope, + conditions) must be visible in the candidate's title or body preview. If you find + yourself describing the candidate using details that aren't in its text, you're + inventing details to fit the new issue. Omit. +3. Weasel phrases. Paraphrases of "may indicate similar...", "could provide context + for...", "shows / demonstrates recent attention to...", "indicates the team has + considered...", "demonstrates a pattern of...", "may provide useful context..." — + these mean you don't have a real claim. Omit. +4. Retest by default. The "reporter may need to retest on the latest build" framing only + applies when the closed issue's symptom is LITERALLY the same as the new issue's. "This + was a recent fix in roughly the same area" is not enough. +5. Same area / feature, different mechanism. Same area label but different bug, different + code path, different trigger. Omit. +6. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent + panel UX" that you could cite next to many unrelated new bugs. Omit. +7. Label or single-keyword overlap. Only connection is a shared area:* label or one shared + keyword. Omit. + +Output only valid JSON (no markdown code blocks): +{ + "verdict": "include" | "omit", + "rule_violated": null | 1 | 2 | 3 | 4 | 5 | 6 | 7, + "rationale": "one concise sentence explaining the verdict" +} + +When "verdict" is "include", "rule_violated" must be null. +When "verdict" is "omit", "rule_violated" should be the most relevant rule number, or null +if the candidate is simply too unrelated for any rule to specifically apply.""" + + +def critique_closed_candidates(anthropic_key, issue, proposed, search_results): + """Run a strict per-candidate critique pass over the proposer's closed candidates. + + For each proposed match, call Claude with only the new issue and that single candidate + (blind to the proposer's rationale) and ask for a yes/no verdict. Default is omit. + Returns the subset of `proposed` that passes critique. + """ + if not proposed: + log(" Critique: proposer surfaced 0 closed candidates; skipping") + return [] + + log(f" Critique: proposer surfaced {len(proposed)} closed candidate(s): " + f"{[m['number'] for m in proposed]}") + + results_by_number = {r["number"]: r for r in search_results} + kept = [] + for match in proposed: + number = match["number"] + candidate = results_by_number.get(number) + if candidate is None: + # Should not happen — analyze_duplicates only emits numbers from candidates it + # was given — but be defensive rather than crash the bot. + log(f" Critique: dropping #{number} — candidate context not found") + continue + + state_reason = candidate.get("state_reason") or "unknown" + user_content = f"""## New Issue #{issue['number']} +**Title:** {issue['title']} + +**Body:** +{issue['body'][:3000]} + +## Closed Candidate #{number} +**Title:** {candidate.get('title', '')} +**State reason:** {state_reason} + +**Body preview:** +{candidate.get('body_preview', '')}""" + + log(f" Critique: evaluating #{number}") + try: + response = call_claude(anthropic_key, CRITIQUE_SYSTEM_PROMPT, user_content, max_tokens=300) + except requests.RequestException as e: + # If the critique call fails, prefer omitting the candidate over posting noise. + log(f" Critique: API call failed for #{number} ({e}); omitting candidate") + continue + + fence = re.match(r"^\s*```(?:json)?\s*\n?(.*?)\n?```\s*$", response, re.DOTALL) + if fence: + response = fence.group(1) + + try: + verdict_data = json.loads(response) + except json.JSONDecodeError as e: + log(f" Critique: failed to parse verdict for #{number} ({e}); omitting candidate") + log(f" Raw response: {response}") + continue + + verdict = verdict_data.get("verdict") + rule = verdict_data.get("rule_violated") + rationale = verdict_data.get("rationale", "") + + if verdict == "include": + log(f" Critique: keeping #{number} — {rationale}") + kept.append(match) + else: + rule_str = f"rule {rule}" if rule else "no specific rule" + log(f" Critique: omitting #{number} ({rule_str}) — {rationale}") + + log(f" Critique: kept {len(kept)} of {len(proposed)} closed candidates") + return kept if __name__ == "__main__": @@ -515,35 +862,46 @@ if __name__ == "__main__": sys.exit(0) # detect areas - taxonomy = format_taxonomy_for_claude(fetch_area_labels()) - detected_areas = detect_areas(anthropic_key, issue, taxonomy) + detected_areas = detect_areas(anthropic_key, issue, fetch_area_labels()) - # search for potential duplicates + # search for potential duplicates and related closed issues all_magnets = parse_duplicate_magnets() relevant_magnets = filter_magnets_by_areas(all_magnets, detected_areas) search_results = search_for_similar_issues(issue, detected_areas) - # analyze potential duplicates - if relevant_magnets or search_results: - matches, summary = analyze_duplicates(anthropic_key, issue, relevant_magnets, search_results) - else: - matches, summary = [], "No potential duplicates to analyze" + # analyze candidates + likely_duplicates, possible_duplicates, related_closed_issues = analyze_duplicates( + anthropic_key, issue, relevant_magnets, search_results + ) - # post comment if high-confidence matches found - high_confidence_matches = [m for m in matches if m["confidence"] == "high"] + # second-pass critique: prompt iteration on the proposer hit a ceiling around 30% noise. + # Re-evaluate each proposed closed candidate in isolation with a stricter prompt that + # has no slate to fill and is blind to the proposer's rationale. + related_closed_issues = critique_closed_candidates( + anthropic_key, issue, related_closed_issues, search_results + ) + + # resolve close reason from our search results (the source of truth) so we don't depend + # on Claude to faithfully echo it back + results_by_number = {r["number"]: r for r in search_results} + for m in related_closed_issues: + m["state_reason"] = results_by_number[m["number"]]["state_reason"] + + comment_body = build_comment(likely_duplicates, possible_duplicates, related_closed_issues) commented = False - if high_confidence_matches: - comment_body = build_duplicate_comment(high_confidence_matches) + if comment_body: if args.dry_run: log("Dry run - would post comment:\n" + "-" * 40 + "\n" + comment_body + "\n" + "-" * 40) else: - log("Posting comment for high-confidence match(es)") + log("Posting comment") try: post_comment(issue["number"], comment_body) commented = True except requests.RequestException as e: log(f" Failed to post comment: {e}") + log(f" Comment we were trying to post:\n{comment_body}") + sys.exit(1) print(json.dumps({ "skipped": False, @@ -556,7 +914,8 @@ if __name__ == "__main__": "detected_areas": detected_areas, "magnets_count": len(relevant_magnets), "search_results_count": len(search_results), - "matches": matches, - "summary": summary, + "likely_duplicates": likely_duplicates, + "possible_duplicates": possible_duplicates, + "related_closed_issues": related_closed_issues, "commented": commented, })) diff --git a/script/github-track-duplicate-bot-effectiveness.py b/script/github-track-duplicate-bot-effectiveness.py index 05e64026d95..02be1b9e137 100644 --- a/script/github-track-duplicate-bot-effectiveness.py +++ b/script/github-track-duplicate-bot-effectiveness.py @@ -24,6 +24,7 @@ import functools import os import re import sys +import time from datetime import datetime, timezone import requests @@ -35,15 +36,25 @@ REPO_NAME = "zed" STAFF_TEAM_SLUG = "staff" BOT_LOGIN = "zed-community-bot[bot]" BOT_APP_SLUG = "zed-community-bot" -BOT_COMMENT_PREFIX = "This issue appears to be a duplicate of" +# Strings that identify a comment posted by the duplicate-detection bot. Any +# match counts as a bot comment for classification purposes. A single comment +# can contain both markers (v3+ produces this when there are both confident +# duplicates and lower-confidence triage context). +BOT_COMMENT_MARKERS = ( + "This issue appears to be a duplicate of", # user-facing duplicate alert + "Additional recent context for triagers", # v3+ collapsed triage section +) BOT_START_DATE = "2026-02-18" NEEDS_TRIAGE_LABEL = "state:needs triage" DEFAULT_PROJECT_NUMBER = 76 VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"} +# HTTP statuses we'll retry on for GET requests +TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504} # Add a new tuple when you deploy a new version of the bot that you want to # keep track of (e.g. the prompt gets a rewrite or the model gets swapped). -# Newest first, please. The datetime is for the deployment time (merge to maain). +# Newest first, please. The datetime is for the deployment time (merge to main). BOT_VERSION_TIMELINE = [ + ("v3", datetime(2026, 5, 25, 14, 30, tzinfo=timezone.utc)), ("v2", datetime(2026, 2, 26, 14, 9, tzinfo=timezone.utc)), ("v1", datetime(2026, 2, 18, tzinfo=timezone.utc)), ] @@ -59,10 +70,22 @@ def bot_version_for_time(date_string): def github_api_get(path, params=None): + """Fetch JSON from the GitHub REST API, retrying transient failures. Raises on non-2xx status.""" url = f"{GITHUB_API}/{path.lstrip('/')}" - response = requests.get(url, headers=GITHUB_HEADERS, params=params) - response.raise_for_status() - return response.json() + for attempt in range(3): + try: + response = requests.get(url, headers=GITHUB_HEADERS, params=params) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or ( + isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES + ) + if not transient or attempt == 2: + raise + wait = 2 ** attempt + print(f" Transient GitHub API error ({e}); retrying in {wait}s") + time.sleep(wait) def github_search_issues(query): @@ -96,10 +119,16 @@ def fetch_issue(issue_number): } +def is_bot_dupe_comment(body): + """True if the comment body looks like one posted by the duplicate-detection bot.""" + return any(marker in body for marker in BOT_COMMENT_MARKERS) + + def get_bot_comment_with_time(issue_number): """Get the bot's duplicate-detection comment and its timestamp from an issue. - Returns {"body": str, "created_at": str} if found, else None. + Recognizes both the user-facing duplicate alert and the v3+ triage-only + comment formats. Returns {"body": str, "created_at": str} if found, else None. """ comments_path = f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments" page = 1 @@ -107,7 +136,7 @@ def get_bot_comment_with_time(issue_number): for comment in comments: author = (comment.get("user") or {}).get("login", "") body = comment.get("body", "") - if author == BOT_LOGIN and body.startswith(BOT_COMMENT_PREFIX): + if author == BOT_LOGIN and is_bot_dupe_comment(body): return {"body": body, "created_at": comment.get("created_at", "")} page += 1 return None @@ -147,9 +176,11 @@ def find_canonical_among(duplicate_number, candidates): if not candidates: return None + # candidate issue numbers are baked into the query body via field aliases + # (GraphQL doesn't let you parametrize alias names), so $numbers isn't needed. data = github_api_graphql( """ - query($owner: String!, $repo: String!, $numbers: [Int!]!) { + query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { PLACEHOLDER } @@ -160,7 +191,7 @@ def find_canonical_among(duplicate_number, candidates): f' nodes {{ ... on MarkedAsDuplicateEvent {{ duplicate {{ ... on Issue {{ number }} }} }} }} }} }}' for number in candidates )), - {"owner": REPO_OWNER, "repo": REPO_NAME, "numbers": list(candidates)}, + {"owner": REPO_OWNER, "repo": REPO_NAME}, partial_errors_ok=True, ) @@ -395,11 +426,10 @@ def classify_as_assist(issue, bot_comment): bot_comment_time=bot_comment["created_at"]) return - original = None - try: - original = find_canonical_among(issue["number"], suggested) - except (requests.RequestException, RuntimeError) as error: - print(f" Warning: failed to query candidate timelines: {error}") + # Let exceptions from find_canonical_among propagate — a query failure here is + # not the same as "no canonical match" and shouldn't be silently downgraded to + # a Needs review entry. Failing the workflow surfaces the problem immediately. + original = find_canonical_among(issue["number"], suggested) if original: status = "Auto-classified" @@ -448,7 +478,7 @@ def classify_open(): node_id = item["node_id"] skip_reason = ( - f"type is {type_name}" if type_name not in ("Bug", "Crash") + f"type is {type_name}" if type_name and type_name not in ("Bug", "Crash") else f"author {author} is staff" if is_staff_member(author) else "already on the board" if find_project_item(node_id) else "no bot duplicate comment found" if not (bot_comment := get_bot_comment_with_time(number)) @@ -469,6 +499,8 @@ def classify_open(): errors += 1 print(f" Done: added {added}, skipped {skipped}, errors {errors}") + if errors > 0: + sys.exit(1) if __name__ == "__main__": diff --git a/script/licenses/zed-licenses.toml b/script/licenses/zed-licenses.toml index db14a280f2f..17ebc15954a 100644 --- a/script/licenses/zed-licenses.toml +++ b/script/licenses/zed-licenses.toml @@ -1,16 +1,9 @@ no-clearly-defined = true private = { ignore = true } -# Licenses allowed in Zed's dependencies. AGPL should not be added to -# this list as use of AGPL software is sometimes disallowed. When -# adding to this list, please check the following open source license -# policies: +# Licenses allowed in Zed's dependencies. AGPL should not be added to this list. +# When adding to this list, please check the following open source license policies: # # * https://opensource.google/documentation/reference/thirdparty/licenses -# -# The Zed project does have AGPL crates, but these are only involved -# in servers and are not built into the binaries in the release -# tarball. `script/check-licenses` checks that AGPL crates are not -# involved in release binaries. accepted = [ "Apache-2.0", "MIT", diff --git a/script/new-crate b/script/new-crate index 52ee900b308..4f70a5fdc32 100755 --- a/script/new-crate +++ b/script/new-crate @@ -18,13 +18,13 @@ fi CRATE_NAME="$1" -LICENSE_FLAG=$(echo "${2}" | tr '[:upper:]' '[:lower:]') +LICENSE_FLAG=$(echo "${2:-}" | tr '[:upper:]' '[:lower:]') if [[ "$LICENSE_FLAG" == *"apache"* ]]; then LICENSE_MODE="Apache-2.0" LICENSE_FILE="LICENSE-APACHE" elif [[ "$LICENSE_FLAG" == *"agpl"* ]]; then - LICENSE_MODE="AGPL-3.0-or-later" - LICENSE_FILE="LICENSE-AGPL" + echo "Error: New first-party crates cannot use AGPL. Use GPL or Apache." + exit 1 else LICENSE_MODE="GPL-3.0-or-later" LICENSE_FILE="LICENSE-GPL" diff --git a/script/terms/terms.rtf b/script/terms/terms.rtf index cd01004c11e..441b52cae4d 100644 --- a/script/terms/terms.rtf +++ b/script/terms/terms.rtf @@ -54,7 +54,7 @@ Community Guidelines {\pard \ql \f0 \sa180 \li0 \fi0 Customer will not (and will not permit anyone else to), directly or indirectly, do any of the following: (a) provide access to, distribute, sell, or sublicense the Service to a third party; (b)\u160 ?seek to access non-public APIs associated with the Service; (c) copy any element of the Service; (d) interfere with the operation of the Service, circumvent any access restrictions, or conduct any security or vulnerability test of the Service; (e) transmit any viruses or other harmful materials to the Service or others;\u160 ?(f) take any action that risks harm to others or to the security, availability, or integrity of the Service except for the purposes of legitimate security or malware research; or (g) access or use the Service or Output in a manner that violates any applicable relevant local, state, federal or international laws, regulations, or conventions, including those related to data privacy or data transfer, international communications, or export of data (collectively, \u8220"{\b Laws}\u8221"), or the Terms.\u160 ?The Service incorporates functionality provided by third-party services, the use of which is subject to additional terms. Customer agrees that if Customer accesses or uses services, features or functionality in the Software or Service that are provided by a third party, Customer will comply with any applicable terms promulgated by that third party, including as set forth at {\field{\*\fldinst{HYPERLINK "/acceptable-use-policies"}}{\fldrslt{\ul https://zed.dev/acceptable-use-policies }}} -\u160 ?(as may be updated from time to time). Customer further acknowledges that certain components of the Software or Service may be covered by open source licenses ("{\b Open Source Component}"), including but not limited to Apache License, Version 2.0, GNU General Public License v3.0, and the GNU Affero General Public License v3.0.\u160 ?To the extent required by such open source license for the applicable Open Source Component, the terms of such license will apply to such Open Source Component in lieu of the relevant provisions of these Terms. If such open\u160 ?source license prohibits any of the restrictions in these Terms, such restrictions will not apply to such Open Source Component. Zed shall provide Customer with a list of Open Source Components upon Customer's request.\par} +\u160 ?(as may be updated from time to time). Customer further acknowledges that certain components of the Software or Service may be covered by open source licenses ("{\b Open Source Component}"), including but not limited to Apache License, Version 2.0 and GNU General Public License v3.0.\u160 ?To the extent required by such open source license for the applicable Open Source Component, the terms of such license will apply to such Open Source Component in lieu of the relevant provisions of these Terms. If such open\u160 ?source license prohibits any of the restrictions in these Terms, such restrictions will not apply to such Open Source Component. Zed shall provide Customer with a list of Open Source Components upon Customer's request.\par} {\pard \ql \f0 \sa180 \li0 \fi0 \outlinelevel1 \b \fs32 3. General Payment Terms\par} {\pard \ql \f0 \sa180 \li0 \fi0 Accessing certain features and tiers of the Service requires Customer\u160 ?to pay fees. Before Customer pays any fees, Customer will have an opportunity to review and accept the fees that Customer will be charged. Unless otherwise specifically provided for in these Terms, all fees are in U.S. Dollars and are non-refundable, except as required by law.\par} {\pard \ql \f0 \sa180 \li0 \fi0 \outlinelevel2 \b \fs28 3.1. Price\par} diff --git a/script/trigger-docs-build b/script/trigger-docs-build new file mode 100755 index 00000000000..3d429e0097d --- /dev/null +++ b/script/trigger-docs-build @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +which gh >/dev/null || brew install gh + +case "${1:-}" in + preview | stable) + channel="$1" + ;; + *) + echo "Usage: $0 preview|stable [--from-main]" >&2 + exit 1 + ;; +esac + +case "${2:-}" in + "") + from_main=false + ;; + --from-main) + from_main=true + ;; + *) + echo "Usage: $0 preview|stable [--from-main]" >&2 + exit 1 + ;; +esac + +version=$(./script/get-released-version "$channel") +branch=$(echo "$version" | sed -E 's/^([0-9]+)\.([0-9]+)\.[0-9]+$/v\1.\2.x/') +workflow_ref="$branch" +if [ "$from_main" = true ]; then + workflow_ref="main" +fi + +echo "Triggering docs build for $channel ($branch) using workflow from $workflow_ref" +echo "This will publish docs from $branch before the next release." +echo "Only continue if $branch has no unreleased feature-specific docs." +read -r -p "Continue? [y/N] " confirmation +case "$confirmation" in + y | Y | yes | YES) + ;; + *) + echo "Cancelled." + exit 1 + ;; +esac + +gh workflow run "deploy_docs.yml" --ref "$workflow_ref" -f channel="$channel" -f checkout_ref="$branch" +echo "Follow along at: https://github.com/zed-industries/zed/actions/workflows/deploy_docs.yml" diff --git a/script/update_top_ranking_issues/pyproject.toml b/script/update_top_ranking_issues/pyproject.toml index 18d4afe9508..fcfc23e83d0 100644 --- a/script/update_top_ranking_issues/pyproject.toml +++ b/script/update_top_ranking_issues/pyproject.toml @@ -11,4 +11,5 @@ dependencies = [ "typer>=0.15.1", "types-pytz>=2025.1.0.20250204", "types-requests>=2.32.0", + "urllib3>=2.7.0", ] diff --git a/script/update_top_ranking_issues/uv.lock b/script/update_top_ranking_issues/uv.lock index 0e98447aa5b..f1b3ec28515 100644 --- a/script/update_top_ranking_issues/uv.lock +++ b/script/update_top_ranking_issues/uv.lock @@ -134,7 +134,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.3" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -142,9 +142,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -252,6 +252,7 @@ dependencies = [ { name = "typer" }, { name = "types-pytz" }, { name = "types-requests" }, + { name = "urllib3" }, ] [package.metadata] @@ -263,13 +264,14 @@ requires-dist = [ { name = "typer", specifier = ">=0.15.1" }, { name = "types-pytz", specifier = ">=2025.1.0.20250204" }, { name = "types-requests", specifier = ">=2.32.0" }, + { name = "urllib3", specifier = ">=2.7.0" }, ] [[package]] name = "urllib3" -version = "2.2.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] diff --git a/tooling/xtask/src/tasks/licenses.rs b/tooling/xtask/src/tasks/licenses.rs index 449c774d458..e187299c375 100644 --- a/tooling/xtask/src/tasks/licenses.rs +++ b/tooling/xtask/src/tasks/licenses.rs @@ -9,7 +9,7 @@ use crate::workspace::load_workspace; pub struct LicensesArgs {} pub fn run_licenses(_args: LicensesArgs) -> Result<()> { - const LICENSE_FILES: &[&str] = &["LICENSE-APACHE", "LICENSE-GPL", "LICENSE-AGPL"]; + const LICENSE_FILES: &[&str] = &["LICENSE-APACHE", "LICENSE-GPL"]; let workspace = load_workspace()?; diff --git a/tooling/xtask/src/tasks/setup_webrtc.rs b/tooling/xtask/src/tasks/setup_webrtc.rs index 756a3767838..5dbf5bcaa96 100644 --- a/tooling/xtask/src/tasks/setup_webrtc.rs +++ b/tooling/xtask/src/tasks/setup_webrtc.rs @@ -219,31 +219,47 @@ fn update_cargo_config(webrtc_path: &Path) -> Result<()> { .or_else(|| std::env::var_os("USERPROFILE")) .context("could not determine home directory")?; let config_path = PathBuf::from(home).join(".cargo").join("config.toml"); - if config_path.exists() { - bail!( - "{} already exists; refusing to modify it. \ - Add `[env]\\n{ENV_VAR} = \"{}\"` yourself, \ - or re-run with --no-cargo-config.", - config_path.display(), - webrtc_path.display(), - ); - } if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } - let mut doc = DocumentMut::new(); - let mut env_table = Table::new(); - env_table.set_implicit(false); - let path_str = webrtc_path - .to_str() - .context("webrtc path is not valid UTF-8")?; - env_table.insert(ENV_VAR, value(path_str)); - doc.insert("env", Item::Table(env_table)); + let existing_content = if config_path.exists() { + fs::read_to_string(&config_path) + .with_context(|| format!("reading {}", config_path.display()))? + } else { + String::new() + }; + + let mut doc = existing_content + .parse::() + .with_context(|| format!("parsing existing {}", config_path.display()))?; + + let env_table = doc + .entry("env") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .context("`env` entry is not a table")?; + + let cleaned_path = clean_webrtc_path(webrtc_path)?; + env_table.insert(ENV_VAR, value(cleaned_path.clone())); fs::write(&config_path, doc.to_string()) .with_context(|| format!("writing {}", config_path.display()))?; - eprintln!("Wrote {} with {ENV_VAR}={path_str}", config_path.display()); + + eprintln!( + "Updated {} with {ENV_VAR}={cleaned_path}", + config_path.display() + ); Ok(()) } + +fn clean_webrtc_path(path: &Path) -> Result { + let path_str = path.to_str().context("webrtc path is not valid UTF-8")?; + let mut cleaned = path_str.to_string(); + if cleaned.starts_with(r"\\?\") { + cleaned = cleaned[4..].to_string(); + } + cleaned = cleaned.replace('\\', "/"); + Ok(cleaned) +} diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 1043c1fc009..6f21be93907 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -237,6 +237,7 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(extension_auto_bump::extension_auto_bump), WorkflowFile::zed(extension_tests::extension_tests), WorkflowFile::zed(extension_workflow_rollout::extension_workflow_rollout), + WorkflowFile::zed(nix_build::nix_build), WorkflowFile::zed(publish_extension_cli::publish_extension_cli), WorkflowFile::zed(release::release), WorkflowFile::zed(release_nightly::release_nightly), diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index 56aeb677eac..f93415f2077 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -14,7 +14,7 @@ use crate::tasks::workflows::{ vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token}, }; -pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7"; +pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "2a00db06ce6d01089bfafd207b6348078e980df9"; // This should follow the set target in crates/extension/src/extension_builder.rs const EXTENSION_RUST_TARGET: &str = "wasm32-wasip2"; diff --git a/tooling/xtask/src/tasks/workflows/nix_build.rs b/tooling/xtask/src/tasks/workflows/nix_build.rs index 9e401ccac08..cc04ec5b855 100644 --- a/tooling/xtask/src/tasks/workflows/nix_build.rs +++ b/tooling/xtask/src/tasks/workflows/nix_build.rs @@ -1,11 +1,69 @@ use crate::tasks::workflows::{ runners::{Arch, Platform}, - steps::{CommonJobConditions, NamedJob}, + steps::{CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob}, }; use super::{runners, steps, steps::named, vars}; use gh_workflow::*; +/// Generates the nix_build.yml workflow, which builds the Nix package on PRs +/// that carry the `run-nix` or `run-bundling` label. The Nix jobs live only +/// here (not in run_bundling.yml) so that setting both labels doesn't build +/// them twice. +pub fn nix_build() -> Workflow { + let [nix_linux_x86_64, nix_mac_aarch64] = nix_pr_jobs(&["run-nix", "run-bundling"]); + named::workflow() + .on(Event::default().pull_request( + PullRequest::default().types([PullRequestType::Labeled, PullRequestType::Synchronize]), + )) + .concurrency( + Concurrency::new(Expression::new( + "${{ github.workflow }}-${{ github.head_ref || github.ref }}", + )) + .cancel_in_progress(true), + ) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", "1")) + .add_job(nix_linux_x86_64.name, nix_linux_x86_64.job) + .add_job(nix_mac_aarch64.name, nix_mac_aarch64.job) +} + +/// Builds the pair of PR Nix jobs (Linux x86_64 + macOS aarch64), each gated so +/// they run when any of the given PR `labels` is present (on +/// `labeled`/`synchronize` events). +fn nix_pr_jobs(labels: &[&str]) -> [NamedJob; 2] { + let labeled = labels + .iter() + .map(|label| format!("github.event.label.name == '{label}'")) + .collect::>() + .join(" || "); + let synchronized = labels + .iter() + .map(|label| format!("contains(github.event.pull_request.labels.*.name, '{label}')")) + .collect::>() + .join(" || "); + [ + (Platform::Linux, Arch::X86_64), + (Platform::Mac, Arch::AARCH64), + ] + .map(|(platform, arch)| { + let mut job = build_nix( + platform, + arch, + "default", + // don't push PR builds to the cache + Some("-zed-editor-[0-9.]*"), + &[], + ); + job.job = job.job.cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} && \ + ((github.event.action == 'labeled' && ({labeled})) || \ + (github.event.action == 'synchronize' && ({synchronized})))" + ))); + job + }) +} + pub(crate) fn build_nix( platform: Platform, arch: Arch, diff --git a/tooling/xtask/src/tasks/workflows/run_bundling.rs b/tooling/xtask/src/tasks/workflows/run_bundling.rs index 5fa6b870b02..7546e93c418 100644 --- a/tooling/xtask/src/tasks/workflows/run_bundling.rs +++ b/tooling/xtask/src/tasks/workflows/run_bundling.rs @@ -1,13 +1,9 @@ use std::path::Path; use crate::tasks::workflows::{ - nix_build::build_nix, release::ReleaseBundleJobs, runners::{Arch, Platform, ReleaseChannel}, - steps::{ - DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, IfNoFilesFound, NamedJob, - UploadArtifactStep, dependant_job, named, - }, + steps::{FluentBuilder, IfNoFilesFound, NamedJob, UploadArtifactStep, dependant_job, named}, vars::{assets, bundle_envs}, }; @@ -24,8 +20,6 @@ pub fn run_bundling() -> Workflow { windows_aarch64: bundle_windows(Arch::AARCH64, None, &[]), windows_x86_64: bundle_windows(Arch::X86_64, None, &[]), }; - let nix_linux_x86_64 = nix_job(Platform::Linux, Arch::X86_64); - let nix_mac_aarch64 = nix_job(Platform::Mac, Arch::AARCH64); named::workflow() .on(Event::default().pull_request( PullRequest::default().types([PullRequestType::Labeled, PullRequestType::Synchronize]), @@ -44,25 +38,6 @@ pub fn run_bundling() -> Workflow { } workflow }) - .add_job(nix_linux_x86_64.name, nix_linux_x86_64.job) - .add_job(nix_mac_aarch64.name, nix_mac_aarch64.job) -} - -fn nix_job(platform: Platform, arch: Arch) -> NamedJob { - let mut job = build_nix( - platform, - arch, - "default", - // don't push PR builds to the cache - Some("-zed-editor-[0-9.]*"), - &[], - ); - job.job = job.job.cond(Expression::new(format!( - "{} && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || \ - (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')))", - DEFAULT_REPOSITORY_OWNER_GUARD - ))); - job } fn bundle_job(deps: &[&NamedJob]) -> Job { diff --git a/typos.toml b/typos.toml index 22823e6b2d9..4f877457dc1 100644 --- a/typos.toml +++ b/typos.toml @@ -98,6 +98,10 @@ extend-ignore-re = [ # Yarn Plug'n'Play "PnP", # `image` crate method: Delay::from_numer_denom_ms - "numer" + "numer", + # Abbreviation for foreignObject in mermaid SVG processing + "fo", + # Mermaid CSS class name for state diagram composites + "composit" ] check-filename = true