diff --git a/.cloudflare/docs-proxy/src/worker.js b/.cloudflare/docs-proxy/src/worker.js index 08b0265fafb..99fca0c7116 100644 --- a/.cloudflare/docs-proxy/src/worker.js +++ b/.cloudflare/docs-proxy/src/worker.js @@ -1,6 +1,11 @@ export default { async fetch(request, _env, _ctx) { const url = new URL(request.url); + const acceptHeader = request.headers.get("Accept") || ""; + const wantsMarkdown = acceptHeader + .split(",") + .map((mediaType) => mediaType.split(";")[0].trim().toLowerCase()) + .includes("text/markdown"); if (url.pathname === "/docs/nightly") { url.hostname = "docs-nightly.pages.dev"; @@ -18,6 +23,14 @@ export default { url.hostname = "docs-anw.pages.dev"; } + if (url.pathname === "/docs.md") { + url.pathname = "/docs/getting-started.md"; + } + + if (wantsMarkdown) { + url.pathname = markdownPathFor(url.pathname); + } + let res = await fetch(url, request); if (res.status === 404) { @@ -27,3 +40,31 @@ export default { return res; }, }; + +function markdownPathFor(pathname) { + if (pathname === "/docs" || pathname === "/docs/") { + return "/docs/getting-started.md"; + } + + if (pathname.endsWith("/index.md")) { + return pathname.replace(/\/index\.md$/, "/getting-started.md"); + } + + if (pathname.endsWith(".md")) { + return pathname; + } + + if (pathname.endsWith(".html")) { + return pathname.replace(/\.html$/, ".md"); + } + + if (pathname.split("/").pop().includes(".")) { + return pathname; + } + + if (pathname.endsWith("/")) { + return `${pathname}getting-started.md`; + } + + return `${pathname}.md`; +} diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index 0e18eb2d16d..48474ac0bd5 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -27,10 +27,6 @@ jobs: run_cherry_pick: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - id: generate-token name: steps::authenticate_as_zippy uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 @@ -40,6 +36,11 @@ jobs: permission-contents: write permission-workflows: write permission-pull-requests: write + - name: steps::checkout_repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + clean: false + token: ${{ steps.generate-token.outputs.token }} - name: cherry_pick::run_cherry_pick::cherry_pick run: ./script/cherry-pick "$BRANCH" "$COMMIT" "$CHANNEL" env: diff --git a/.github/workflows/guild_new_pr_notify.yml b/.github/workflows/guild_new_pr_notify.yml index babab1c8b9c..8aad061e5f8 100644 --- a/.github/workflows/guild_new_pr_notify.yml +++ b/.github/workflows/guild_new_pr_notify.yml @@ -40,18 +40,23 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - const GUILD_TEAM_SLUG = 'guild-cohort-2'; + // Guild cohort members are outside collaborators holding this custom + // repository role, not members of an org team. + const GUILD_ROLE_NAME = 'Guild Assign issues/PRs'; const pr = context.payload.pull_request; const author = pr.user.login; const isGuildMember = async (username) => { try { - const response = await github.rest.teams.getMembershipForUserInOrg({ - org: 'zed-industries', - team_slug: GUILD_TEAM_SLUG, + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: 'zed-industries', + repo: 'zed', username }); - return response.data.state === 'active'; + // role_name is the effective (highest) role; for cohort outside + // collaborators that is the custom role. Built-in roles come back + // lowercased and won't match. + return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase(); } catch (error) { if (error.status === 404) { return false; diff --git a/.github/workflows/pr_issue_labeler.yml b/.github/workflows/pr_issue_labeler.yml index f295dcb4f9a..524d301aecc 100644 --- a/.github/workflows/pr_issue_labeler.yml +++ b/.github/workflows/pr_issue_labeler.yml @@ -41,7 +41,9 @@ jobs: const STAFF_TEAM_SLUG = 'staff'; const FIRST_CONTRIBUTION_LABEL = 'first contribution'; const GUILD_LABEL = 'guild'; - const GUILD_TEAM_SLUG = 'guild-cohort-2'; + // Guild cohort members are outside collaborators holding this custom + // repository role, not members of an org team. + const GUILD_ROLE_NAME = 'Guild Assign issues/PRs'; const COMMUNITY_CHAMPION_LABEL = 'community champion'; const COMMUNITY_CHAMPIONS = [ '0x2CA', @@ -138,7 +140,25 @@ jobs: }; const isStaffMember = (author) => isTeamMember(STAFF_TEAM_SLUG, author); - const isGuildMember = (author) => isTeamMember(GUILD_TEAM_SLUG, author); + + const isGuildMember = async (author) => { + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: 'zed-industries', + repo: 'zed', + username: author + }); + // role_name is the effective (highest) role; for cohort outside + // collaborators that is the custom role. Built-in roles come back + // lowercased and won't match. + return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase(); + } catch (error) { + if (error.status !== 404) { + throw error; + } + return false; + } + }; const getIssueLabels = () => { if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec65ab2c872..5a3808e0632 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -760,6 +760,8 @@ jobs: needs: - upload_release_assets runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + contents: write steps: - name: release::validate_release_assets run: | diff --git a/.github/workflows/slack_notify_first_responders.yml b/.github/workflows/slack_notify_first_responders.yml index 91507ebeefd..c8d970451dd 100644 --- a/.github/workflows/slack_notify_first_responders.yml +++ b/.github/workflows/slack_notify_first_responders.yml @@ -11,28 +11,41 @@ env: PRIORITY_LABELS: '["priority:P0", "priority:P1"]' REPRODUCIBLE_LABEL: 'state:reproducible' FREQUENCY_LABELS: '["frequency:always", "frequency:common"]' + MARKER_REACTION: 'eyes' jobs: notify-slack: if: github.repository_owner == 'zed-industries' && github.event.issue.state == 'open' runs-on: namespace-profile-2x4-ubuntu-2404 - # Serialize per-issue so concurrent `labeled` events can't both observe - # the trifecta and double-notify. + permissions: + contents: read + # For the issue reaction used to dedupe notifications. + issues: write + # Serialize per issue so the reaction claim below can't race. concurrency: group: slack-notify-first-responders-${{ github.event.issue.number }} cancel-in-progress: false steps: - - name: Check if label combination requires first responder notification + - name: Check label combination and claim the notification id: check-label env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} LABEL_NAME: ${{ github.event.label.name }} - ISSUE_LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }} + ISSUE_NUMBER: ${{ github.event.issue.number }} run: | set -euo pipefail - # Gate on the just-added label so unrelated labeling on an - # already-qualifying issue doesn't re-fire the notification. + api() { + curl -sS \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + # Ignore labels outside the trifecta so unrelated later edits don't re-fire. TRIGGER_LABELS=$(jq -cn \ --argjson priority "$PRIORITY_LABELS" \ --arg repro "$REPRODUCIBLE_LABEL" \ @@ -45,19 +58,52 @@ jobs: exit 0 fi + # The webhook's issue.labels snapshot is racy under bulk apply; read live. + ISSUE_LABELS_JSON=$(api -f "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/labels?per_page=100" | jq '[.[].name]') + MATCHED_PRIORITY=$(echo "$ISSUE_LABELS_JSON" | jq -r --argjson priority "$PRIORITY_LABELS" 'map(select(. as $x | $priority | index($x) != null)) | first // ""') HAS_REPRO=$(echo "$ISSUE_LABELS_JSON" | jq --arg l "$REPRODUCIBLE_LABEL" 'index($l) != null') HAS_FREQ=$(echo "$ISSUE_LABELS_JSON" | jq --argjson freq "$FREQUENCY_LABELS" 'any(.[]; . as $x | $freq | index($x) != null)') - if [ -n "$MATCHED_PRIORITY" ] && [ "$HAS_REPRO" = "true" ] && [ "$HAS_FREQ" = "true" ]; then - echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying" - echo "notify_reason=confirmed $MATCHED_PRIORITY" >> "$GITHUB_OUTPUT" - echo "should_notify=true" >> "$GITHUB_OUTPUT" - else + if [ -z "$MATCHED_PRIORITY" ] || [ "$HAS_REPRO" != "true" ] || [ "$HAS_FREQ" != "true" ]; then echo "Combination not yet satisfied (priority=$MATCHED_PRIORITY, reproducible=$HAS_REPRO, frequency=$HAS_FREQ), skipping" echo "should_notify=false" >> "$GITHUB_OUTPUT" + exit 0 fi + # A bulk label apply emits one `labeled` event per label, so several + # runs reach this point. Creating the reaction is our atomic claim: + # one run gets 201 and notifies, the rest get 200. It's scoped to our + # own reaction, so a human's reaction can't suppress it. + REACTION_PAYLOAD=$(jq -cn --arg content "$MARKER_REACTION" '{content: $content}') + REACTION_RESPONSE=$(api -w "\n%{http_code}" -X POST \ + "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions" \ + -d "$REACTION_PAYLOAD") + REACTION_BODY=$(echo "$REACTION_RESPONSE" | sed '$d') + REACTION_STATUS=$(echo "$REACTION_RESPONSE" | tail -n1) + + if [ "$REACTION_STATUS" = "200" ]; then + echo "First responders already notified for this issue (reaction present), skipping" + echo "should_notify=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$REACTION_STATUS" != "201" ]; then + echo "::error::Unexpected status $REACTION_STATUS creating reaction: $REACTION_BODY" + exit 1 + fi + + REACTION_ID=$(echo "$REACTION_BODY" | jq -r '.id') + LABELS=$(echo "$ISSUE_LABELS_JSON" | jq -r 'join(", ")') + + echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying" + { + echo "notify_reason=confirmed $MATCHED_PRIORITY" + echo "issue_labels=$LABELS" + echo "reaction_id=$REACTION_ID" + echo "should_notify=true" + } >> "$GITHUB_OUTPUT" + - name: Build Slack message payload if: steps.check-label.outputs.should_notify == 'true' env: @@ -65,10 +111,8 @@ jobs: ISSUE_URL: ${{ github.event.issue.html_url }} LABELED_BY: ${{ github.event.sender.login }} NOTIFY_REASON: ${{ steps.check-label.outputs.notify_reason }} - LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }} + LABELS: ${{ steps.check-label.outputs.issue_labels }} run: | - LABELS=$(echo "$LABELS_JSON" | jq -r 'join(", ")') - jq -n \ --arg notify_reason "$NOTIFY_REASON" \ --arg issue_title "$ISSUE_TITLE" \ @@ -111,9 +155,27 @@ jobs: if: steps.check-label.outputs.should_notify == 'true' env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_FIRST_RESPONDERS }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REACTION_ID: ${{ steps.check-label.outputs.reaction_id }} run: | + set -uo pipefail + + release_claim() { + if [ -n "${REACTION_ID:-}" ]; then + echo "Releasing reaction claim so the notification can be retried" + curl -sS -X DELETE \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions/$REACTION_ID" || true + fi + } + if [ -z "$SLACK_WEBHOOK_URL" ]; then echo "::error::SLACK_WEBHOOK_FIRST_RESPONDERS secret is not set" + release_claim exit 1 fi @@ -129,6 +191,7 @@ jobs: if [ "$HTTP_STATUS" -ne 200 ]; then echo "::error::Slack notification failed with status $HTTP_STATUS: $HTTP_BODY" + release_claim exit 1 fi diff --git a/Cargo.lock b/Cargo.lock index 1d5fe8fd9bd..17acf0e6ed9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -494,6 +494,7 @@ dependencies = [ "heapless", "html_to_markdown", "http_client", + "idna", "image", "indoc", "itertools 0.14.0", @@ -549,6 +550,7 @@ dependencies = [ "tree-sitter-md", "ui", "ui_input", + "unicode-script", "unicode-segmentation", "unindent", "url", @@ -5344,9 +5346,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -6465,9 +6467,9 @@ dependencies = [ [[package]] name = "fancy-regex" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" dependencies = [ "bit-set 0.8.0", "regex-automata", @@ -14003,7 +14005,7 @@ dependencies = [ "dap", "encoding_rs", "extension", - "fancy-regex 0.17.0", + "fancy-regex 0.18.0", "fs", "futures 0.3.32", "fuzzy", @@ -15066,9 +15068,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -15995,6 +15997,7 @@ dependencies = [ "libc", "log", "nix 0.29.0", + "seccompiler", "serde", "serde_json", "smol", @@ -16312,6 +16315,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -16724,6 +16736,7 @@ dependencies = [ "agent_skills", "anyhow", "audio", + "client", "cloud_api_types", "codestral", "collections", @@ -20843,9 +20856,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -20929,9 +20942,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -22994,7 +23007,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.11.0" +version = "1.12.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/Cargo.toml b/Cargo.toml index 7a7003ec5eb..c657ff1aefb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -595,7 +595,7 @@ emojis = "0.6.1" env_logger = "0.11" encoding_rs = "0.8" exec = "0.3.1" -fancy-regex = "0.17.0" +fancy-regex = "0.18.0" fork = "0.4.0" futures = "0.3.32" futures-concurrency = "7.7.1" @@ -744,6 +744,7 @@ rustls-platform-verifier = "0.5.0" # WARNING: If you change this, you must also publish a new version of zed-scap to crates.io scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" } schemars = { version = "1.0", features = ["indexmap2"] } +seccompiler = "0.5" semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0.221", features = ["derive", "rc"] } serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] } diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 8799e032bfa..58bfc4b10e7 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -298,13 +298,6 @@ "alt-r": "search::ToggleRegex", }, }, - { - "context": "AgentTerminalThread", - "use_key_equivalents": true, - "bindings": { - "ctrl-f": "agent::ToggleSearch", - }, - }, { "context": "AcpThreadSearchBar", "use_key_equivalents": true, @@ -326,6 +319,7 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-alt-pageup": "agent::ScrollOutputPageUp", "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", "ctrl-alt-home": "agent::ScrollOutputToTop", @@ -1240,6 +1234,7 @@ "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"], "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"], "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"], + "ctrl-q": ["terminal::SendKeystroke", "ctrl-q"], "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], "ctrl-r": ["terminal::SendKeystroke", "ctrl-r"], "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"], @@ -1270,7 +1265,9 @@ }, { "context": "AgentPanel > Terminal", + "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-n": "agent::NewThread", }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index de0f8bd2b46..0bf5b95fe58 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -336,13 +336,6 @@ "alt-cmd-x": "search::ToggleRegex", }, }, - { - "context": "AgentTerminalThread", - "use_key_equivalents": true, - "bindings": { - "cmd-f": "agent::ToggleSearch", - }, - }, { "context": "AcpThreadSearchBar", "use_key_equivalents": true, @@ -364,6 +357,7 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "cmd-f": "agent::ToggleSearch", "ctrl-pageup": "agent::ScrollOutputPageUp", "ctrl-pagedown": "agent::ScrollOutputPageDown", "ctrl-home": "agent::ScrollOutputToTop", @@ -1341,6 +1335,7 @@ "context": "AgentPanel > Terminal", "use_key_equivalents": true, "bindings": { + "cmd-f": "agent::ToggleSearch", "cmd-n": "agent::NewThread", }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index acc2a82e18e..d373b94f649 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -299,13 +299,6 @@ "alt-r": "search::ToggleRegex", }, }, - { - "context": "AgentTerminalThread", - "use_key_equivalents": true, - "bindings": { - "ctrl-f": "agent::ToggleSearch", - }, - }, { "context": "AcpThreadSearchBar", "use_key_equivalents": true, @@ -327,6 +320,7 @@ "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-alt-pageup": "agent::ScrollOutputPageUp", "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", "ctrl-alt-home": "agent::ScrollOutputToTop", @@ -1285,6 +1279,7 @@ "context": "AgentPanel > Terminal", "use_key_equivalents": true, "bindings": { + "ctrl-f": "agent::ToggleSearch", "ctrl-n": "agent::NewThread", }, }, diff --git a/assets/settings/default.json b/assets/settings/default.json index 983784e8951..0e42d27e980 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1458,7 +1458,13 @@ // The EditorConfig `end_of_line` property overrides this setting and behaves // like `enforce_lf` or `enforce_crlf`. "line_ending": "detect", - // Whether or not to perform a buffer format before saving: [on, off] + // Whether or not to perform a buffer format before saving: + // "on" — format the whole buffer + // "off" — do not format + // "modifications" — format only lines with unstaged changes; skips formatting + // when no git diff is available or the language server lacks range formatting + // "modifications_if_available" — same, but falls back to formatting the whole + // buffer when range formatting cannot be used // Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored "format_on_save": "off", // How to perform a buffer format. This setting can take multiple values: diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 9ec28e47d73..c2b2fe35d4d 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -224,6 +224,11 @@ pub struct SandboxFallbackAuthorizationDetails { /// whether to run the command without a sandbox. #[serde(default)] pub reason: String, + /// Slug of the sandboxing docs section that best explains how to fix this + /// failure (see [`crate::LinuxWslSandboxError::docs_section`]), rendered as a + /// "Learn more" link. `None` when the cause is unknown. + #[serde(default)] + pub docs_section: Option, } pub fn meta_with_sandbox_fallback_authorization( @@ -430,16 +435,7 @@ impl ElicitationStore { &self.elicitations } - fn validate_request( - request: &acp::CreateElicitationRequest, - cx: &App, - ) -> Result<(), acp::Error> { - if !cx.has_flag::() { - return Err( - acp::Error::invalid_params().data("elicitation support requires the ACP beta flag") - ); - } - + fn validate_request(request: &acp::CreateElicitationRequest) -> Result<(), acp::Error> { if let acp::ElicitationMode::Url(mode) = &request.mode { url::Url::parse(&mode.url) .map_err(|_| acp::Error::invalid_params().data("invalid elicitation URL"))?; @@ -590,7 +586,7 @@ impl ElicitationStore { request: acp::CreateElicitationRequest, cx: &mut Context, ) -> Result<(ElicitationEntryId, Task), acp::Error> { - Self::validate_request(&request, cx)?; + Self::validate_request(&request)?; let (id, response_rx) = self.insert_pending_elicitation(request); cx.emit(ElicitationStoreEvent::ElicitationRequested(id.clone())); cx.notify(); @@ -3485,7 +3481,7 @@ impl AcpThread { request: acp::CreateElicitationRequest, cx: &mut Context, ) -> Result<(ElicitationEntryId, Task), acp::Error> { - ElicitationStore::validate_request(&request, cx)?; + ElicitationStore::validate_request(&request)?; let (id, response_rx) = self.elicitations.insert_pending_elicitation(request); self.push_entry(AgentThreadEntry::Elicitation(id.clone()), cx); @@ -7416,7 +7412,7 @@ mod tests { } #[gpui::test] - async fn test_elicitation_requires_acp_beta_flag(cx: &mut TestAppContext) { + async fn test_elicitation_is_available_without_acp_beta_flag(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { cx.update_flags(false, vec![]); @@ -7438,8 +7434,13 @@ mod tests { ) }); - assert!(result.is_err()); - thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty())); + assert!(result.is_ok()); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries(), + [AgentThreadEntry::Elicitation(_)] + )); + }); } #[gpui::test] diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 3bc33e2a957..cce475268e6 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -129,6 +129,31 @@ impl LinuxWslSandboxError { LinuxWslSandboxError::Other(message) => message.clone(), } } + + /// The slug of the sandboxing docs section that best explains how to resolve + /// this failure, for deep-linking from the UI. Pair with + /// `client::zed_urls::sandboxing_docs`. + pub fn docs_section(&self) -> &'static str { + match self { + // Both "no bwrap" and "only a setuid-root bwrap" are resolved by + // installing a non-setuid Bubblewrap. + LinuxWslSandboxError::BwrapNotFound | LinuxWslSandboxError::SetuidRejected => { + "installing-bubblewrap" + } + // A failed probe on Linux is almost always disabled unprivileged + // user namespaces, which the Ubuntu-specific section covers. + LinuxWslSandboxError::SandboxProbeFailed => "installing-bubblewrap-ubuntu", + // Catch-all (includes WSL/Windows messages): point at the platform + // overview for the current OS. + LinuxWslSandboxError::Other(_) => { + if cfg!(target_os = "windows") { + "windows" + } else { + "linux" + } + } + } + } } impl SandboxWrap { @@ -151,6 +176,15 @@ impl SandboxWrap { /// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical /// path) rather than passing a re-resolvable path. A location that can't be /// captured (e.g. it doesn't exist) is dropped from the grant — fail-closed. + /// + /// This function has **no filesystem side effects**: it never creates paths. + /// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe + /// and by real sandbox construction, and must behave identically. On Linux a + /// writable grant that doesn't exist yet simply can't be captured (bwrap + /// can't bind a missing path), so it's dropped here — the sanctioned way to + /// get a grant to a new directory is the `create_directory` tool, which + /// creates it (pinning the inode) before the grant is recorded. On macOS a + /// missing leaf still canonicalizes, so such grants are captured directly. fn to_policy(&self) -> sandbox::SandboxPolicy { let protected_paths = self .protected_paths @@ -164,12 +198,11 @@ impl SandboxWrap { .writable_paths .iter() .chain(self.extra_write_paths.iter()) - .filter_map(|path| { - // Create not-yet-existing writable grants (e.g. an approved - // scratch dir) so they can be captured and bound; best-effort. - let _ = std::fs::create_dir_all(path); - sandbox::HostFilesystemLocation::new(path).ok() - }) + // Capture only — never create anything here (see the doc comment): + // materializing an approved-but-missing grant is deferred to + // `Sandbox::new` so it can never happen during the `can_create` + // probe, before the user has approved the grant. + .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok()) .collect(); sandbox::SandboxFsPolicy::Restricted { writable_paths, diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index a09aa9c2c13..8e17b290548 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -281,7 +281,9 @@ impl DbThread { name: tool_use.name.into(), raw_input: serde_json::to_string(&tool_use.input) .unwrap_or_default(), - input: tool_use.input, + input: language_model::LanguageModelToolUseInput::Json( + tool_use.input, + ), is_input_complete: true, thought_signature: None, }, diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index e8d6589601e..90599fd6551 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -5,10 +5,9 @@ //! 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 the user has the `sandboxing` feature -//! flag turned on, the project is local, the platform has an integration, and -//! the user has not persistently allowed unsandboxed execution (the -//! `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed` +//! The current policy is: enabled iff the project is local, the platform has an +//! integration, and the user has not persistently allowed unsandboxed execution +//! (the `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed` //! persistently turns sandboxing off for the model-facing surface entirely: //! the plain (non-sandboxed) `terminal` tool is exposed and the system prompt //! omits the sandbox section, since every command would run without a wrap @@ -19,14 +18,12 @@ //! //! macOS (Seatbelt), Linux (Bubblewrap), and Windows (Bubblewrap via WSL) //! have real sandbox integrations; on platforms without one the per-command -//! wrap is a no-op, so commands run with the agent's ambient permissions even -//! when the flag is on. +//! wrap is a no-op, so commands run with the agent's ambient permissions. //! //! Naming note: this module is about agent terminal sandboxing specifically. //! Other agent operations (e.g. file edits) are gated separately. use agent_settings::{AgentSettings, SandboxPermissions}; -use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag}; use gpui::App; use http_proxy::HostPattern; use project::Project; @@ -176,12 +173,6 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy SandboxPolicy { fs, network } } -/// 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 { - cx.has_flag::() -} - /// Whether the sandboxed terminal can be exposed for this project. /// /// The persistent `allow_unsandboxed` setting turns sandboxing off for the @@ -193,20 +184,19 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool { /// prompt in place, since the model is still operating in the sandbox model and /// only escaping individual commands (tracked in `ThreadSandboxGrants`). pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool { - sandboxing_available_for_project(project, cx) + sandboxing_available_for_project(project) && !AgentSettings::get_global(cx) .sandbox_permissions .allow_unsandboxed } -/// Whether sandboxing is *applicable* for this project at all — the feature is -/// enabled, the project is local, and the platform has a sandbox integration — -/// independent of the persistent `allow_unsandboxed` setting. Used by the UI to -/// distinguish "sandboxing isn't relevant here" (don't show the indicator) from -/// "sandboxing is available but turned off in settings" (show it, struck out). -pub(crate) fn sandboxing_available_for_project(project: &Project, cx: &App) -> bool { - sandboxing_enabled(cx) - && project.is_local() +/// Whether sandboxing is *applicable* for this project at all — the project is +/// local and the platform has a sandbox integration — independent of the +/// persistent `allow_unsandboxed` setting. Used by the UI to distinguish +/// "sandboxing isn't relevant here" (don't show the indicator) from "sandboxing +/// is available but turned off in settings" (show it, struck out). +pub(crate) fn sandboxing_available_for_project(project: &Project) -> bool { + project.is_local() && cfg!(any( target_os = "macos", target_os = "linux", diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 160c94328d1..233a0e45215 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -198,7 +198,7 @@ You can request elevated permissions on individual `terminal` calls: - `allow_hosts: ["github.com", "*.npmjs.org"]` — allow outbound HTTP/HTTPS to specific hosts (exact hostnames or leading-`*.` subdomain wildcards; no IP literals). Prefer this whenever you know which hosts the command needs. - `allow_all_hosts: true` — lift the network restriction entirely: outbound access to any host over any protocol, so SSH, FTP, and raw sockets work too (unlike `allow_hosts`, which is HTTP/HTTPS-only). Use only when the specific hosts can't be enumerated up front. {{/if}} -- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. Git metadata paths cannot be requested and will never be made writable while sandboxed. +- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. Each path must be an existing directory. To write into a directory that doesn't exist yet, first create it with the `create_directory` tool (which creates it and grants write access to exactly that directory) rather than requesting write access to a broad existing parent. Git metadata paths cannot be requested and will never be made writable while sandboxed. - `allow_fs_write_all: true` — allow unrestricted filesystem writes except protected Git metadata. Only use this when the specific paths can't be enumerated up front. - `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice, including when a command must write Git metadata. diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 4998a210b8a..73415f55af4 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -689,7 +689,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { id: "tool_1".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }; @@ -840,17 +840,25 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) { assert_eq!(last_tool_use.name.as_ref(), "word_list"); if tool_call.status == acp::ToolCallStatus::Pending { if !last_tool_use.is_input_complete - && last_tool_use.input.get("g").is_none() + && last_tool_use + .input + .as_json() + .and_then(|input| input.get("g")) + .is_none() { saw_partial_tool_use = true; } } else { last_tool_use .input + .as_json() + .expect("tool input should be JSON") .get("a") .expect("'a' has streamed because input is now complete"); last_tool_use .input + .as_json() + .expect("tool input should be JSON") .get("g") .expect("'g' has streamed because input is now complete"); } @@ -884,7 +892,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -894,7 +902,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_2".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -951,7 +959,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_3".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -990,7 +998,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { id: "tool_id_4".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1029,7 +1037,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) { id: "tool_id_1".into(), name: "nonexistent_tool".into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1533,7 +1541,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { id: "tool_1".into(), name: "echo".into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }, @@ -1577,7 +1585,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { id: "tool_2".into(), name: "test_server_echo".into(), raw_input: json!({"text": "mcp"}).to_string(), - input: json!({"text": "mcp"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "mcp"})), is_input_complete: true, thought_signature: None, }, @@ -1587,7 +1595,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { id: "tool_3".into(), name: "echo".into(), raw_input: json!({"text": "native"}).to_string(), - input: json!({"text": "native"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "native"})), is_input_complete: true, thought_signature: None, }, @@ -1697,7 +1705,7 @@ async fn test_mcp_tool_names_are_sanitized_for_providers(cx: &mut TestAppContext id: "tool_1".into(), name: "snake_case_PascalCase".into(), raw_input: json!({}).to_string(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1786,7 +1794,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) { id: "tool_1".into(), name: "screenshot".into(), raw_input: json!({}).to_string(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -1936,7 +1944,9 @@ async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAp name: "issue_read".into(), raw_input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}) .to_string(), - input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}), + ), is_input_complete: true, thought_signature: None, }, @@ -2351,7 +2361,9 @@ async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2446,7 +2458,7 @@ async fn test_cancellation_aware_tool_responds_to_cancellation(cx: &mut TestAppC id: "cancellation_aware_1".into(), name: "cancellation_aware".into(), raw_input: r#"{}"#.into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -2632,7 +2644,9 @@ async fn test_truncate_while_terminal_tool_running(cx: &mut TestAppContext) { id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2697,7 +2711,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext) id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2707,7 +2723,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext) id: "terminal_tool_2".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 2000", "cd": "."}"#.into(), - input: json!({"command": "sleep 2000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 2000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2811,7 +2829,9 @@ async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppCon id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(), - input: json!({"command": "sleep 1000", "cd": "."}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": "."}), + ), is_input_complete: true, thought_signature: None, }, @@ -2907,7 +2927,9 @@ async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) { id: "terminal_tool_1".into(), name: TerminalTool::NAME.into(), raw_input: r#"{"command": "sleep 1000", "cd": ".", "timeout_ms": 100}"#.into(), - input: json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}), + input: language_model::LanguageModelToolUseInput::Json( + json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}), + ), is_input_complete: true, thought_signature: None, }, @@ -3376,7 +3398,7 @@ async fn test_cumulative_token_usage(cx: &mut TestAppContext) { id: "tool_1".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "hello"}).to_string(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, @@ -3786,7 +3808,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }; @@ -3794,7 +3816,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { id: "tool_id_2".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }; @@ -3992,7 +4014,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { id: "1".into(), name: EchoTool::NAME.into(), raw_input: input.to_string(), - input, + input: language_model::LanguageModelToolUseInput::Json(input), is_input_complete: false, thought_signature: None, }, @@ -4005,7 +4027,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { id: "1".into(), name: "echo".into(), raw_input: input.to_string(), - input, + input: language_model::LanguageModelToolUseInput::Json(input), is_input_complete: true, thought_signature: None, }, @@ -4175,7 +4197,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { id: "tool_1".into(), name: EchoTool::NAME.into(), raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})), is_input_complete: true, thought_signature: None, }; @@ -4323,7 +4345,7 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input( id: "tool_1".into(), name: "streaming_echo".into(), raw_input: r#"{"text": "partial"}"#.into(), - input: json!({"text": "partial"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})), is_input_complete: false, thought_signature: None, }; @@ -4428,7 +4450,7 @@ async fn test_streaming_tool_json_parse_error_is_forwarded_to_running_tool( id: "tool_1".into(), name: StreamingJsonErrorContextTool::NAME.into(), raw_input: r#"{"text": "partial"#.into(), - input: json!({"text": "partial"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})), is_input_complete: false, thought_signature: None, }; @@ -5228,7 +5250,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5362,7 +5386,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5509,7 +5535,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5638,7 +5666,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -5699,7 +5729,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { id: "subagent_2".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&resume_tool_input).unwrap(), - input: serde_json::to_value(&resume_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&resume_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6285,7 +6317,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6410,7 +6444,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6476,7 +6512,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu id: "subagent_2".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&resume_tool_input).unwrap(), - input: serde_json::to_value(&resume_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&resume_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -6583,7 +6621,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { id: "subagent_1".into(), name: SpawnAgentTool::NAME.into(), raw_input: serde_json::to_string(&subagent_tool_input).unwrap(), - input: serde_json::to_value(&subagent_tool_input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&subagent_tool_input).unwrap(), + ), is_input_complete: true, thought_signature: None, }; @@ -7350,6 +7390,194 @@ async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext) ); } +/// A granted host that redirects to a loopback target must not have that +/// redirect followed: loopback hosts can't be granted individually, so the hop +/// is refused just like a direct loopback fetch. This is the redirect variant of +/// the SSRF protection — the approved domain can't be used to bounce the request +/// onto the local machine. +#[gpui::test] +async fn test_fetch_tool_refuses_redirect_to_loopback(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::create(|req| async move { + let uri = req.uri().to_string(); + assert!( + uri.contains("example.com"), + "the loopback redirect target must never be requested, but saw {uri}" + ); + Ok(gpui::http_client::Response::builder() + .status(302) + .header("location", "http://localhost:3000/internal") + .body("".into()) + .unwrap()) + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, _rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap(); + + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + let result = task.await; + assert!( + result.is_err(), + "expected a redirect to a loopback host to be refused" + ); + assert!( + result.unwrap_err().contains("unsandboxed"), + "error should point at unsandboxed access as the way to reach loopback hosts" + ); +} + +/// A granted host that redirects to a *different*, ungranted host triggers a +/// fresh per-host authorization prompt for the redirect target — the redirect is +/// not silently followed to a host the user never approved. +#[gpui::test] +async fn test_fetch_tool_reauthorizes_redirect_to_new_host(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::create(|req| async move { + let uri = req.uri().to_string(); + assert!( + uri.contains("example.com"), + "the ungranted redirect target must not be requested before authorization, \ + but saw {uri}" + ); + Ok(gpui::http_client::Response::builder() + .status(302) + .header("location", "https://redirect-target.example/landing") + .body("".into()) + .unwrap()) + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap(); + + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + + cx.run_until_parked(); + + let authorization = rx.expect_authorization().await; + let details = + acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) + .expect("a redirect to an ungranted host should request a sandbox network grant"); + assert_eq!( + details.network_hosts, + vec!["redirect-target.example".to_string()] + ); + assert!(!details.network_all_hosts); +} + +/// Redirects between paths on an already-granted host are followed without any +/// additional prompt, so ordinary redirects (http→https upgrades, trailing-slash +/// canonicalization, etc.) keep working after the per-hop authorization change. +#[gpui::test] +async fn test_fetch_tool_follows_same_host_redirect(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + FetchTool::NAME.into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Allow), + always_allow: vec![], + always_deny: vec![], + always_confirm: vec![], + invalid_patterns: vec![], + }, + ); + settings + .sandbox_permissions + .network_hosts + .push("example.com".into()); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let http_client = gpui::http_client::FakeHttpClient::create(|req| async move { + let uri = req.uri().to_string(); + if uri.ends_with("/start") { + Ok(gpui::http_client::Response::builder() + .status(302) + .header("location", "https://example.com/final") + .body("".into()) + .unwrap()) + } else if uri.ends_with("/final") { + Ok(gpui::http_client::Response::builder() + .status(200) + .header("content-type", "text/plain") + .body("final content".into()) + .unwrap()) + } else { + panic!("unexpected request to {uri}"); + } + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::FetchTool::new(http_client)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let input: crate::FetchToolInput = + serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap(); + + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); + let result = task.await; + assert_eq!( + result.expect("same-host redirect should succeed"), + "final content" + ); + + let event = rx.try_recv(); + assert!( + !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))), + "expected no authorization prompt for a redirect to an already-granted host" + ); +} + /// Approving one pending tool call with "Always for " auto-resolves /// sibling pending authorizations for the same tool in the same turn. #[gpui::test] @@ -7372,7 +7600,7 @@ async fn test_always_allow_resolves_pending_authorizations(cx: &mut TestAppConte id: id.into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7451,7 +7679,7 @@ async fn test_external_settings_edit_resolves_pending_authorization(cx: &mut Tes id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7522,7 +7750,7 @@ async fn test_external_deny_rule_resolves_pending_authorization(cx: &mut TestApp id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7598,7 +7826,7 @@ async fn test_unrelated_settings_change_does_not_resolve_pending_authorization( id: "tool_id_1".into(), name: ToolRequiringPermission::NAME.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7668,7 +7896,7 @@ async fn test_always_allow_does_not_resolve_unrelated_tool_authorization(cx: &mu id: id.into(), name: name.into(), raw_input: "{}".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }, @@ -7765,7 +7993,7 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) { id: "tool_1".into(), name: "echo".into(), raw_input: r#"{"text": "hello"}"#.into(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, @@ -7847,7 +8075,7 @@ async fn test_queued_message_does_not_end_turn_without_boundary_flag(cx: &mut Te id: "tool_1".into(), name: "echo".into(), raw_input: r#"{"text": "hello"}"#.into(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, @@ -7914,7 +8142,7 @@ async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestA id: "call_1".into(), name: StreamingFailingEchoTool::NAME.into(), raw_input: "hello".into(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: false, thought_signature: None, }; @@ -7996,7 +8224,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te id: "call_1".into(), name: StreamingEchoTool::NAME.into(), raw_input: "hello".into(), - input: json!({ "text": "hello" }), + input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })), is_input_complete: false, thought_signature: None, }, @@ -8005,7 +8233,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te id: "call_1".into(), name: StreamingEchoTool::NAME.into(), raw_input: "hello world".into(), - input: json!({ "text": "hello world" }), + input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello world" })), is_input_complete: true, thought_signature: None, }; @@ -8015,7 +8243,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te let second_tool_use = LanguageModelToolUse { name: StreamingFailingEchoTool::NAME.into(), raw_input: "hello".into(), - input: json!({ "text": "hello" }), + input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })), is_input_complete: false, thought_signature: None, id: "call_2".into(), @@ -8144,7 +8372,7 @@ async fn test_mid_turn_model_and_settings_refresh(cx: &mut TestAppContext) { id: "tool_1".into(), name: "echo".into(), raw_input: r#"{"text":"hello"}"#.into(), - input: json!({"text": "hello"}), + input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})), is_input_complete: true, thought_signature: None, }, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 48b86de73b9..fe5c9f2004c 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -607,7 +607,7 @@ impl AgentMessage { "{}\n", MarkdownCodeBlock { tag: "json", - text: &format!("{:#}", tool_use.input) + text: &format!("{:#}", tool_use.input.to_display_json()) } )); } @@ -1593,7 +1593,7 @@ impl Thread { .unbounded_send(Ok(ThreadEvent::ToolCall( acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) .status(status) - .raw_input(tool_use.input.clone()), + .raw_input(tool_use.input.to_display_json()), ))) .ok(); let mut fields = acp::ToolCallUpdateFields::new() @@ -1606,15 +1606,12 @@ impl Thread { return; }; - let title = tool.initial_title(tool_use.input.clone(), cx); + let Ok(input) = tool_use.input.clone().into_json() else { + return; + }; + let title = tool.initial_title(input.clone(), cx); let kind = tool.kind(); - stream.send_tool_call( - &tool_use.id, - &tool_use.name, - title, - kind, - tool_use.input.clone(), - ); + stream.send_tool_call(&tool_use.id, &tool_use.name, title, kind, input.clone()); if let Some(content) = replay_content { stream.update_tool_call_fields( @@ -1635,8 +1632,7 @@ impl Thread { self.sandbox_grants.clone(), Some(cx.weak_entity()), ); - tool.replay(tool_use.input.clone(), output, tool_event_stream, cx) - .log_err(); + tool.replay(input, output, tool_event_stream, cx).log_err(); } stream.update_tool_call_fields( @@ -1850,12 +1846,12 @@ impl Thread { sandboxing_enabled_for_project(self.project.read(cx), cx) } - /// Whether sandboxing is *applicable* for this thread's project (feature on, - /// local project, supported platform), regardless of whether it's been - /// turned off in settings. The UI shows the sandbox indicator whenever this - /// is true, drawing it struck-out when sandboxing is disabled. + /// Whether sandboxing is *applicable* for this thread's project (local + /// project, supported platform), regardless of whether it's been turned off + /// in settings. The UI shows the sandbox indicator whenever this is true, + /// drawing it struck-out when sandboxing is disabled. pub fn sandboxing_available(&self, cx: &App) -> bool { - sandboxing_available_for_project(self.project.read(cx), cx) + sandboxing_available_for_project(self.project.read(cx)) } /// The directory subtrees the sandbox always grants write access to for this @@ -3418,7 +3414,9 @@ impl Thread { let mut title = SharedString::from(&tool_use.name); let mut kind = acp::ToolKind::Other; if let Some(tool) = tool.as_ref() { - title = tool.initial_title(tool_use.input.clone(), cx); + if let Ok(input) = tool_use.input.clone().into_json() { + title = tool.initial_title(input, cx); + } kind = tool.kind(); } @@ -3435,16 +3433,33 @@ impl Thread { })); }; + // Agent tools are JSON-schema tools. Custom text-tool deltas are rejected + // before considering partial-vs-complete input for these local tools. + let input = match tool_use.input.clone().into_json() { + Ok(input) => input, + Err(error) => { + return Some(Task::ready(LanguageModelToolResult { + content: vec![LanguageModelToolResultContent::Text(Arc::from( + error.to_string(), + ))], + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + output: None, + })); + } + }; + if !tool_use.is_input_complete { if tool.supports_input_streaming() { let running_turn = self.running_turn.as_mut()?; if let Some(sender) = running_turn.streaming_tool_inputs.get_mut(&tool_use.id) { - sender.send_partial(tool_use.input); + sender.send_partial(input); return None; } let (mut sender, tool_input) = ToolInputSender::channel(); - sender.send_partial(tool_use.input); + sender.send_partial(input); running_turn .streaming_tool_inputs .insert(tool_use.id.clone(), sender); @@ -3471,12 +3486,12 @@ impl Thread { .streaming_tool_inputs .remove(&tool_use.id) { - sender.send_full(tool_use.input); + sender.send_full(input); return None; } log::debug!("Running tool {}", tool_use.name); - let tool_input = ToolInput::ready(tool_use.input); + let tool_input = ToolInput::ready(input); Some(self.run_tool( tool, tool_input, @@ -3600,7 +3615,7 @@ impl Thread { id: tool_use_id, name: tool_name, raw_input: raw_input.to_string(), - input: serde_json::json!({}), + input: language_model::LanguageModelToolUseInput::Json(serde_json::json!({})), is_input_complete: true, thought_signature: None, }; @@ -3676,7 +3691,7 @@ impl Thread { &tool_use.name, title, kind, - tool_use.input.clone(), + tool_use.input.to_display_json(), ); last_message .content @@ -3687,7 +3702,7 @@ impl Thread { acp::ToolCallUpdateFields::new() .title(title.as_str()) .kind(kind) - .raw_input(tool_use.input.clone()), + .raw_input(tool_use.input.to_display_json()), None, ); } @@ -3927,12 +3942,12 @@ impl Thread { .iter() .filter_map(|(tool_name, tool)| { log::trace!("Including tool: {}", tool_name); - Some(LanguageModelRequestTool { - name: tool_name.to_string(), - description: tool.description().to_string(), - input_schema: tool.input_schema(model.tool_input_format()).log_err()?, - use_input_streaming: tool.supports_input_streaming(), - }) + Some(LanguageModelRequestTool::function( + tool_name.to_string(), + tool.description().to_string(), + tool.input_schema(model.tool_input_format()).log_err()?, + tool.supports_input_streaming(), + )) }) .collect::>() } else { @@ -5911,10 +5926,15 @@ impl ToolCallEventStream { &self, command: Option, reason: String, + docs_section: Option, retries: usize, cx: &mut App, ) -> Task> { - let details = acp_thread::SandboxFallbackAuthorizationDetails { command, reason }; + let details = acp_thread::SandboxFallbackAuthorizationDetails { + command, + reason, + docs_section, + }; let retry_label = if retries == 0 { "Retry".to_string() } else { @@ -7671,6 +7691,7 @@ mod tests { event_stream.authorize_sandbox_fallback( Some("cargo build".to_string()), "bwrap not found on PATH".to_string(), + Some("installing-bubblewrap".to_string()), 0, cx, ) @@ -7682,6 +7703,10 @@ mod tests { .expect("fallback authorization should include details"); assert_eq!(details.command.as_deref(), Some("cargo build")); assert_eq!(details.reason, "bwrap not found on PATH"); + assert_eq!( + details.docs_section.as_deref(), + Some("installing-bubblewrap") + ); let acp_thread::PermissionOptions::Flat(options) = &authorization.options else { panic!("expected flat fallback permission options"); @@ -7722,6 +7747,7 @@ mod tests { event_stream.authorize_sandbox_fallback( None, "probe failed".to_string(), + None, retries, cx, ) @@ -7766,6 +7792,7 @@ mod tests { event_stream.authorize_sandbox_fallback( Some("cargo build".to_string()), "user namespaces are disabled".to_string(), + None, 0, cx, ) @@ -7795,7 +7822,13 @@ mod tests { let (event_stream, mut receiver) = ToolCallEventStream::test(); let authorize = cx.update(|cx| { - event_stream.authorize_sandbox_fallback(None, "bwrap probe failed".to_string(), 0, cx) + event_stream.authorize_sandbox_fallback( + None, + "bwrap probe failed".to_string(), + None, + 0, + cx, + ) }); let authorization = receiver.expect_authorization().await; authorization @@ -7870,7 +7903,7 @@ mod tests { id: registered_tool_use_id.clone(), name: ReplayImageTool::NAME.into(), raw_input: "null".to_string(), - input: json!(null), + input: language_model::LanguageModelToolUseInput::Json(json!(null)), is_input_complete: true, thought_signature: None, }; @@ -7878,7 +7911,7 @@ mod tests { id: missing_tool_use_id.clone(), name: "missing_image_tool".into(), raw_input: "{}".to_string(), - input: json!({}), + input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }; @@ -8185,7 +8218,10 @@ mod tests { assert_eq!(tool_use.raw_input, raw_input.to_string()); assert!(tool_use.is_input_complete); // Should fall back to empty object for invalid JSON - assert_eq!(tool_use.input, json!({})); + assert_eq!( + tool_use.input, + language_model::LanguageModelToolUseInput::Json(json!({})) + ); } _ => panic!("Expected ToolUse content"), } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 452b4a01d09..85da6bbb214 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -153,12 +153,12 @@ macro_rules! tools { /// A list of all built-in tools pub fn built_in_tools() -> impl Iterator { fn language_model_tool() -> LanguageModelRequestTool { - LanguageModelRequestTool { - name: T::NAME.to_string(), - description: T::description().to_string(), - input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(), - use_input_streaming: T::supports_input_streaming(), - } + LanguageModelRequestTool::function( + T::NAME.to_string(), + T::description().to_string(), + T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(), + T::supports_input_streaming(), + ) } [ $( diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index 308e7b91458..22918c43154 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -5,7 +5,7 @@ use super::tool_permissions::{ use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -17,12 +17,13 @@ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, authorize_with_sensitive_settings, decide_permission_for_path, }; -use std::path::Path; +use std::path::{Path, PathBuf}; -/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created. +/// Creates a new directory at the specified path, and all necessary parent directories. 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. +/// Use this whenever you need to create new directories. Paths inside the project are created directly. +/// +/// This tool can also create a directory **outside** the project. When agent terminal commands are sandboxed, doing so grants those commands write access to exactly that new directory — so, rather than requesting write access to a broad existing parent (e.g. your home directory) just to create something inside it, create the specific directory here first and then write into it. The only other 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. @@ -40,6 +41,13 @@ pub struct CreateDirectoryToolInput { /// To create a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`. /// pub path: String, + + /// Justification for creating a directory **outside** the project, shown to + /// the user (attributed to you) in the approval prompt that grants sandboxed + /// terminal commands write access to it. Required only for out-of-project + /// paths; ignored for paths inside the project or the global skills dir. + #[serde(default)] + pub reason: Option, } pub struct CreateDirectoryTool { @@ -83,6 +91,28 @@ impl AgentTool for CreateDirectoryTool { let project = self.project.clone(); cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; + + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); + + // Resolve where this directory lives. The global agent-skills dir is a + // special case allowed outside the project; anything else outside the + // project is handled as a narrow sandbox write grant below. + let global_skill_directory = + resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await; + let in_project = project.read_with(cx, |project, cx| { + project.find_project_path(&input.path, cx).is_some() + }); + + // A path outside the project (and not the global skills dir) can only + // be created as a narrow sandbox write grant: create the directory and + // grant sandboxed terminal commands write access to exactly it. The + // sandbox approval prompt — which shows the real, canonicalized target + // — fully replaces the normal permission and symlink-escape prompts + // here. + if global_skill_directory.is_none() && !in_project { + return create_out_of_project_directory(&project, &input, &event_stream, cx).await; + } + let decision = cx.update(|cx| { decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx)) }); @@ -93,7 +123,6 @@ impl AgentTool for CreateDirectoryTool { let destination_path: Arc = input.path.as_str().into(); - let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; let symlink_escape_target = project.read_with(cx, |project, cx| { @@ -149,9 +178,7 @@ 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 - { + if let Some(global_skill_directory) = global_skill_directory { futures::select! { result = fs.create_dir(&global_skill_directory).fuse() => { result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?; @@ -185,6 +212,100 @@ impl AgentTool for CreateDirectoryTool { } } +/// Create a directory that lives **outside** the project by granting sandboxed +/// terminal commands write access to exactly it. +/// +/// The directory is created (Linux: eagerly, pinning the inode; macOS: after +/// approval) and the user is shown the real, canonicalized target in the sandbox +/// approval prompt — which is what defends against a concurrent symlink swap: the +/// grant is always against the inode/path the user actually saw. On denial, only +/// the directories we created are removed. +async fn create_out_of_project_directory( + project: &Entity, + input: &CreateDirectoryToolInput, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, +) -> Result { + // Narrowing a grant to a brand-new directory only makes sense when the + // project's terminal commands are sandboxed, and only on platforms that can + // grant a not-yet-existing directory. Otherwise keep the historical + // "outside the project" rejection. + let sandboxing = project.read_with(cx, |project, cx| { + crate::sandboxing::sandboxing_enabled_for_project(project, cx) + }); + let platform_supported = cfg!(any(target_os = "linux", target_os = "macos")); + if !sandboxing || !platform_supported { + return Err("Path to create was outside the project".to_string()); + } + + let Some(reason) = input + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + else { + return Err( + "Creating a directory outside the project grants sandboxed terminal commands write \ + access to it, so a `reason` is required: briefly justify why the directory is needed, \ + then try again." + .to_string(), + ); + }; + let reason = reason.to_string(); + + let absolute = resolve_absolute_path(project, &input.path, cx) + .ok_or_else(|| format!("Couldn't resolve `{}` to an absolute path.", input.path))?; + + let prepared = cx + .background_spawn(async move { sandbox::GrantableWriteDir::prepare(&absolute) }) + .await + .map_err(|error| format!("Creating directory {}: {error}", input.path))?; + + let canonical = prepared.canonical_path().to_path_buf(); + let request = crate::sandboxing::SandboxRequest { + write_paths: vec![canonical.clone()], + ..Default::default() + }; + + let approve = cx.update(|cx| event_stream.authorize_sandbox(request, reason, cx)); + match approve.await { + Ok(()) => { + let display = canonical.display().to_string(); + cx.background_spawn(async move { prepared.finalize() }) + .await + .map_err(|error| format!("Creating directory {display}: {error}"))?; + Ok(format!("Created directory {display}")) + } + Err(error) => { + // Roll back exactly what we created; leave the user no litter. + cx.background_spawn(async move { prepared.discard() }).await; + Err(format!("Create directory cancelled: {error}")) + } + } +} + +/// Resolve a model-provided path to an absolute, lexically-normalized path. +/// Relative paths are joined onto the first worktree root. +fn resolve_absolute_path( + project: &Entity, + raw: &str, + cx: &mut AsyncApp, +) -> Option { + let path = Path::new(raw); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + let base = project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + })?; + base.join(path) + }; + util::paths::normalize_lexically(&absolute).ok() +} + #[cfg(test)] mod tests { use super::*; @@ -231,7 +352,10 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( - ToolInput::resolved(CreateDirectoryToolInput { path: input_path }), + ToolInput::resolved(CreateDirectoryToolInput { + path: input_path, + reason: None, + }), event_stream, cx, ) @@ -286,6 +410,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: outside_path.to_string_lossy().into_owned(), + reason: None, }), event_stream, cx, @@ -342,6 +467,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -404,6 +530,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -463,6 +590,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -545,6 +673,7 @@ mod tests { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), + reason: None, }), event_stream, cx, @@ -561,4 +690,107 @@ mod tests { "Deny policy should not emit symlink authorization prompt", ); } + + /// Out-of-project creation goes through the sandbox write-grant prompt and, + /// on approval, creates the *specific* new directory (not its broad parent). + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[gpui::test] + async fn test_create_directory_out_of_project_creates_and_grants(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + // The sandbox create path operates on the *real* filesystem, so use a + // real directory outside the (fake) project. + let scratch = tempfile::tempdir().unwrap(); + let target = scratch.path().join("new_grant_dir"); + assert!(!target.exists()); + + let tool = Arc::new(CreateDirectoryTool::new(project)); + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let path_input = target.to_string_lossy().into_owned(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CreateDirectoryToolInput { + path: path_input, + reason: Some("scratch space for the build".into()), + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + let details = acp_thread::sandbox_authorization_details_from_meta(&auth.tool_call.meta) + .expect("out-of-project create should request a sandbox write grant"); + // The grant is for exactly the new directory, not its parent. + assert_eq!( + details.write_paths, + vec![scratch.path().canonicalize().unwrap().join("new_grant_dir")] + ); + + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), + acp::PermissionOptionKind::AllowAlways, + )) + .unwrap(); + + let result = task.await; + assert!(result.is_ok(), "expected success, got {result:?}"); + assert!( + target.is_dir(), + "the new directory should have been created" + ); + } + + /// Denying the grant removes the directory we eagerly created, leaving no + /// trace on the filesystem. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[gpui::test] + async fn test_create_directory_out_of_project_denied_cleans_up(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } })) + .await; + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let scratch = tempfile::tempdir().unwrap(); + let target = scratch.path().join("denied_dir"); + + let tool = Arc::new(CreateDirectoryTool::new(project)); + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let path_input = target.to_string_lossy().into_owned(); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(CreateDirectoryToolInput { + path: path_input, + reason: Some("scratch space".into()), + }), + event_stream, + cx, + ) + }); + + let auth = event_rx.expect_authorization().await; + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), + acp::PermissionOptionKind::RejectOnce, + )) + .unwrap(); + + let result = task.await; + assert!(result.is_err(), "denied create should fail"); + assert!( + !target.exists(), + "denied create should leave no directory behind" + ); + } } diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 8cf5610531d..4734dd6b36c 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -324,6 +324,77 @@ mod tests { assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); } + #[gpui::test] + async fn test_streaming_edit_first_line_missing_indent(cx: &mut TestAppContext) { + // Reproduces https://github.com/zed-industries/zed/issues/60302: the + // first line of the multi-line `old_text` omits its leading + // indentation while subsequent lines include theirs, so the indent + // delta computed from the first line must not be applied to the + // following lines. `old_text` also omits the `self.extra` line, so + // the query lines don't correspond one-to-one to the matched buffer + // rows and the indent pairing must follow the fuzzy match's + // alignment instead of assuming equal line counts. + let content = concat!( + "class Outer:\n", + " def method(self):\n", + " self.kept = \"unchanged\"\n", + " self.target_a = \"before\"\n", + " self.extra = \"row\"\n", + " self.target_b = \"before\"\n", + " self.target_c = \"before\"\n", + " self.target_d = \"before\"\n", + " self.kept_2 = \"unchanged\"\n", + ); + let (edit_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.py": content})).await; + let result = cx + .update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: "root/file.py".into(), + edits: vec![Edit { + old_text: concat!( + "self.target_a = \"before\"\n", + " self.target_b = \"before\"\n", + " self.target_c = \"before\"\n", + " self.target_d = \"before\"", + ) + .into(), + new_text: concat!( + "self.target_a = \"after\"\n", + " self.target_b = \"after\"\n", + " self.target_c = \"after\"\n", + " self.target_d = \"after\"", + ) + .into(), + }], + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + // The matched range includes the `self.extra` row, so it is replaced + // along with the rest of the match. + assert_eq!( + new_text, + concat!( + "class Outer:\n", + " def method(self):\n", + " self.kept = \"unchanged\"\n", + " self.target_a = \"after\"\n", + " self.target_b = \"after\"\n", + " self.target_c = \"after\"\n", + " self.target_d = \"after\"\n", + " self.kept_2 = \"unchanged\"\n", + ) + ); + } + #[gpui::test] async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) { let (edit_tool, _project, _action_log, _fs, _thread) = setup_test( diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index b6f8c0f1cfc..d47a8712333 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -16,7 +16,7 @@ use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry}; use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{AgentLocation, Project, ProjectPath}; -use reindent::{Reindenter, compute_indent_delta}; +use reindent::{Reindenter, compute_indent_delta, compute_rest_indent_delta}; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use std::ops::Range; @@ -527,15 +527,34 @@ impl EditPipeline { ); let buffer_indent = snapshot.line_indent_for_row(line); + let query_lines = matcher.query_lines(); let query_indent = text::LineIndent::from_iter( - matcher - .query_lines() + query_lines .first() .map(|s| s.as_str()) .unwrap_or("") .chars(), ); - let indent_delta = compute_indent_delta(buffer_indent, query_indent); + let first_line_delta = compute_indent_delta(buffer_indent, query_indent); + + // Query row 0 is excluded: its delta is `first_line_delta`, + // which intentionally differs when the model stripped the + // first line's indentation. + let rest_delta = compute_rest_indent_delta( + first_line_delta, + matcher + .line_pairs(&range) + .unwrap_or(&[]) + .iter() + .filter(|(query_row, _)| *query_row != 0) + .filter_map(|(query_row, buffer_row)| { + let query_line = query_lines.get(*query_row as usize)?; + Some(( + snapshot.line_indent_for_row(*buffer_row), + text::LineIndent::from_iter(query_line.chars()), + )) + }), + ); let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::(); @@ -551,7 +570,7 @@ impl EditPipeline { self.current_edit = Some(EditPipelineEntry::StreamingNewText { streaming_diff: StreamingDiff::new(old_text_in_buffer), edit_cursor: range.start, - reindenter: Reindenter::new(indent_delta), + reindenter: Reindenter::with_deltas(first_line_delta, rest_delta), original_snapshot: text_snapshot, }); diff --git a/crates/agent/src/tools/edit_session/reindent.rs b/crates/agent/src/tools/edit_session/reindent.rs index 7f08749e475..ed4066e30d2 100644 --- a/crates/agent/src/tools/edit_session/reindent.rs +++ b/crates/agent/src/tools/edit_session/reindent.rs @@ -1,7 +1,7 @@ use language::LineIndent; use std::{cmp, iter}; -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum IndentDelta { Spaces(isize), Tabs(isize), @@ -31,20 +31,60 @@ pub fn compute_indent_delta(buffer_indent: LineIndent, query_indent: LineIndent) } } +/// Computes the indent delta for the lines after the first, given per-line +/// `(buffer, query)` indents for those lines. +/// +/// When the remaining lines agree on a consistent delta, that delta is +/// returned even if it differs from `first_line_delta`. This handles queries +/// where only the first line's indentation was stripped. When the remaining +/// lines are inconsistent (or all blank), falls back to `first_line_delta`, +/// preserving the uniform re-indentation behavior. +pub fn compute_rest_indent_delta( + first_line_delta: IndentDelta, + indent_pairs: impl IntoIterator, +) -> IndentDelta { + let mut rest_delta = None; + for (buffer_indent, query_indent) in indent_pairs { + if buffer_indent.line_blank || query_indent.line_blank { + continue; + } + let delta = compute_indent_delta(buffer_indent, query_indent); + match rest_delta { + None => rest_delta = Some(delta), + Some(existing) if existing == delta => {} + Some(_) => return first_line_delta, + } + } + rest_delta.unwrap_or(first_line_delta) +} + /// Synchronous re-indentation adapter. Buffers incomplete lines and applies /// an `IndentDelta` to each line's leading whitespace before emitting it. +/// +/// Models sometimes omit the leading indentation only on the first line of +/// `old_text`/`new_text` (e.g. when copying from mid-line context), so the +/// first line and the remaining lines can require different deltas. pub struct Reindenter { - delta: IndentDelta, + first_line_delta: IndentDelta, + rest_delta: IndentDelta, buffer: String, in_leading_whitespace: bool, + on_first_line: bool, } impl Reindenter { - pub fn new(delta: IndentDelta) -> Self { + #[cfg(test)] + fn uniform(delta: IndentDelta) -> Self { + Self::with_deltas(delta, delta) + } + + pub fn with_deltas(first_line_delta: IndentDelta, rest_delta: IndentDelta) -> Self { Self { - delta, + first_line_delta, + rest_delta, buffer: String::new(), in_leading_whitespace: true, + on_first_line: true, } } @@ -70,14 +110,19 @@ impl Reindenter { None => (self.buffer.len(), true), }; let line = &self.buffer[start_ix..line_end]; + let delta = if self.on_first_line { + self.first_line_delta + } else { + self.rest_delta + }; if self.in_leading_whitespace { - if let Some(non_whitespace_ix) = line.find(|c| self.delta.character() != c) { + if let Some(non_whitespace_ix) = line.find(|c| delta.character() != c) { // We found a non-whitespace character, adjust indentation // based on the delta. let new_indent_len = - cmp::max(0, non_whitespace_ix as isize + self.delta.len()) as usize; - indented.extend(iter::repeat(self.delta.character()).take(new_indent_len)); + cmp::max(0, non_whitespace_ix as isize + delta.len()) as usize; + indented.extend(iter::repeat(delta.character()).take(new_indent_len)); indented.push_str(&line[non_whitespace_ix..]); self.in_leading_whitespace = false; } else if is_pending_line && !is_final { @@ -97,6 +142,7 @@ impl Reindenter { break; } else { self.in_leading_whitespace = true; + self.on_first_line = false; indented.push('\n'); start_ix = line_end + 1; } @@ -116,7 +162,7 @@ mod tests { #[test] fn test_indent_single_chunk() { - let mut r = Reindenter::new(IndentDelta::Spaces(2)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(2)); let out = r.push(" abc\n def\n ghi"); // All three lines are emitted: "ghi" starts with spaces but // contains non-whitespace, so it's processed immediately. @@ -127,7 +173,7 @@ mod tests { #[test] fn test_outdent_tabs() { - let mut r = Reindenter::new(IndentDelta::Tabs(-2)); + let mut r = Reindenter::uniform(IndentDelta::Tabs(-2)); let out = r.push("\t\t\t\tabc\n\t\tdef\n\t\t\t\t\t\tghi"); assert_eq!(out, "\t\tabc\ndef\n\t\t\t\tghi"); let out = r.finish(); @@ -136,7 +182,7 @@ mod tests { #[test] fn test_incremental_chunks() { - let mut r = Reindenter::new(IndentDelta::Spaces(2)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(2)); // Feed " ab" — the `a` is non-whitespace, so the line is // processed immediately even without a trailing newline. let out = r.push(" ab"); @@ -151,7 +197,7 @@ mod tests { #[test] fn test_zero_delta() { - let mut r = Reindenter::new(IndentDelta::Spaces(0)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(0)); let out = r.push(" hello\n world\n"); assert_eq!(out, " hello\n world\n"); let out = r.finish(); @@ -160,7 +206,7 @@ mod tests { #[test] fn test_clamp_negative_indent() { - let mut r = Reindenter::new(IndentDelta::Spaces(-10)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(-10)); let out = r.push(" abc\n"); // max(0, 2 - 10) = 0, so no leading spaces. assert_eq!(out, "abc\n"); @@ -170,7 +216,7 @@ mod tests { #[test] fn test_whitespace_only_lines() { - let mut r = Reindenter::new(IndentDelta::Spaces(2)); + let mut r = Reindenter::uniform(IndentDelta::Spaces(2)); let out = r.push(" \n code\n"); // First line is all whitespace — emitted verbatim. Second line is indented. assert_eq!(out, " \n code\n"); @@ -178,6 +224,95 @@ mod tests { assert_eq!(out, ""); } + #[test] + fn test_distinct_first_line_delta() { + // First line's indentation was stripped in the query (delta +8), + // while the remaining lines are already correct (delta 0). Chunks + // split mid-line and mid-indentation to exercise the streaming path, + // and the blank line is passed through verbatim. + let mut r = Reindenter::with_deltas(IndentDelta::Spaces(8), IndentDelta::Spaces(0)); + let mut out = r.push("self.target_a = "); + out.push_str(&r.push("\"after\"\n ")); + out.push_str(&r.push(" self.target_b = \"after\"\n")); + out.push_str(&r.push("\n self.target_c = \"after\"")); + out.push_str(&r.finish()); + assert_eq!( + out, + concat!( + " self.target_a = \"after\"\n", + " self.target_b = \"after\"\n", + "\n", + " self.target_c = \"after\"", + ) + ); + } + + fn line_indent(text: &str) -> LineIndent { + LineIndent::from_iter(text.chars()) + } + + #[test] + fn test_compute_rest_indent_delta() { + let first_line_delta = IndentDelta::Spaces(8); + + // Remaining lines that agree on a delta override the first-line + // delta, and blank lines are skipped when forming the consensus. + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![ + (line_indent(" b"), line_indent(" b")), + (line_indent(""), line_indent("")), + (line_indent(" c"), line_indent(" c")), + ], + ), + IndentDelta::Spaces(0) + ); + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![ + (line_indent(" b"), line_indent(" b")), + (line_indent(" "), line_indent("")), + (line_indent(" c"), line_indent(" c")), + ], + ), + IndentDelta::Spaces(4) + ); + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![(line_indent("\t\tb"), line_indent("\tb"))], + ), + IndentDelta::Tabs(1) + ); + + // Inconsistent remaining lines fall back to the first-line delta... + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![ + (line_indent(" b"), line_indent(" b")), + (line_indent(" c"), line_indent(" c")), + ], + ), + first_line_delta + ); + + // ...and so do all-blank and empty pairings. + assert_eq!( + compute_rest_indent_delta( + first_line_delta, + vec![(line_indent(" "), line_indent(""))], + ), + first_line_delta + ); + assert_eq!( + compute_rest_indent_delta(first_line_delta, vec![]), + first_line_delta + ); + } + #[test] fn test_compute_indent_delta_spaces() { let buffer = LineIndent { diff --git a/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs b/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs index e6a56099a29..3515275f70a 100644 --- a/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs +++ b/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs @@ -12,10 +12,18 @@ pub struct StreamingFuzzyMatcher { query_lines: Vec, line_hint: Option, incomplete_line: String, - matches: Vec>, + matches: Vec, matrix: SearchMatrix, } +/// A match candidate: the matched byte range plus the 0-based +/// `(query_row, buffer_row)` line pairs the search aligned to produce it. +#[derive(Clone, Debug)] +struct SearchMatch { + range: Range, + line_pairs: Vec<(u32, u32)>, +} + impl StreamingFuzzyMatcher { pub fn new(snapshot: TextBufferSnapshot) -> Self { let buffer_line_count = snapshot.max_point().row as usize + 1; @@ -34,6 +42,23 @@ impl StreamingFuzzyMatcher { &self.query_lines } + /// Returns the 0-based `(query_row, buffer_row)` line pairs that the + /// search aligned for the match with the given range. Lines that were + /// skipped on either side of the alignment are absent. + pub fn line_pairs(&self, range: &Range) -> Option<&[(u32, u32)]> { + self.matches + .iter() + .find(|search_match| search_match.range == *range) + .map(|search_match| search_match.line_pairs.as_slice()) + } + + fn match_ranges(&self) -> Vec> { + self.matches + .iter() + .map(|search_match| search_match.range.clone()) + .collect() + } + /// Push a new chunk of text and get the best match found so far. /// /// This method accumulates text chunks and processes complete lines. @@ -62,7 +87,11 @@ impl StreamingFuzzyMatcher { } let best_match = self.select_best_match(); - best_match.or_else(|| self.matches.first().cloned()) + best_match.or_else(|| { + self.matches + .first() + .map(|search_match| search_match.range.clone()) + }) } /// Finish processing and return the final best match(es). @@ -72,26 +101,35 @@ impl StreamingFuzzyMatcher { pub fn finish(&mut self) -> Vec> { // Process any remaining incomplete line if !self.incomplete_line.is_empty() { - if self.matches.len() == 1 { - let range = &mut self.matches[0]; + if let [only_match] = self.matches.as_mut_slice() { + let range = &mut only_match.range; if range.end < self.snapshot.len() && self .snapshot .contains_str_at(range.end + 1, &self.incomplete_line) { range.end += 1 + self.incomplete_line.len(); - return self.matches.clone(); + // Record the line and its alignment so that `query_lines` + // and `line_pairs` stay in sync with the lines covered by + // the returned range. + let extended_row = self.snapshot.offset_to_point(range.end).row; + self.query_lines + .push(std::mem::take(&mut self.incomplete_line)); + only_match + .line_pairs + .push(((self.query_lines.len() - 1) as u32, extended_row)); + return self.match_ranges(); } } - self.query_lines.push(self.incomplete_line.clone()); - self.incomplete_line.clear(); + self.query_lines + .push(std::mem::take(&mut self.incomplete_line)); self.matches = self.resolve_location_fuzzy(); } - self.matches.clone() + self.match_ranges() } - fn resolve_location_fuzzy(&mut self) -> Vec> { + fn resolve_location_fuzzy(&mut self) -> Vec { let new_query_line_count = self.query_lines.len(); let old_query_line_count = self.matrix.rows.saturating_sub(1); if new_query_line_count == old_query_line_count { @@ -167,7 +205,7 @@ impl StreamingFuzzyMatcher { // Find ranges for the matches let mut valid_matches = Vec::new(); for &buffer_row_end in &matches_with_best_cost { - let mut matched_lines = 0; + let mut line_pairs = Vec::new(); let mut query_row = new_query_line_count; let mut buffer_row_start = buffer_row_end; while query_row > 0 && buffer_row_start > 0 { @@ -176,7 +214,7 @@ impl StreamingFuzzyMatcher { SearchDirection::Diagonal => { query_row -= 1; buffer_row_start -= 1; - matched_lines += 1; + line_pairs.push((query_row as u32, buffer_row_start)); } SearchDirection::Up => { query_row -= 1; @@ -186,9 +224,10 @@ impl StreamingFuzzyMatcher { } } } + line_pairs.reverse(); let matched_buffer_row_count = buffer_row_end - buffer_row_start; - let matched_ratio = matched_lines as f32 + let matched_ratio = line_pairs.len() as f32 / (matched_buffer_row_count as f32).max(new_query_line_count as f32); if matched_ratio >= 0.8 { let buffer_start_ix = self @@ -198,11 +237,14 @@ impl StreamingFuzzyMatcher { buffer_row_end - 1, self.snapshot.line_len(buffer_row_end - 1), )); - valid_matches.push((buffer_row_start, buffer_start_ix..buffer_end_ix)); + valid_matches.push(SearchMatch { + range: buffer_start_ix..buffer_end_ix, + line_pairs, + }); } } - valid_matches.into_iter().map(|(_, range)| range).collect() + valid_matches } /// Return the best match with starting position close enough to line_hint. @@ -216,8 +258,8 @@ impl StreamingFuzzyMatcher { return None; } - if self.matches.len() == 1 { - return self.matches.first().cloned(); + if let [only_match] = self.matches.as_slice() { + return Some(only_match.range.clone()); } let Some(line_hint) = self.line_hint else { @@ -228,14 +270,14 @@ impl StreamingFuzzyMatcher { let mut best_match = None; let mut best_distance = u32::MAX; - for range in &self.matches { - let start_point = self.snapshot.offset_to_point(range.start); + for search_match in &self.matches { + let start_point = self.snapshot.offset_to_point(search_match.range.start); let start_line = start_point.row; let distance = start_line.abs_diff(line_hint); if distance <= LINE_HINT_TOLERANCE && distance < best_distance { best_distance = distance; - best_match = Some(range.clone()); + best_match = Some(search_match.range.clone()); } } @@ -831,6 +873,79 @@ mod tests { } } + #[test] + fn test_line_pairs_skip_unmatched_buffer_line() { + let text = indoc! {r#" + class Outer: + def method(self): + self.kept = "unchanged" + self.target_a = "before" + self.extra = "row" + self.target_b = "before" + self.target_c = "before" + self.target_d = "before" + self.kept_2 = "unchanged" + "#}; + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); + let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone()); + + // The query omits the `self.extra` row that sits between the matched + // buffer lines. + matcher.push( + concat!( + " self.target_a = \"before\"\n", + " self.target_b = \"before\"\n", + " self.target_c = \"before\"\n", + " self.target_d = \"before\"\n", + ), + None, + ); + let matches = matcher.finish(); + + assert_eq!(matches.len(), 1); + assert_eq!( + matcher.line_pairs(&matches[0]), + Some(&[(0, 3), (1, 5), (2, 6), (3, 7)][..]) + ); + } + + #[test] + fn test_line_pairs_include_extended_incomplete_line() { + let text = indoc! {r#" + fn on_query_change(&mut self, cx: &mut Context) { + self.filter(cx); + } + + + + fn render_search(&self, cx: &mut Context) -> Div { + div() + } + "#}; + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); + let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone()); + + // The last query line is incomplete and gets appended to the match by + // `finish` via verbatim comparison rather than the fuzzy search. + matcher.push("}\n\n\n\nfn render_search", None); + let matches = matcher.finish(); + + assert_eq!(matches.len(), 1); + assert_eq!( + matcher.line_pairs(&matches[0]), + Some(&[(0, 2), (1, 3), (2, 4), (3, 5), (4, 6)][..]) + ); + assert_eq!(matcher.query_lines().len(), 5); + } + fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec { let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index eb690cdcdf0..4a8537805db 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -503,7 +503,9 @@ impl EditToolTest { if tool_use.is_input_complete && tool_use.name.as_ref() == EditFileTool::NAME => { - let input: EditFileToolInput = serde_json::from_value(tool_use.input) + let input: EditFileToolInput = tool_use + .input + .parse() .context("Failed to parse tool input as EditFileToolInput")?; return Ok(input); } @@ -607,7 +609,9 @@ fn tool_use( id: LanguageModelToolUseId::from(id.into()), name: name.into(), raw_input: serde_json::to_string_pretty(&input).unwrap(), - input: serde_json::to_value(input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(input).unwrap(), + ), is_input_complete: true, thought_signature: None, }) diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index d5c3ac1ba87..31b35c0e539 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -327,7 +327,9 @@ async fn extract_tool_use( Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) if tool_use.is_input_complete && tool_use.name.as_ref() == TerminalTool::NAME => { - let input: TerminalToolInput = serde_json::from_value(tool_use.input) + let input: TerminalToolInput = tool_use + .input + .parse() .context("Failed to parse tool input as TerminalToolInput")?; return Ok(input); } diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index 3fce2b04047..c0fb1de7de7 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -323,7 +323,9 @@ impl WriteToolTest { if tool_use.is_input_complete && tool_use.name.as_ref() == WriteFileTool::NAME => { - let input: WriteFileToolInput = serde_json::from_value(tool_use.input) + let input: WriteFileToolInput = tool_use + .input + .parse() .context("Failed to parse tool input as WriteFileToolInput")?; return Ok(input); } @@ -410,7 +412,9 @@ fn tool_use( id: LanguageModelToolUseId::from(id.into()), name: name.into(), raw_input: serde_json::to_string_pretty(&input).unwrap(), - input: serde_json::to_value(input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(input).unwrap(), + ), is_input_complete: true, thought_signature: None, }) diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs index 96c0fd2aba1..cfe7792c9f1 100644 --- a/crates/agent/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -23,12 +23,38 @@ enum ContentType { Json, } +/// The maximum number of HTTP redirects the fetch tool will follow. Each hop is +/// re-authorized against the shared network grants before being followed. +const MAX_REDIRECTS: usize = 20; + +/// The outcome of a single (non-redirect-following) HTTP request. +enum FetchStep { + /// The server responded with a redirect to this absolute URL. Its host must + /// be authorized before the redirect is followed. + Redirect(String), + /// A terminal response was received and converted to Markdown. + Complete(String), +} + +/// Prepends `https://` when the URL has no explicit HTTP(S) scheme, matching the +/// behavior the fetch tool has always had for user/model-supplied URLs. +fn normalize_url(url: &str) -> Cow<'_, str> { + if !url.starts_with("https://") && !url.starts_with("http://") { + Cow::Owned(format!("https://{url}")) + } else { + Cow::Borrowed(url) + } +} + /// Fetches a URL and returns the content as Markdown. /// /// This tool is not run inside the terminal OS sandbox, but it still refuses to /// reach any host that hasn't been granted network access. It shares the same /// per-host grants as the `terminal` tool: approving a host for one authorizes /// it for the other, whether the grant is for this thread or saved permanently. +/// HTTP redirects are followed one hop at a time, and each hop's host must be +/// granted the same way, so a granted host can't redirect the request to a host +/// that hasn't been approved. /// When unsandboxed access has been granted, these restrictions are lifted /// entirely, matching the terminal, which is also how loopback and IP-literal /// hosts (which can't be granted individually) become reachable. @@ -47,14 +73,35 @@ impl FetchTool { Self { http_client } } - async fn build_message(http_client: Arc, url: &str) -> Result { - let url = if !url.starts_with("https://") && !url.starts_with("http://") { - Cow::Owned(format!("https://{url}")) - } else { - Cow::Borrowed(url) - }; + /// Performs a single HTTP GET *without* following redirects, so the tool can + /// re-authorize each hop against the shared network grants before following + /// it. Returns the redirect target when the server responds with a 3xx, or + /// the final content converted to Markdown otherwise. + async fn fetch_step(http_client: Arc, url: &str) -> Result { + let normalized = normalize_url(url); - let mut response = http_client.get(&url, AsyncBody::default(), true).await?; + let mut response = http_client + .get(&normalized, AsyncBody::default(), false) + .await?; + + let status = response.status(); + if status.is_redirection() { + let location = response + .headers() + .get("location") + .context("redirect response is missing a Location header")? + .to_str() + .context("redirect response has an invalid Location header")?; + let target = url::Url::parse(&normalized) + .with_context(|| format!("could not parse URL {normalized:?}"))? + .join(location) + .with_context(|| format!("invalid redirect target {location:?}"))?; + anyhow::ensure!( + matches!(target.scheme(), "http" | "https"), + "refusing to follow redirect to non-HTTP(S) URL {target}" + ); + return Ok(FetchStep::Redirect(target.to_string())); + } let mut body = Vec::new(); response @@ -63,12 +110,9 @@ impl FetchTool { .await .context("error reading response body")?; - if response.status().is_client_error() { + if status.is_client_error() { let text = String::from_utf8_lossy(body.as_slice()); - bail!( - "status error {}, response: {text:?}", - response.status().as_u16() - ); + bail!("status error {}, response: {text:?}", status.as_u16()); } let Some(content_type) = response.headers().get("content-type") else { @@ -86,7 +130,7 @@ impl FetchTool { ContentType::Html }; - match content_type { + let text = match content_type { ContentType::Html => { let mut handlers: Vec = vec![ Rc::new(RefCell::new(markdown::WebpageChromeRemover)), @@ -96,7 +140,7 @@ impl FetchTool { Rc::new(RefCell::new(markdown::TableHandler::new())), Rc::new(RefCell::new(markdown::StyledTextHandler)), ]; - if url.contains("wikipedia.org") { + if normalized.contains("wikipedia.org") { use html_to_markdown::structure::wikipedia; handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); @@ -108,30 +152,25 @@ impl FetchTool { handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); } - convert_html_to_markdown(&body[..], &mut handlers) + convert_html_to_markdown(&body[..], &mut handlers)? } - ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), + ContentType::Plaintext => std::str::from_utf8(&body)?.to_owned(), ContentType::Json => { let json: serde_json::Value = serde_json::from_slice(&body)?; - Ok(format!( - "```json\n{}\n```", - serde_json::to_string_pretty(&json)? - )) + format!("```json\n{}\n```", serde_json::to_string_pretty(&json)?) } - } + }; + + Ok(FetchStep::Complete(text)) } } /// Extracts the host from a fetch URL as a [`http_proxy::HostPattern`] so it can /// be matched against the shared network grants. Mirrors the scheme handling in -/// [`FetchTool::build_message`] (defaulting to `https://` when none is given). +/// [`normalize_url`] (defaulting to `https://` when none is given). fn host_pattern_for_url(url: &str) -> Result { - let normalized = if !url.starts_with("https://") && !url.starts_with("http://") { - Cow::Owned(format!("https://{url}")) - } else { - Cow::Borrowed(url) - }; + let normalized = normalize_url(url); let parsed = url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?; let host = parsed @@ -211,36 +250,61 @@ impl AgentTool for FetchTool { // already runs without isolation, so we drop fetch's restrictions // too — including reaching hosts that can't be granted individually // (loopback and IP literals). + // + // Crucially, this authorization is applied to every redirect hop as + // well as the initial URL, so a granted host can't 30x-redirect the + // fetch to a host the user never approved. We disable the HTTP + // client's own redirect following and re-run the grant for each hop + // before requesting it. let unsandboxed = cx.update(|cx| event_stream.unsandboxed_access_granted(cx)); - if !unsandboxed { - let host = host_pattern_for_url(&input.url).map_err(|e| e.to_string())?; - let authorize_host = cx.update(|cx| { - let request = SandboxRequest { - network: NetworkRequest::Hosts(vec![host]), - ..Default::default() + + let mut current_url = input.url.clone(); + let mut redirects = 0; + let text = loop { + if !unsandboxed { + let host = host_pattern_for_url(¤t_url).map_err(|e| e.to_string())?; + let authorize_host = cx.update(|cx| { + let request = SandboxRequest { + network: NetworkRequest::Hosts(vec![host]), + ..Default::default() + }; + event_stream.authorize_sandbox(request, String::new(), cx) + }); + futures::select! { + result = authorize_host.fuse() => result.map_err(|e| e.to_string())?, + _ = event_stream.cancelled_by_user().fuse() => { + return Err("Fetch cancelled by user".to_string()); + } }; - event_stream.authorize_sandbox(request, String::new(), cx) + } + + let fetch_task = cx.background_spawn({ + let http_client = http_client.clone(); + let url = current_url.clone(); + async move { Self::fetch_step(http_client, &url).await } }); - futures::select! { - result = authorize_host.fuse() => result.map_err(|e| e.to_string())?, + + let step = futures::select! { + result = fetch_task.fuse() => result.map_err(|e| e.to_string())?, _ = event_stream.cancelled_by_user().fuse() => { return Err("Fetch cancelled by user".to_string()); } }; - } - let fetch_task = cx.background_spawn({ - let http_client = http_client.clone(); - let url = input.url.clone(); - async move { Self::build_message(http_client, &url).await } - }); - - let text = futures::select! { - result = fetch_task.fuse() => result.map_err(|e| e.to_string())?, - _ = event_stream.cancelled_by_user().fuse() => { - return Err("Fetch cancelled by user".to_string()); + match step { + FetchStep::Complete(text) => break text, + FetchStep::Redirect(target) => { + redirects += 1; + if redirects > MAX_REDIRECTS { + return Err(format!( + "exceeded the maximum of {MAX_REDIRECTS} redirects" + )); + } + current_url = target; + } } }; + if text.trim().is_empty() { return Err("no textual content found".to_string()); } diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index ef394c5e8ba..3e7954e5ba6 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -558,8 +558,10 @@ async fn run_terminal_tool( if !path.is_dir() { return Err(format!( "Cannot request sandbox write access to `{}`: on Linux, write access can only \ - be granted to directories that already exist. To create or modify files, \ - request write access to the existing directory that contains them, not the \ + be granted to directories that already exist. To create a new directory to write \ + into, use the `create_directory` tool (which creates it and grants write access to \ + exactly that directory) rather than requesting its parent. To modify existing \ + files, request write access to the existing directory that contains them, not the \ file path itself.", path.display() )); @@ -683,6 +685,7 @@ async fn run_terminal_tool( event_stream.authorize_sandbox_fallback( Some(input.command.clone()), error.user_facing_message(), + Some(error.docs_section().to_string()), retries, cx, ) @@ -788,6 +791,7 @@ async fn run_terminal_tool( event_stream.authorize_sandbox_fallback( Some(input.command.clone()), sandbox_error.user_facing_message(), + Some(sandbox_error.docs_section().to_string()), retries, cx, ) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 884b0efd779..ec3b196272e 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -753,10 +753,7 @@ fn connect_client_future( ) } -fn client_capabilities_for_agent( - agent_id: &AgentId, - supports_beta_features: bool, -) -> acp::ClientCapabilities { +fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities { let mut meta = acp::Meta::from_iter([ ("terminal_output".into(), true.into()), ("terminal-auth".into(), true.into()), @@ -766,7 +763,7 @@ fn client_capabilities_for_agent( meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into()); } - let mut capabilities = acp::ClientCapabilities::new() + acp::ClientCapabilities::new() .fs(acp::FileSystemCapabilities::new() .read_text_file(true) .write_text_file(true)) @@ -778,17 +775,12 @@ fn client_capabilities_for_agent( .boolean(acp::BooleanConfigOptionCapabilities::new()), ), ) - .meta(meta); - - if supports_beta_features { - capabilities = capabilities.elicitation( + .elicitation( acp::ElicitationCapabilities::new() .form(acp::ElicitationFormCapabilities::new()) .url(acp::ElicitationUrlCapabilities::new()), - ); - } - - capabilities + ) + .meta(meta) } impl AcpConnection { @@ -980,10 +972,7 @@ impl AcpConnection { let initialize_response = connection .send_request( acp::InitializeRequest::new(ProtocolVersion::V1) - .client_capabilities(client_capabilities_for_agent( - &agent_id, - cx.update(|cx| cx.has_flag::()), - )) + .client_capabilities(client_capabilities_for_agent(&agent_id)) .client_info( acp::Implementation::new("zed", version) .title(release_channel.map(ToOwned::to_owned)), @@ -2698,7 +2687,6 @@ mod tests { use super::*; use feature_flags::FeatureFlag as _; - use gpui::UpdateGlobal as _; use settings::Settings as _; fn init_feature_flags_test(cx: &mut gpui::TestAppContext) { @@ -2710,50 +2698,15 @@ mod tests { }); } - fn set_acp_beta_override(value: &str, cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - 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.to_string()); - }); - }); - }); - } - #[gpui::test] - async fn client_capabilities_omit_elicitation_without_acp_beta(cx: &mut gpui::TestAppContext) { + async fn client_capabilities_include_elicitation_without_acp_beta( + cx: &mut gpui::TestAppContext, + ) { init_feature_flags_test(cx); - set_acp_beta_override("off", cx); - - let capabilities = cx.update(|cx| { - client_capabilities_for_agent( - &AgentId::new("codex-acp"), - cx.has_flag::(), - ) - }); - - assert!(capabilities.elicitation.is_none()); - } - - #[gpui::test] - async fn client_capabilities_include_elicitation_with_acp_beta(cx: &mut gpui::TestAppContext) { - init_feature_flags_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); - }); - - let capabilities = cx.update(|cx| { - client_capabilities_for_agent( - &AgentId::new("codex-acp"), - cx.has_flag::(), - ) - }); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); let elicitation = capabilities .elicitation - .expect("elicitation should be advertised when acp-beta is enabled"); + .expect("elicitation should always be advertised"); assert!(elicitation.form.is_some()); assert!(elicitation.url.is_some()); @@ -3016,7 +2969,7 @@ mod tests { #[test] fn cursor_client_capabilities_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false); + let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID)); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -3031,7 +2984,7 @@ mod tests { #[test] fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -3041,7 +2994,7 @@ mod tests { #[test] fn client_capabilities_include_boolean_config_options() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); assert!( capabilities @@ -4566,13 +4519,6 @@ fn handle_create_elicitation( cx: &mut AsyncApp, ctx: &ClientContext, ) { - if !cx.update(|cx| cx.has_flag::()) { - return respond_err( - responder, - acp::Error::invalid_params().data("elicitation support requires the ACP beta flag"), - ); - } - match args.scope() { acp::ElicitationScope::Session(scope) => { let thread = match session_thread(ctx, &scope.session_id) { @@ -4661,10 +4607,6 @@ fn handle_complete_elicitation( cx: &mut AsyncApp, ctx: &ClientContext, ) { - if !cx.update(|cx| cx.has_flag::()) { - return; - } - let threads = ctx .sessions .borrow() diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 5d884e8a6ce..e1cce89d3b0 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -414,7 +414,7 @@ impl Default for AgentProfileId { /// combines them with the in-memory per-thread grants. `write_paths` are /// stored as minimal, lexically-normalized subtrees (see /// [`compile_sandbox_permissions`]). -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct SandboxPermissions { /// Allow sandboxed commands to reach any host over the network. pub allow_all_hosts: bool, @@ -432,6 +432,24 @@ pub struct SandboxPermissions { /// tool/prompt in place — see `agent::sandboxing`. pub allow_unsandboxed: bool, pub write_paths: Vec, + /// Whether sandbox escalation prompts warn about domains or write paths + /// that contain potentially confusable Unicode characters (homoglyphs, + /// invisible characters, or bidirectional overrides). Enabled by default. + pub warn_confusable_unicode: bool, +} + +impl Default for SandboxPermissions { + fn default() -> Self { + Self { + allow_all_hosts: false, + network_hosts: Vec::new(), + allow_fs_write_all: false, + allow_unsandboxed: false, + write_paths: Vec::new(), + // The confusable-Unicode warning is a safety net, so it defaults on. + warn_confusable_unicode: true, + } + } } #[derive(Clone, Debug, Default)] @@ -821,6 +839,7 @@ fn compile_sandbox_permissions( allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false), allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false), write_paths, + warn_confusable_unicode: content.warn_confusable_unicode.unwrap_or(true), } } @@ -1098,6 +1117,22 @@ mod tests { fn test_sandbox_permissions_empty() { let permissions = compile_sandbox_permissions(None); assert_eq!(permissions, SandboxPermissions::default()); + // The confusable-Unicode warning is a safety net, so it's on by default. + assert!(permissions.warn_confusable_unicode); + } + + #[test] + fn test_sandbox_permissions_warn_confusable_unicode_can_be_disabled() { + let content: settings::SandboxPermissionsContent = + serde_json::from_value(json!({ "warn_confusable_unicode": false })).unwrap(); + let permissions = compile_sandbox_permissions(Some(content)); + assert!(!permissions.warn_confusable_unicode); + + // Omitting the key keeps the warning enabled. + let content: settings::SandboxPermissionsContent = + serde_json::from_value(json!({})).unwrap(); + let permissions = compile_sandbox_permissions(Some(content)); + assert!(permissions.warn_confusable_unicode); } #[test] diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 822b91c3cd1..3ccf5297380 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -63,6 +63,7 @@ gpui.workspace = true gpui_tokio.workspace = true html_to_markdown.workspace = true http_client.workspace = true +idna.workspace = true indoc.workspace = true itertools.workspace = true jsonschema.workspace = true @@ -108,6 +109,7 @@ theme_settings.workspace = true time.workspace = true ui.workspace = true ui_input.workspace = true +unicode-script.workspace = true unicode-segmentation.workspace = true url.workspace = true util.workspace = true diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 079b043e467..524cb0b0747 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -29,11 +29,11 @@ use std::{ sync::Arc, }; use ui::{CommonAnimationExt, Divider, IconButtonShape, KeyBinding, Tooltip, prelude::*}; -use util::ResultExt; +use util::{ResultExt, truncate_and_trailoff}; use workspace::{ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, - item::{ItemEvent, SaveOptions, TabContentParams}, + item::{ItemEvent, SaveOptions, TabContentParams, TabTooltipContent}, searchable::SearchableItemHandle, }; use zed_actions::assistant::ToggleFocus; @@ -528,23 +528,33 @@ impl Item for AgentDiffPane { .update(cx, |editor, cx| editor.navigate(data, window, cx)) } - fn tab_tooltip_text(&self, _: &App) -> Option { - Some("Agent Diff".into()) + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + let label_content = self.tab_content_text(params.detail.unwrap_or_default(), cx); + + Label::new(label_content) + .when(!params.selected, |this| this.color(Color::Muted)) + .into_any_element() } - fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + fn tab_tooltip_content(&self, cx: &App) -> Option { let title = self.thread.read(cx).title(); - Label::new(if let Some(title) = title { - format!("Review: {}", title) - } else { - "Review".to_string() - }) - .color(if params.selected { - Color::Default - } else { - Color::Muted - }) - .into_any_element() + + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + let title = title.map(|title| title.to_string()); + + move |_, _| { + v_flex() + .child(Label::new( + title.clone().unwrap_or_else(|| "Review".to_string()), + )) + .child( + Label::new("Agent Diff") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + } + })))) } fn telemetry_event_text(&self) -> Option<&'static str> { @@ -666,8 +676,11 @@ impl Item for AgentDiffPane { }); } - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Agent Diff".into() + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + match self.thread.read(cx).title() { + Some(title) => format!("Review: {}", truncate_and_trailoff(&title, 20)).into(), + None => "Review".into(), + } } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5575046e25d..8ff5e046a46 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2037,6 +2037,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + self.pending_terminal_spawn = Some(terminal_id); let terminal_working_directory = working_directory.clone(); let init_command = Self::terminal_init_command(run_init_command, cx); let terminal_task = self.project.update(cx, |project, cx| { @@ -5715,24 +5716,6 @@ impl AgentPanel { } menu = menu - .separator() - .header("MCP Servers") - .action( - "Add Server…", - Box::new(zed_actions::OpenSettingsAt { - path: "context_servers".to_string(), - target: None, - }), - ) - .action( - "Install New Servers…", - Box::new(zed_actions::Extensions { - category_filter: Some( - zed_actions::ExtensionCategoryFilter::ContextServers, - ), - id: None, - }), - ) .separator() .action("Profiles", Box::new(ManageProfiles::default())); } @@ -6502,7 +6485,6 @@ impl Render for AgentPanel { .and_then(|terminal_id| self.terminals.get(&terminal_id)) .and_then(|terminal| terminal.search_bar.clone()); let terminal_content = v_flex() - .key_context("AgentTerminalThread") .size_full() .when_some(search_bar, |this, search_bar| { this.when(!search_bar.read(cx).is_dismissed(), |this| { @@ -7382,6 +7364,34 @@ mod tests { }); } + #[gpui::test] + async fn test_explicit_terminal_blocks_redundant_auto_init(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + + panel.update_in(&mut cx, |panel, window, cx| { + panel.last_created_entry_kind = AgentPanelEntryKind::Terminal; + assert!( + matches!(panel.base_view, BaseView::Uninitialized), + "precondition: the panel starts uninitialized" + ); + assert!( + panel.pending_terminal_spawn.is_none(), + "precondition: no terminal spawn is in-flight yet" + ); + panel.new_terminal(None, AgentThreadSource::AgentPanel, window, cx); + let pending = panel.pending_terminal_spawn; + assert!( + pending.is_some(), + "an explicit new terminal must mark a spawn in-flight" + ); + panel.set_active(true, window, cx); + assert_eq!( + panel.pending_terminal_spawn, pending, + "activating the panel while a terminal spawn is in-flight must not schedule a second (auto-init) terminal" + ); + }); + } + #[gpui::test] async fn test_restored_terminal_runs_init_command_once(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 1640a90297f..91ae944e700 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -35,6 +35,7 @@ pub mod thread_worktree_archive; pub mod threads_archive_view; mod ui; +mod unicode_confusables; use std::rc::Rc; use std::sync::Arc; diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 1fee158f4a1..022bf5c6585 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -525,18 +525,18 @@ impl CodegenAlternative { messages.push(user_message); let tools = vec![ - LanguageModelRequestTool { - name: REWRITE_SECTION_TOOL_NAME.to_string(), - description: "Replaces text in tags with your replacement_text.".to_string(), - input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), - use_input_streaming: false, - }, - LanguageModelRequestTool { - name: FAILURE_MESSAGE_TOOL_NAME.to_string(), - description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(), - input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), - use_input_streaming: false, - }, + LanguageModelRequestTool::function( + REWRITE_SECTION_TOOL_NAME.to_string(), + "Replaces text in tags with your replacement_text.".to_string(), + language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + false, + ), + LanguageModelRequestTool::function( + FAILURE_MESSAGE_TOOL_NAME.to_string(), + "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(), + language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + false, + ), ]; LanguageModelRequest { @@ -1179,9 +1179,7 @@ impl CodegenAlternative { let mut chars_read_by_tool_id = chars_read_by_tool_id.lock(); match tool_use.name.as_ref() { REWRITE_SECTION_TOOL_NAME => { - let Ok(input) = - serde_json::from_value::(tool_use.input) - else { + let Ok(input) = tool_use.input.parse::() else { return None; }; let chars_read_so_far = @@ -1198,9 +1196,7 @@ impl CodegenAlternative { }) } FAILURE_MESSAGE_TOOL_NAME => { - let Ok(mut input) = - serde_json::from_value::(tool_use.input) - else { + let Ok(mut input) = tool_use.input.parse::() else { return None; }; Some(ToolUseOutput::Failure(std::mem::take(&mut input.message))) @@ -2011,7 +2007,9 @@ mod tests { id: id.into(), name: REWRITE_SECTION_TOOL_NAME.into(), raw_input: serde_json::to_string(&input).unwrap(), - input: serde_json::to_value(&input).unwrap(), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::to_value(&input).unwrap(), + ), is_input_complete: is_complete, thought_signature: None, }) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 65632e9406b..7b6dd48ca65 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -23,7 +23,6 @@ use editor::scroll::Autoscroll; use editor::{ Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior, }; -use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use file_icons::FileIcons; use fs::Fs; use futures::FutureExt as _; @@ -277,7 +276,7 @@ impl Conversation { let session_id = thread.read(cx).session_id().clone(); let subscription = cx.subscribe(&thread, { let session_id = session_id.clone(); - move |this, _thread, event, cx| { + move |this, _thread, event, _cx| { this.updated_at = Some(Instant::now()); match event { AcpThreadEvent::ToolAuthorizationRequested(id) => { @@ -294,15 +293,12 @@ impl Conversation { } } } - AcpThreadEvent::ElicitationRequested(id) - if cx.has_flag::() => - { + AcpThreadEvent::ElicitationRequested(id) => { this.elicitation_requests .entry(session_id.clone()) .or_default() .push(id.clone()); } - AcpThreadEvent::ElicitationRequested(_) => {} AcpThreadEvent::ElicitationResponded(id) => { if let Some(elicitations) = this.elicitation_requests.get_mut(&session_id) { elicitations.retain(|elicitation_id| elicitation_id != id); @@ -422,10 +418,6 @@ impl Conversation { response: acp::CreateElicitationResponse, cx: &mut Context, ) -> Option<()> { - if !cx.has_flag::() { - return None; - } - let thread = self.threads.get(&session_id)?.clone(); thread.update(cx, |thread, cx| { thread.respond_to_elicitation(&elicitation_id, response, cx); @@ -1614,7 +1606,7 @@ impl ConversationView { }); active.update(cx, |active, cx| { active.sync_elicitation_state_for_entry(index, window, cx); - active.sync_editor_mode_for_empty_state(cx); + active.sync_editor_mode(cx); active.sync_generating_indicator(cx); }); } @@ -1641,7 +1633,7 @@ impl ConversationView { entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone())); list_state.splice(range.clone(), 0); active.update(cx, |active, cx| { - active.sync_editor_mode_for_empty_state(cx); + active.sync_editor_mode(cx); }); } } @@ -1652,10 +1644,9 @@ impl ConversationView { self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx); } AcpThreadEvent::ToolAuthorizationReceived(_) => {} - AcpThreadEvent::ElicitationRequested(_) if cx.has_flag::() => { + AcpThreadEvent::ElicitationRequested(_) => { self.notify_with_sound("Waiting for input", IconName::Info, window, cx); } - AcpThreadEvent::ElicitationRequested(_) => {} AcpThreadEvent::ElicitationResponded(_) => {} AcpThreadEvent::Retry(retry) => { if let Some(active) = self.thread_view(&session_id) { @@ -2347,11 +2338,6 @@ impl ConversationView { } fn sync_request_elicitation_states(&mut self, window: &mut Window, cx: &mut Context) { - if !cx.has_flag::() { - self.request_elicitation_form_states.clear(); - return; - } - let Some(store) = self.request_elicitation_store() else { self.request_elicitation_form_states.clear(); return; @@ -2397,10 +2383,6 @@ impl ConversationView { view: WeakEntity, cx: &App, ) -> Vec { - if !cx.has_flag::() { - return Vec::new(); - } - let Some(store) = connection.request_elicitations() else { return Vec::new(); }; @@ -2529,10 +2511,6 @@ impl ConversationView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - let Some(store) = self.request_elicitation_store() else { return; }; @@ -2581,10 +2559,6 @@ impl ConversationView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - self.respond_to_request_elicitation( elicitation_id, acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), @@ -2598,10 +2572,6 @@ impl ConversationView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - self.respond_to_request_elicitation( elicitation_id, acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), @@ -3634,7 +3604,7 @@ pub(crate) mod tests { use agent_servers::FakeAcpAgentServer; use editor::MultiBufferOffset; use editor::actions::Paste; - use feature_flags::{FeatureFlag as _, FeatureFlagAppExt as _}; + use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _}; use fs::FakeFs; use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size}; use parking_lot::Mutex; diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 90ea600ff5f..5a2ae6fd725 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -20,12 +20,12 @@ use agent_settings::UserAgentsMd; use agent_skills::MAX_SKILL_DESCRIPTION_LEN; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; -use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; use crate::completion_provider::{AvailableSkill, PromptLocalCommand}; use crate::message_editor::SharedSessionCapabilities; use crate::ui::{SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip}; +use crate::unicode_confusables; use db::kvp::KeyValueStore; use gpui::List; @@ -39,8 +39,8 @@ use language_model::{ use notifications::status_toast::StatusToast; use settings::{update_settings_file, update_settings_file_with_completion}; use ui::{ - ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, - Tab, + ButtonLike, CalloutBorderPosition, Checkbox, SpinnerLabel, SpinnerVariant, SplitButton, + SplitButtonStyle, Tab, ToggleState, }; use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME}; @@ -589,6 +589,10 @@ pub struct ThreadView { pub expanded_tool_call_raw_inputs: HashSet, collapsed_sandbox_authorization_details: HashSet, collapsed_sandbox_network_details: HashSet, + /// Sandbox escalation prompts whose "surprising Unicode" warning the user + /// has explicitly acknowledged. Until a prompt's tool call is in this set, + /// its allow buttons stay disabled. See [`Self::sandbox_confusable_findings`]. + acknowledged_confusable_warnings: HashSet, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, @@ -996,6 +1000,7 @@ impl ThreadView { expanded_tool_call_raw_inputs: HashSet::default(), collapsed_sandbox_authorization_details: HashSet::default(), collapsed_sandbox_network_details: HashSet::default(), + acknowledged_confusable_warnings: HashSet::default(), subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, @@ -1036,7 +1041,7 @@ impl ThreadView { }; this.sync_generating_indicator(cx); - this.sync_editor_mode_for_empty_state(cx); + this.sync_editor_mode(cx); this.sync_existing_elicitation_states(window, cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); @@ -2373,27 +2378,7 @@ impl ThreadView { pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) { self.editor_expanded = is_expanded; - self.message_editor.update(cx, |editor, cx| { - if is_expanded { - editor.set_mode( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, - }, - cx, - ) - } else { - let agent_settings = AgentSettings::get_global(cx); - editor.set_mode( - EditorMode::AutoHeight { - min_lines: agent_settings.message_editor_min_lines, - max_lines: Some(agent_settings.set_message_editor_max_lines()), - }, - cx, - ) - } - }); + self.sync_editor_mode(cx); cx.notify(); } @@ -2506,13 +2491,40 @@ impl ThreadView { } pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context) { + if self.pending_allow_blocked_by_confusables(cx) { + return; + } self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx); } pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context) { + if self.pending_allow_blocked_by_confusables(cx) { + return; + } self.authorize_pending_with_granularity(true, window, cx); } + /// Whether the currently pending permission prompt is blocked by an + /// unacknowledged surprising-Unicode warning, so the keyboard allow + /// shortcuts must be ignored (mirroring the disabled allow buttons). + fn pending_allow_blocked_by_confusables(&self, cx: &Context) -> bool { + let session_id = self.thread.read(cx).session_id().clone(); + let Some((_, tool_call_id, _)) = self + .conversation + .read(cx) + .pending_tool_call(&session_id, cx) + else { + return false; + }; + self.thread.read(cx).entries().iter().any(|entry| { + matches!( + entry, + AgentThreadEntry::ToolCall(call) + if call.id == tool_call_id && self.sandbox_confusables_block_allow(call, cx) + ) + }) + } + pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context) { self.authorize_pending_with_granularity(false, window, cx); } @@ -2538,26 +2550,18 @@ impl ThreadView { Some(()) } - fn is_waiting_for_confirmation(&self, entry: &AgentThreadEntry, cx: &Context) -> bool { - match entry { - AgentThreadEntry::ToolCall(tool_call) => { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ) - } - AgentThreadEntry::Elicitation(elicitation_id) => { - cx.has_flag::() - && self - .thread - .read(cx) - .elicitation(elicitation_id) - .is_some_and(|(_, elicitation)| { + fn has_pending_request_elicitation(&self, cx: &App) -> bool { + self.server_view + .read_with(cx, |server_view, cx| { + server_view + .request_elicitation_store() + .is_some_and(|store| { + store.read(cx).elicitations().iter().any(|elicitation| { matches!(elicitation.status, ElicitationStatus::Pending { .. }) }) - } - _ => false, - } + }) + }) + .unwrap_or(false) } pub fn sync_elicitation_state_for_entry( @@ -2575,11 +2579,6 @@ impl ThreadView { elicitation_id.clone() }; - if !cx.has_flag::() { - self.elicitation_form_states.remove(&elicitation_id); - return; - } - let thread = self.thread.read(cx); let entry = thread.elicitation(&elicitation_id).map(|(_, elicitation)| { ( @@ -2625,10 +2624,6 @@ impl ThreadView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - let mode = self .thread .read(cx) @@ -2674,10 +2669,6 @@ impl ThreadView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - self.respond_to_elicitation( elicitation_id, acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), @@ -2691,10 +2682,6 @@ impl ThreadView { _window: &mut Window, cx: &mut Context, ) { - if !cx.has_flag::() { - return; - } - self.respond_to_elicitation( elicitation_id, acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), @@ -6030,9 +6017,8 @@ impl ThreadView { let rendered = this.render_entry(index, entries.len(), entry, window, cx); centered_container(rendered.into_any_element()).into_any_element() } else if this.generating_indicator_in_list { - let confirmation = entries - .last() - .is_some_and(|entry| this.is_waiting_for_confirmation(entry, cx)); + let confirmation = this.thread.read(cx).is_waiting_for_confirmation() + || this.has_pending_request_elicitation(cx); let rendered = this.render_generating(confirmation, cx); centered_container(rendered.into_any_element()).into_any_element() } else { @@ -6353,8 +6339,7 @@ impl ThreadView { } AgentThreadEntry::Elicitation(elicitation_id) => { let thread = self.thread.read(cx); - if cx.has_flag::() - && let Some((_, elicitation)) = thread.elicitation(elicitation_id) + if let Some((_, elicitation)) = thread.elicitation(elicitation_id) && should_render_elicitation(elicitation) { let elicitation = self.render_elicitation(entry_ix, elicitation, window, cx); @@ -6446,7 +6431,8 @@ impl ThreadView { primary }; - let needs_confirmation = self.is_waiting_for_confirmation(entry, cx); + let needs_confirmation = thread.read(cx).is_waiting_for_confirmation() + || self.has_pending_request_elicitation(cx); let comments_editor = self.thread_feedback.comments_editor.clone(); @@ -7104,11 +7090,21 @@ impl ThreadView { open_markdown_in_workspace(thread_title, markdown, workspace, window, cx) } - pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) { + pub(crate) fn sync_editor_mode(&mut self, cx: &mut Context) { let has_messages = self.list_state.item_count() > 0; let v2_empty_state = !has_messages; - let mode = if v2_empty_state { + if !has_messages { + self.editor_expanded = false; + } + + let mode = if self.editor_expanded { + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, + } + } else if v2_empty_state { EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, @@ -7412,7 +7408,7 @@ impl ThreadView { block.markdown() } }; - md.map_or(false, |m| m.read(cx).selected_text().is_some()) + md.map_or(false, |m| m.read(cx).has_selection()) }) }) .unwrap_or(false); @@ -7931,6 +7927,7 @@ impl ThreadView { }) .when_some(confirmation_options, |this, options| { let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx); + let allow_disabled = self.sandbox_confusables_block_allow(tool_call, cx); this.child(self.render_permission_buttons( self.thread.read(cx).session_id().clone(), is_first, @@ -7938,6 +7935,7 @@ impl ThreadView { entry_ix, tool_call.id.clone(), focus_handle, + allow_disabled, cx, )) }) @@ -7951,34 +7949,44 @@ impl ThreadView { reason: &SandboxNotAppliedReason, cx: &Context, ) -> AnyElement { - let (title, detail): (SharedString, SharedString) = match reason { - SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( - "Couldn't create a sandbox".into(), - error.user_facing_message().into(), - ), - SandboxNotAppliedReason::DisabledForThisThread => { - // The grant only exists because an earlier command failed to - // create a sandbox; surface that same explanation here. - let detail = self - .find_thread_sandbox_error(cx) - .map(|error| { - SharedString::from(format!( - "Allowed for this thread after the sandbox failed: {}", - error.user_facing_message() - )) - }) - .unwrap_or_else(|| { - "Unsandboxed execution is allowed for the rest of this thread.".into() - }); - ("Ran without sandbox".into(), detail) - } - }; + // (title, detail line, docs section slug) + let (title, detail, docs_section): (SharedString, SharedString, Option<&'static str>) = + match reason { + SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( + "Couldn't create a sandbox".into(), + error.user_facing_message().into(), + Some(error.docs_section()), + ), + SandboxNotAppliedReason::DisabledForThisThread => { + // The grant only exists because an earlier command failed to + // create a sandbox; surface that same explanation here. + let thread_error = self.find_thread_sandbox_error(cx); + let detail = thread_error + .as_ref() + .map(|error| { + SharedString::from(format!( + "Allowed for this thread after the sandbox failed: {}", + error.user_facing_message() + )) + }) + .unwrap_or_else(|| { + "Unsandboxed execution is allowed for the rest of this thread.".into() + }); + let docs_section = thread_error.as_ref().map(|error| error.docs_section()); + ("Ran without sandbox".into(), detail, docs_section) + } + }; Callout::new() .severity(Severity::Warning) .icon(IconName::Warning) .title(title) .description(detail) + .actions_slot(self.render_sandbox_docs_link( + "sandbox-not-applied-docs-link", + docs_section, + cx, + )) .into_any_element() } @@ -8126,7 +8134,13 @@ impl ThreadView { let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); - let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; + + let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; + + let has_content = !tool_call.content.is_empty() + || (should_show_raw_input && tool_call.raw_input.is_some()); + + let is_collapsible = has_content && !needs_confirmation; let mut is_open = self .entry_view_state .read(cx) @@ -8134,8 +8148,6 @@ impl ThreadView { is_open |= needs_confirmation; - let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content; - let input_output_header = |label: SharedString| { Label::new(label) .size(LabelSize::XSmall) @@ -8173,6 +8185,7 @@ impl ThreadView { entry_ix, &tool_call.id, details, + window, cx, )) }, @@ -8281,6 +8294,7 @@ impl ThreadView { entry_ix, tool_call.id.clone(), focus_handle, + self.sandbox_confusables_block_allow(tool_call, cx), cx, )) .into_any() @@ -8587,11 +8601,42 @@ impl ThreadView { .children(tool_output_display) } + /// A small "Learn more" link to the sandboxing docs, deep-linked to + /// `section` when provided. Shared by the sandbox warning and the two + /// sandbox approval prompts so the user can always reach an explanation of + /// what they're being asked about. + fn render_sandbox_docs_link( + &self, + id: &'static str, + section: Option<&str>, + cx: &Context, + ) -> AnyElement { + let url = zed_urls::sandboxing_docs(section, cx); + let tooltip = format!("Opens {url}"); + // Wrap in a row so the button shrinks to its content width instead of + // stretching to fill the enclosing column. + h_flex() + .child( + Button::new(id, "Learn more") + .label_size(LabelSize::Small) + .color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .tooltip(Tooltip::text(tooltip)) + .on_click(move |_, _, cx| cx.open_url(&url)), + ) + .into_any_element() + } + fn render_sandbox_authorization_details( &self, entry_ix: usize, tool_call_id: &acp::ToolCallId, details: &SandboxAuthorizationDetails, + window: &Window, cx: &Context, ) -> AnyElement { let has_network = details.network_all_hosts || !details.network_hosts.is_empty(); @@ -8600,6 +8645,12 @@ impl ThreadView { return Empty.into_any_element(); } + let confusable_findings = if Self::confusable_warning_enabled(cx) { + Self::sandbox_confusable_findings(details) + } else { + Vec::new() + }; + let network_section = has_network.then(|| { let summary = if details.network_all_hosts { "any host".to_string() @@ -8827,10 +8878,186 @@ impl ThreadView { v_flex() .border_t_1() .border_color(self.tool_card_border_color(cx)) + .when(!confusable_findings.is_empty(), |this| { + this.child(self.render_sandbox_confusable_warning( + tool_call_id, + &confusable_findings, + window, + cx, + )) + }) .children(network_section) .children(write_section) .children(unsandboxed_section) .children(reason_section) + .child( + h_flex() + .px_1() + .py_0p5() + .child(self.render_sandbox_docs_link( + "sandbox-authorization-docs-link", + None, + cx, + )), + ) + .into_any_element() + } + + /// Scan the hosts and paths in a sandbox escalation request for surprising + /// Unicode characters (homoglyphs, invisible characters, bidi overrides). + /// Returns, for each offending value, the display string shown to the user + /// and the distinct suspicious characters it contains. Hosts are decoded from + /// Punycode first, so the display string is the Unicode form the user should + /// scrutinize. Empty when nothing is surprising. + fn sandbox_confusable_findings( + details: &SandboxAuthorizationDetails, + ) -> Vec<(String, Vec)> { + let mut findings = Vec::new(); + for host in &details.network_hosts { + let (decoded, suspicious) = unicode_confusables::scan_host(host); + if !suspicious.is_empty() { + findings.push((decoded, suspicious)); + } + } + for path in &details.write_paths { + let display = path.display().to_string(); + let suspicious = unicode_confusables::scan(&display); + if !suspicious.is_empty() { + findings.push((display, suspicious)); + } + } + findings + } + + /// Whether the surprising-Unicode warning is enabled in settings (on by + /// default). When off, prompts neither show the banner nor gate their allow + /// buttons on it. + fn confusable_warning_enabled(cx: &App) -> bool { + AgentSettings::get_global(cx) + .sandbox_permissions + .warn_confusable_unicode + } + + /// Whether this tool call's sandbox escalation shows surprising Unicode that + /// the user hasn't acknowledged yet. While true, the prompt's allow buttons + /// stay disabled so the user can't grant access to a lookalike target + /// without first ticking the acknowledgement checkbox. + fn sandbox_confusables_block_allow(&self, tool_call: &ToolCall, cx: &App) -> bool { + if !Self::confusable_warning_enabled(cx) { + return false; + } + let Some(details) = tool_call.sandbox_authorization_details.as_ref() else { + return false; + }; + if self + .acknowledged_confusable_warnings + .contains(&tool_call.id) + { + return false; + } + !Self::sandbox_confusable_findings(details).is_empty() + } + + /// Red banner warning that a requested domain or path contains surprising + /// Unicode characters, with a checkbox the user must tick to unlock the + /// allow buttons. See [`Self::sandbox_confusables_block_allow`]. + fn render_sandbox_confusable_warning( + &self, + tool_call_id: &acp::ToolCallId, + findings: &[(String, Vec)], + window: &Window, + cx: &Context, + ) -> AnyElement { + let acknowledged = self.acknowledged_confusable_warnings.contains(tool_call_id); + let line_height = window.line_height(); + + v_flex() + .w_full() + .p_2() + .gap_2() + .border_t_1() + .border_color(cx.theme().status().error_border) + .bg(cx.theme().status().error_background.opacity(0.15)) + .child( + h_flex() + .w_full() + .gap_1p5() + .items_start() + .child( + h_flex() + .h(line_height) + .flex_none() + .justify_center() + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Error), + ), + ) + .child( + v_flex().min_w_0().flex_1().gap_1().children(findings.iter().map( + |(value, suspicious)| { + v_flex() + .min_w_0() + .gap_0p5() + .child( + Label::new(format!( + "“{value}” contains potentially surprising Unicode characters" + )) + .size(LabelSize::Small) + .color(Color::Error), + ) + .child(v_flex().min_w_0().pl_2().children( + suspicious.iter().map(|character| { + Label::new(format!("• {}", character.description())) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx) + }), + )) + }, + )), + ) + .child( + IconButton::new("configure-confusable-warning", IconName::Settings) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Configure unicode confusables warning")) + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(), + target: None, + }), + cx, + ); + }), + ), + ) + .child( + Checkbox::new( + SharedString::from(format!("confusable-ack-{}", tool_call_id.0)), + if acknowledged { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("I understand and wish to proceed") + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + move |this, state: &ToggleState, _window, cx| { + if *state == ToggleState::Selected { + this.acknowledged_confusable_warnings + .insert(tool_call_id.clone()); + } else { + this.acknowledged_confusable_warnings.remove(&tool_call_id); + } + cx.notify(); + } + })), + ) .into_any_element() } @@ -8866,7 +9093,12 @@ impl ThreadView { .size(LabelSize::Small) .color(Color::Muted), ) - .child(Label::new(details.reason.clone()).size(LabelSize::Small)), + .child(Label::new(details.reason.clone()).size(LabelSize::Small)) + .child(self.render_sandbox_docs_link( + "sandbox-fallback-docs-link", + details.docs_section.as_deref(), + cx, + )), ) .into_any_element() } @@ -8934,6 +9166,9 @@ impl ThreadView { entry_ix: usize, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, + // When true, the "allow" choices are disabled (e.g. an unacknowledged + // surprising-Unicode warning is showing). "Deny"/"Retry" stay enabled. + allow_disabled: bool, cx: &Context, ) -> Div { match options { @@ -8944,6 +9179,7 @@ impl ThreadView { entry_ix, tool_call_id, focus_handle, + allow_disabled, cx, ), PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown( @@ -8954,6 +9190,7 @@ impl ThreadView { session_id, tool_call_id, focus_handle, + allow_disabled, cx, ), PermissionOptions::DropdownWithPatterns { @@ -8968,6 +9205,7 @@ impl ThreadView { session_id, tool_call_id, focus_handle, + allow_disabled, cx, ), } @@ -8982,6 +9220,7 @@ impl ThreadView { session_id: acp::SessionId, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, + allow_disabled: bool, cx: &Context, ) -> Div { let selection = self.permission_selections.get(&tool_call_id); @@ -9036,13 +9275,14 @@ impl ThreadView { .gap_0p5() .child( Button::new(("allow-btn", entry_ix), "Allow") + .disabled(allow_disabled) .start_icon( Icon::new(IconName::Check) .size(IconSize::XSmall) .color(Color::Success), ) .label_size(LabelSize::Small) - .when(is_first, |this| { + .when(is_first && !allow_disabled, |this| { this.key_binding( KeyBinding::for_action_in( &AllowOnce as &dyn Action, @@ -9368,6 +9608,7 @@ impl ThreadView { entry_ix: usize, tool_call_id: acp::ToolCallId, focus_handle: &FocusHandle, + allow_disabled: bool, cx: &Context, ) -> Div { let mut seen_kinds: ArrayVec = ArrayVec::new(); @@ -9431,13 +9672,22 @@ impl ThreadView { } }; - let this = this.start_icon(icon); + // An "allow" choice is disabled while a surprising-Unicode + // warning is unacknowledged; "deny"/"retry" stay enabled. + let is_allow = matches!( + option.kind, + acp::PermissionOptionKind::AllowOnce + | acp::PermissionOptionKind::AllowAlways + ) && !is_retry; + let disabled = allow_disabled && is_allow; + + let this = this.start_icon(icon).disabled(disabled); let Some(action) = action else { return this; }; - if !is_first || seen_kinds.contains(&option.kind) { + if !is_first || disabled || seen_kinds.contains(&option.kind) { return this; } diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index faa760b73a6..bdc79beacc9 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -713,7 +713,6 @@ mod tests { use agent_client_protocol::schema::v1 as acp; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use editor::RowInfo; - use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _}; use fs::FakeFs; use gpui::{AppContext as _, TestAppContext}; use parking_lot::RwLock; @@ -851,11 +850,8 @@ mod tests { } #[gpui::test] - async fn test_hidden_elicitation_preserves_entry_index(cx: &mut TestAppContext) { + async fn test_elicitation_preserves_entry_index(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); - }); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/project", json!({})).await; @@ -900,7 +896,6 @@ mod tests { )), cx, ); - cx.update_flags(false, vec![]); }); let view_state = cx.new(|_cx| { diff --git a/crates/agent_ui/src/unicode_confusables.rs b/crates/agent_ui/src/unicode_confusables.rs new file mode 100644 index 00000000000..3b1ea11eb21 --- /dev/null +++ b/crates/agent_ui/src/unicode_confusables.rs @@ -0,0 +1,244 @@ +//! Detection of "surprising" Unicode characters in the domains and paths shown +//! in sandbox privilege-escalation prompts. +//! +//! Homoglyph/confusable attacks (a Cyrillic `а` standing in for a Latin `a`), +//! invisible characters (zero-width spaces), and bidirectional overrides can +//! make a requested domain or path look like something it is not, tricking the +//! user into granting access to the wrong target. Domains reach the prompt in +//! Punycode (`xn--…`) ASCII form, so a lookalike host is decoded back to +//! Unicode before scanning; paths are scanned as they are displayed. + +use unicode_script::UnicodeScript as _; + +/// Why a character in a domain or path is considered surprising. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SuspiciousKind { + /// A bidirectional control that can visually reorder surrounding text (for + /// example U+202E RIGHT-TO-LEFT OVERRIDE) — the classic "Trojan Source" + /// trick. + BidiControl, + /// A zero-width, invisible, or non-ASCII whitespace formatting character. + Invisible, + /// A visible non-ASCII character that can be confused with ASCII (a + /// homoglyph) or that mixes an unexpected script into otherwise-ASCII text. + Confusable, +} + +/// A single surprising character discovered while scanning a domain or path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SuspiciousChar { + pub character: char, + pub kind: SuspiciousKind, +} + +impl SuspiciousChar { + /// A human-readable, one-line description for the approval banner, such as + /// `‘а’ (U+0430 Cyrillic)` or `U+202E right-to-left override`. + pub fn description(&self) -> String { + let codepoint = format!("U+{:04X}", self.character as u32); + match self.kind { + SuspiciousKind::Confusable => { + format!( + "‘{}’ ({codepoint} {})", + self.character, + self.character.script().full_name() + ) + } + // Bidi controls and invisible characters have no meaningful glyph to + // show (and printing them could itself reorder the banner text), so + // we render only the codepoint and a name. + SuspiciousKind::BidiControl | SuspiciousKind::Invisible => { + match well_known_name(self.character) { + Some(name) => format!("{codepoint} {name}"), + None => codepoint, + } + } + } + } +} + +/// Scan a raw string for surprising Unicode characters, returning each distinct +/// offending character once, in order of first appearance. +pub fn scan(text: &str) -> Vec { + let mut result: Vec = Vec::new(); + for character in text.chars() { + if character.is_ascii() { + continue; + } + if result.iter().any(|found| found.character == character) { + continue; + } + result.push(SuspiciousChar { + character, + kind: classify(character), + }); + } + result +} + +/// Scan a host for surprising characters, first decoding any IDN/Punycode +/// (`xn--…`) labels back to Unicode so a lookalike domain that reaches us as +/// ASCII is still caught. Returns the decoded (Unicode) host — which is what the +/// banner shows the user — alongside the findings. When nothing is surprising +/// the returned host equals the input. +pub fn scan_host(host: &str) -> (String, Vec) { + // `domain_to_unicode` never fails destructively: on error it still returns a + // best-effort decoding, which is exactly what we want to scan and show. + let (decoded, _result) = idna::domain_to_unicode(host); + let findings = scan(&decoded); + (decoded, findings) +} + +fn classify(character: char) -> SuspiciousKind { + if is_bidi_control(character) { + SuspiciousKind::BidiControl + } else if is_invisible(character) { + SuspiciousKind::Invisible + } else { + SuspiciousKind::Confusable + } +} + +fn is_bidi_control(character: char) -> bool { + matches!(character, + '\u{061C}' // ARABIC LETTER MARK + | '\u{200E}' // LEFT-TO-RIGHT MARK + | '\u{200F}' // RIGHT-TO-LEFT MARK + | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO + | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI + ) +} + +fn is_invisible(character: char) -> bool { + matches!(character, + '\u{00AD}' // SOFT HYPHEN + | '\u{180E}' // MONGOLIAN VOWEL SEPARATOR + | '\u{200B}' // ZERO WIDTH SPACE + | '\u{200C}' // ZERO WIDTH NON-JOINER + | '\u{200D}' // ZERO WIDTH JOINER + | '\u{2060}' // WORD JOINER + | '\u{2061}'..='\u{2064}' // invisible math operators + | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE (BOM) + ) || is_non_ascii_space(character) + // Any remaining control/format character (categories Cc/Cf) is + // invisible for our purposes. + || character.is_control() +} + +fn is_non_ascii_space(character: char) -> bool { + matches!( + character, + '\u{00A0}' // NO-BREAK SPACE + | '\u{1680}' // OGHAM SPACE MARK + | '\u{2000}' + ..='\u{200A}' // EN QUAD … HAIR SPACE + | '\u{202F}' // NARROW NO-BREAK SPACE + | '\u{205F}' // MEDIUM MATHEMATICAL SPACE + | '\u{3000}' // IDEOGRAPHIC SPACE + ) +} + +/// Friendly names for the invisible/bidi characters most likely to show up in an +/// attack, so the banner reads better than a bare codepoint. +fn well_known_name(character: char) -> Option<&'static str> { + Some(match character { + '\u{00A0}' => "no-break space", + '\u{00AD}' => "soft hyphen", + '\u{061C}' => "arabic letter mark", + '\u{180E}' => "mongolian vowel separator", + '\u{200B}' => "zero-width space", + '\u{200C}' => "zero-width non-joiner", + '\u{200D}' => "zero-width joiner", + '\u{200E}' => "left-to-right mark", + '\u{200F}' => "right-to-left mark", + '\u{202A}' => "left-to-right embedding", + '\u{202B}' => "right-to-left embedding", + '\u{202C}' => "pop directional formatting", + '\u{202D}' => "left-to-right override", + '\u{202E}' => "right-to-left override", + '\u{2060}' => "word joiner", + '\u{2066}' => "left-to-right isolate", + '\u{2067}' => "right-to-left isolate", + '\u{2068}' => "first strong isolate", + '\u{2069}' => "pop directional isolate", + '\u{3000}' => "ideographic space", + '\u{FEFF}' => "zero-width no-break space", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_ascii_is_never_flagged() { + assert!(scan("github.com").is_empty()); + assert!(scan("/home/user/project/src/main.rs").is_empty()); + assert!(scan("*.npmjs.org").is_empty()); + } + + #[test] + fn detects_cyrillic_homoglyph() { + // "gіthub.com" with a Cyrillic "і" (U+0456). + let findings = scan("g\u{0456}thub.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].character, '\u{0456}'); + assert_eq!(findings[0].kind, SuspiciousKind::Confusable); + assert!(findings[0].description().contains("U+0456")); + assert!(findings[0].description().contains("Cyrillic")); + } + + #[test] + fn detects_bidi_override() { + let findings = scan("safe\u{202E}txt.exe"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].kind, SuspiciousKind::BidiControl); + assert_eq!(findings[0].description(), "U+202E right-to-left override"); + } + + #[test] + fn detects_zero_width_space() { + let findings = scan("git\u{200B}hub.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].kind, SuspiciousKind::Invisible); + assert_eq!(findings[0].description(), "U+200B zero-width space"); + } + + #[test] + fn deduplicates_repeated_characters() { + // Two Cyrillic "а" (U+0430) should be reported once. + let findings = scan("\u{0430}bc\u{0430}"); + assert_eq!(findings.len(), 1); + } + + #[test] + fn scan_host_decodes_punycode_lookalike() { + // "аpple.com" (leading Cyrillic а, U+0430) encodes to this Punycode. + let (decoded, findings) = scan_host("xn--pple-43d.com"); + assert_eq!(decoded, "\u{0430}pple.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].character, '\u{0430}'); + assert_eq!(findings[0].kind, SuspiciousKind::Confusable); + } + + #[test] + fn scan_host_leaves_plain_domains_alone() { + let (decoded, findings) = scan_host("github.com"); + assert_eq!(decoded, "github.com"); + assert!(findings.is_empty()); + } + + #[test] + fn scan_host_handles_wildcard_subdomain_patterns() { + // Host patterns can carry a leading `*.` wildcard; decoding must not + // choke on it, and a lookalike label behind it is still caught. + let (_decoded, findings) = scan_host("*.xn--pple-43d.com"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].character, '\u{0430}'); + + let (decoded, findings) = scan_host("*.github.com"); + assert_eq!(decoded, "*.github.com"); + assert!(findings.is_empty()); + } +} diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 676b43b02b7..cf8d547cc3e 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -19,7 +19,14 @@ pub mod batches; pub mod completion; pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; -const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; +pub const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; + +pub fn supports_fast_mode(model_id: &str) -> bool { + matches!( + model_id, + "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" + ) +} pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; @@ -156,10 +163,7 @@ impl Model { AnthropicModelMode::Default }; - let supports_speed = matches!( - entry.id.as_str(), - "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" - ); + let supports_speed = supports_fast_mode(&entry.id); // let supports_compaction = matches!( diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 7c9a8c53695..884ed1a1ddd 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -3,9 +3,9 @@ use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, Role, StopReason, - TokenUsage, + LanguageModelProviderName, LanguageModelRequest, LanguageModelRequestToolInput, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, + LanguageModelToolUseInput, MessageContent, Role, StopReason, TokenUsage, util::{fix_streamed_json, parse_tool_arguments}, }; use std::pin::Pin; @@ -68,7 +68,7 @@ fn mark_last_cacheable_content(content: &mut [RequestContent], cache_control: Ca } } -fn to_anthropic_content(content: MessageContent) -> Option { +fn to_anthropic_content(content: MessageContent) -> Result> { match content { MessageContent::Text(text) => { let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) { @@ -77,12 +77,12 @@ fn to_anthropic_content(content: MessageContent) -> Option { text }; if !text.is_empty() { - Some(RequestContent::Text { + Ok(Some(RequestContent::Text { text, cache_control: None, - }) + })) } else { - None + Ok(None) } } MessageContent::Thinking { @@ -92,36 +92,41 @@ fn to_anthropic_content(content: MessageContent) -> Option { if let Some(signature) = signature && !thinking.is_empty() { - Some(RequestContent::Thinking { + Ok(Some(RequestContent::Thinking { thinking, signature, cache_control: None, - }) + })) } else { - None + Ok(None) } } MessageContent::RedactedThinking(data) => { if !data.is_empty() { - Some(RequestContent::RedactedThinking { data }) + Ok(Some(RequestContent::RedactedThinking { data })) } else { - None + Ok(None) } } - MessageContent::Image(image) => Some(RequestContent::Image { + MessageContent::Image(image) => Ok(Some(RequestContent::Image { source: ImageSource { source_type: "base64".to_string(), media_type: "image/png".to_string(), data: image.source.to_string(), }, cache_control: None, - }), - MessageContent::ToolUse(tool_use) => Some(RequestContent::ToolUse { - id: tool_use.id.to_string(), - name: tool_use.name.to_string(), - input: tool_use.input, - cache_control: None, - }), + })), + MessageContent::ToolUse(tool_use) => match tool_use.input { + LanguageModelToolUseInput::Json(input) => Ok(Some(RequestContent::ToolUse { + id: tool_use.id.to_string(), + name: tool_use.name.to_string(), + input, + cache_control: None, + })), + LanguageModelToolUseInput::Text(_) => Err(anyhow::anyhow!( + "Anthropic does not support custom tool calls" + )), + }, MessageContent::ToolResult(tool_result) => { let content = match tool_result.content.as_slice() { [LanguageModelToolResultContent::Text(text)] => { @@ -147,24 +152,24 @@ fn to_anthropic_content(content: MessageContent) -> Option { ToolResultContent::Multipart(parts) } }; - Some(RequestContent::ToolResult { + Ok(Some(RequestContent::ToolResult { tool_use_id: tool_result.tool_use_id.to_string(), is_error: tool_result.is_error, content, cache_control: None, - }) + })) } MessageContent::Compaction(CompactionContent::Summary { content }) => { - Some(RequestContent::Compaction { + Ok(Some(RequestContent::Compaction { content, cache_control: None, - }) + })) } // Encrypted compaction blocks come from other providers, and a // Pending block is a streaming-only UI signal; neither is replayed. MessageContent::Compaction( CompactionContent::Encrypted { .. } | CompactionContent::Pending, - ) => None, + ) => Ok(None), } } @@ -175,7 +180,7 @@ pub fn into_anthropic( max_output_tokens: u64, mode: AnthropicModelMode, cache_mode: AnthropicPromptCacheMode, -) -> crate::Request { +) -> Result { let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); let mut any_message_wants_cache = false; @@ -189,11 +194,12 @@ pub fn into_anthropic( match message.role { Role::User | Role::Assistant => { - let mut anthropic_message_content: Vec = message - .content - .into_iter() - .filter_map(to_anthropic_content) - .collect(); + let mut anthropic_message_content = Vec::new(); + for content in message.content { + if let Some(content) = to_anthropic_content(content)? { + anthropic_message_content.push(content); + } + } let anthropic_role = match message.role { Role::User => crate::Role::User, Role::Assistant => crate::Role::Assistant, @@ -261,21 +267,29 @@ pub fn into_anthropic( let mut tools: Vec = request .tools .into_iter() - .map(|tool| Tool { - name: tool.name, - description: tool.description, - input_schema: tool.input_schema, - eager_input_streaming: tool.use_input_streaming, - cache_control: None, + .map(|tool| match tool.input { + LanguageModelRequestToolInput::Function { + input_schema, + use_input_streaming, + } => Ok(Tool { + name: tool.name, + description: tool.description, + input_schema, + eager_input_streaming: use_input_streaming, + cache_control: None, + }), + LanguageModelRequestToolInput::Custom { .. } => { + Err(anyhow::anyhow!("Anthropic does not support custom tools")) + } }) - .collect(); + .collect::>()?; if let Some(cache_control) = long_lived_cache && let Some(last_tool) = tools.last_mut() { last_tool.cache_control = Some(cache_control); } - crate::Request { + Ok(crate::Request { model, messages: new_messages, max_tokens: max_output_tokens, @@ -339,7 +353,7 @@ pub fn into_anthropic( trigger: Some(CompactionTrigger::InputTokens { value }), }], }), - } + }) } pub struct AnthropicEventMapper { @@ -451,7 +465,7 @@ impl AnthropicEventMapper { name: tool_use.name.clone().into(), is_input_complete: false, raw_input: tool_use.input_json.clone(), - input, + input: LanguageModelToolUseInput::Json(input), thought_signature: None, }, ))]; @@ -469,7 +483,7 @@ impl AnthropicEventMapper { id: tool_use.id.into(), name: tool_use.name.into(), is_input_complete: true, - input, + input: LanguageModelToolUseInput::Json(input), raw_input: tool_use.input_json.clone(), thought_signature: None, }, @@ -597,12 +611,12 @@ mod tests { intent: None, stop: vec![], temperature: None, - tools: vec![language_model_core::LanguageModelRequestTool { - name: "do_thing".into(), - description: "Does a thing.".into(), - input_schema: serde_json::json!({"type": "object"}), - use_input_streaming: false, - }], + tools: vec![language_model_core::LanguageModelRequestTool::function( + "do_thing".into(), + "Does a thing.".into(), + serde_json::json!({"type": "object"}), + false, + )], tool_choice: None, thinking_allowed: true, thinking_effort: None, @@ -617,7 +631,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, - ); + ) + .unwrap(); // No message content block should carry cache_control anymore; the // conversation breakpoint is set via top-level automatic caching. @@ -703,12 +718,12 @@ mod tests { intent: None, stop: vec![], temperature: None, - tools: vec![language_model_core::LanguageModelRequestTool { - name: "do_thing".into(), - description: "Does a thing.".into(), - input_schema: serde_json::json!({"type": "object"}), - use_input_streaming: false, - }], + tools: vec![language_model_core::LanguageModelRequestTool::function( + "do_thing".into(), + "Does a thing.".into(), + serde_json::json!({"type": "object"}), + false, + )], tool_choice: None, thinking_allowed: true, thinking_effort: None, @@ -723,7 +738,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Legacy, - ); + ) + .unwrap(); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( @@ -781,7 +797,8 @@ mod tests { 128_000, AnthropicModelMode::AdaptiveThinking, AnthropicPromptCacheMode::Automatic, - ); + ) + .unwrap(); assert_eq!( anthropic_request @@ -813,12 +830,12 @@ mod tests { intent: None, stop: vec![], temperature: None, - tools: vec![language_model_core::LanguageModelRequestTool { - name: "do_thing".into(), - description: "Does a thing.".into(), - input_schema: serde_json::json!({"type": "object"}), - use_input_streaming: false, - }], + tools: vec![language_model_core::LanguageModelRequestTool::function( + "do_thing".into(), + "Does a thing.".into(), + serde_json::json!({"type": "object"}), + false, + )], tool_choice: None, thinking_allowed: true, thinking_effort: None, @@ -833,7 +850,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Automatic, - ); + ) + .unwrap(); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( @@ -879,6 +897,7 @@ mod tests { }, AnthropicPromptCacheMode::Automatic, ) + .unwrap() } #[test] @@ -977,7 +996,8 @@ mod tests { 4096, AnthropicModelMode::Default, AnthropicPromptCacheMode::Disabled, - ); + ) + .unwrap(); assert_eq!( serde_json::to_value(&anthropic_request.context_management).unwrap(), diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 6eb69b83273..d9cd6255226 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -69,6 +69,23 @@ pub fn skills_docs(cx: &App) -> String { format!("{docs_url}/ai/skills", docs_url = docs_url(cx)) } +/// Returns the URL to Zed's Agent sandboxing documentation. +/// +/// Pass `section` to deep-link to a specific section anchor on the page (for +/// example, `Some("installing-bubblewrap")`); pass `None` to link to the top of +/// the page. +/// +/// Unlike the account/app links above, this targets `zed.dev/docs` (via +/// [`release_channel::docs_url`]) rather than the configured `server_url`: the +/// docs are a static site hosted on `zed.dev`, so pointing at a local dev +/// `server_url` would 404. +pub fn sandboxing_docs(section: Option<&str>, cx: &App) -> String { + let base = release_channel::docs_url("ai/sandboxing", cx); + match section { + Some(section) => format!("{base}#{section}"), + None => base, + } +} pub fn llm_provider_docs(cx: &App) -> String { format!("{docs_url}/ai/llm-providers", docs_url = docs_url(cx)) } diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 2bc734cc10e..adbcffe9be8 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -198,6 +198,8 @@ pub enum EditPredictionRejectReason { InterpolatedEmpty, /// Edits returned, but could not be interpolated after buffer changes InterpolateFailed, + /// A patch was returned, but could not be applied to the buffer + PatchApplyFailed, /// The new prediction was preferred over the current one Replaced, /// The current prediction was preferred over the new one diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index c4650971d2a..38cac226bad 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -7310,6 +7310,20 @@ async fn test_remote_git_branches( }); assert_eq!(host_branch.name(), "totally-new-branch"); + + let default_branch_b = cx_b + .update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(false))) + .await + .unwrap() + .unwrap(); + assert_eq!(default_branch_b.as_deref(), Some("main")); + + let default_branch_with_remote_b = cx_b + .update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(true))) + .await + .unwrap() + .unwrap(); + assert_eq!(default_branch_with_remote_b.as_deref(), Some("origin/main")); } #[gpui::test] diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 322777da042..8f8ba768754 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -8,7 +8,7 @@ use std::{ time::{Duration, Instant}, }; -use crate::table_data_engine::TableDataEngine; +use crate::table_data_engine::{DisplayToDataMapping, TableDataEngine}; use ui::{ AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState, TableResizeBehavior, prelude::*, @@ -41,6 +41,10 @@ pub struct CsvPreviewView { pub(crate) table_interaction_state: Entity, pub(crate) column_widths: ColumnWidths, pub(crate) parsing_task: Option>>, + pub(crate) is_parsing: bool, + /// Background task computing the display-to-data mapping after a filter/sort change. + /// Stored here so that a new change cancels the previous in-flight computation. + pub(crate) filter_sort_task: Option>, pub(crate) settings: CsvPreviewSettings, /// Performance metrics for debugging and monitoring CSV operations. pub(crate) performance_metrics: PerformanceMetrics, @@ -178,9 +182,11 @@ impl CsvPreviewView { table_interaction_state, column_widths: ColumnWidths::new(cx, 1), parsing_task: None, + is_parsing: false, + filter_sort_task: None, performance_metrics: PerformanceMetrics::default(), list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)) - .measure_all(), + .with_uniform_item_height(px(24.)), settings: CsvPreviewSettings::default(), last_parse_end_time: None, engine: TableDataEngine::default(), @@ -194,22 +200,54 @@ impl CsvPreviewView { pub(crate) fn editor_state(&self) -> &EditorState { &self.active_editor_state } - pub(crate) fn apply_sort(&mut self) { - self.performance_metrics.record("Sort", || { - self.engine.apply_sort(); - }); + pub(crate) fn apply_sort(&mut self, cx: &mut Context) { + self.apply_filter_sort(cx); } - /// Update ordered indices when ordering or content changes - pub(crate) fn apply_filter_sort(&mut self) { - self.performance_metrics.record("Filter&sort", || { - self.engine.calculate_d2d_mapping(); - }); + pub fn clear_filters(&mut self, col: types::AnyColumn, cx: &mut Context) { + self.engine.clear_filters_for_col(col); + self.apply_filter_sort(cx); + } - // Update list state with filtered row count - let visible_rows = self.engine.d2d_mapping().visible_row_count(); - self.list_state = - gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.)).measure_all(); + pub fn toggle_filter( + &mut self, + col: types::AnyColumn, + value: Option, + cx: &mut Context, + ) { + if let Err(err) = self.engine.toggle_filter(col, value) { + log::error!("Failed to toggle filter: {err}"); + return; + } + self.apply_filter_sort(cx); + } + + /// Spawns a background task to recompute the display-to-data mapping after a filter or sort + /// change. Storing the task cancels any previous in-flight computation automatically. + pub(crate) fn apply_filter_sort(&mut self, cx: &mut Context) { + let contents = self.engine.contents.clone(); + let filter_stack = self.engine.filter_stack.clone(); + let sorting = self.engine.applied_sorting; + + self.filter_sort_task = Some(cx.spawn(async move |this, cx| { + let mapping = cx + .background_spawn(async move { + DisplayToDataMapping::compute(&contents, &filter_stack, sorting) + }) + .await; + + this.update(cx, |view, cx| { + view.engine.set_d2d_mapping(mapping); + let visible_rows = view.engine.d2d_mapping().visible_row_count(); + // Approximation of single csv table row height. Will be re-measured on scrolling. + // This cheap solution allow to render scrollbar with fraction of a cost compared to `.measure_all()` call + let approximate_height = px(24.); + view.list_state + .reset_with_uniform_height(visible_rows, approximate_height); + cx.notify(); + }) + .ok(); + })); } pub fn resolve_active_item_as_csv_editor( @@ -301,7 +339,7 @@ impl PerformanceMetrics { .map(|(name, (duration, time))| { let took = duration.as_secs_f32() * 1000.; let ago = time.elapsed().as_secs(); - format!("{name}: {took:.2}ms {ago}s ago") + format!("{name}: {took:.3}ms {ago}s ago") }) .collect::>() .join("\n") diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs index efa3573d7aa..116c8912a38 100644 --- a/crates/csv_preview/src/parser.rs +++ b/crates/csv_preview/src/parser.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::{ CsvPreviewView, types::TableLikeContent, @@ -23,6 +25,7 @@ impl CsvPreviewView { cx: &mut Context, ) { let editor = self.active_editor_state.editor.clone(); + self.is_parsing = true; self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx)); } @@ -80,11 +83,13 @@ impl CsvPreviewView { .insert("Parsing", (parse_duration, Instant::now())); log::debug!("Parsed {} rows", parsed_csv.rows.len()); - view.engine.contents = parsed_csv; + view.engine.contents = Arc::new(parsed_csv); + view.engine.calculate_available_filters(); view.sync_column_widths(cx); view.last_parse_end_time = Some(parse_end_time); - view.apply_filter_sort(); + view.is_parsing = false; + view.apply_filter_sort(cx); cx.notify(); }) }) diff --git a/crates/csv_preview/src/renderer/performance_metrics_overlay.rs b/crates/csv_preview/src/renderer/performance_metrics_overlay.rs index 3d0cf50cf1d..d9e7ce6ad9b 100644 --- a/crates/csv_preview/src/renderer/performance_metrics_overlay.rs +++ b/crates/csv_preview/src/renderer/performance_metrics_overlay.rs @@ -20,7 +20,7 @@ impl CsvPreviewView { let children = div() .absolute() - .top_24() + .bottom_8() .right_4() .px_3() .py_2() diff --git a/crates/csv_preview/src/renderer/preview_view.rs b/crates/csv_preview/src/renderer/preview_view.rs index 90500d53d06..335633656af 100644 --- a/crates/csv_preview/src/renderer/preview_view.rs +++ b/crates/csv_preview/src/renderer/preview_view.rs @@ -1,6 +1,6 @@ use std::time::Instant; -use ui::{div, prelude::*}; +use ui::{SpinnerLabel, div, prelude::*}; use crate::CsvPreviewView; @@ -11,12 +11,12 @@ impl Render for CsvPreviewView { let render_prep_start = Instant::now(); let table_with_settings = v_flex() .size_full() - .p_4() .bg(theme.colors().editor_background) .track_focus(&self.focus_handle) .child(self.render_settings_panel(window, cx)) .child({ - if self.engine.contents.number_of_cols == 0 { + let is_parsing = self.is_parsing; + if is_parsing || self.engine.contents.number_of_cols == 0 { div() .flex() .items_center() @@ -25,7 +25,15 @@ impl Render for CsvPreviewView { .text_ui(cx) .font_buffer(cx) .text_color(cx.theme().colors().text_muted) - .child("No CSV content to display") + .when(is_parsing, |div| { + div.child( + h_flex() + .gap_2() + .child(SpinnerLabel::new()) + .child("Loading…"), + ) + }) + .when(!is_parsing, |div| div.child("No CSV content to display")) .into_any_element() } else { self.create_table(&self.column_widths.widths, cx) diff --git a/crates/csv_preview/src/renderer/row_identifiers.rs b/crates/csv_preview/src/renderer/row_identifiers.rs index 06a26e4696e..e24441d3885 100644 --- a/crates/csv_preview/src/renderer/row_identifiers.rs +++ b/crates/csv_preview/src/renderer/row_identifiers.rs @@ -171,6 +171,7 @@ impl CsvPreviewView { .px_1() .border_b_1() .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) .h_full() .text_ui(cx) // Row identifiers are always centered diff --git a/crates/csv_preview/src/renderer/settings.rs b/crates/csv_preview/src/renderer/settings.rs index cafa2a4c1bd..18c185bb0f3 100644 --- a/crates/csv_preview/src/renderer/settings.rs +++ b/crates/csv_preview/src/renderer/settings.rs @@ -3,7 +3,10 @@ use ui::{ IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex, }; -use crate::{CsvPreviewView, settings::VerticalAlignment}; +use crate::{ + CsvPreviewView, + settings::{FilterSortOrder, VerticalAlignment}, +}; ///// Settings related ///// impl CsvPreviewView { @@ -18,6 +21,11 @@ impl CsvPreviewView { VerticalAlignment::Center => "Center", }; + let current_filter_sort_text = match self.settings.filter_sort_order { + FilterSortOrder::AlphaThenCount => "A-Z, then Count", + FilterSortOrder::CountThenAlpha => "Count, then A-Z", + }; + let view = cx.entity(); let alignment_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { menu.entry("Top", None, { @@ -40,6 +48,27 @@ impl CsvPreviewView { }) }); + let filter_sort_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.entry("A-Z, then Count", None, { + let view = view.clone(); + move |_window, cx| { + view.update(cx, |this, cx| { + this.settings.filter_sort_order = FilterSortOrder::AlphaThenCount; + cx.notify(); + }); + } + }) + .entry("Count, then A-Z", None, { + let view = view.clone(); + move |_window, cx| { + view.update(cx, |this, cx| { + this.settings.filter_sort_order = FilterSortOrder::CountThenAlpha; + cx.notify(); + }); + } + }) + }); + let panel = h_flex() .gap_4() .p_2() @@ -68,6 +97,28 @@ impl CsvPreviewView { "Choose vertical text alignment within cells", )), ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .text_sm() + .text_color(cx.theme().colors().text_muted) + .child("Filter Sort:"), + ) + .child( + DropdownMenu::new( + ElementId::Name("filter-sort-order-dropdown".into()), + current_filter_sort_text, + filter_sort_dropdown_menu, + ) + .trigger_size(ButtonSize::Compact) + .trigger_tooltip(Tooltip::text( + "Choose how filter values are sorted in the filter menu", + )), + ), ); #[cfg(feature = "dev-tools")] diff --git a/crates/csv_preview/src/renderer/table_header.rs b/crates/csv_preview/src/renderer/table_header.rs index 05652b49a48..1ce8d34d22b 100644 --- a/crates/csv_preview/src/renderer/table_header.rs +++ b/crates/csv_preview/src/renderer/table_header.rs @@ -1,9 +1,13 @@ use gpui::ElementId; -use ui::{Tooltip, prelude::*}; +use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*}; use crate::{ CsvPreviewView, - table_data_engine::sorting_by_column::{AppliedSorting, SortDirection}, + settings::FilterSortOrder, + table_data_engine::{ + filtering_by_column::{FilterEntry, FilterEntryState}, + sorting_by_column::{AppliedSorting, SortDirection}, + }, types::AnyColumn, }; @@ -22,7 +26,12 @@ impl CsvPreviewView { .w_full() .font_buffer(cx) .child(div().child(header_text)) - .child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx))) + .child( + h_flex() + .gap_1() + .child(self.create_filter_button(cx, col_idx)) + .child(self.create_sort_button(cx, col_idx)), + ) .into_any_element() } @@ -82,9 +91,191 @@ impl CsvPreviewView { }; this.engine.applied_sorting = new_sorting; - this.apply_sort(); + this.apply_sort(cx); cx.notify(); })); sort_btn } + + fn create_filter_button( + &self, + cx: &mut Context<'_, CsvPreviewView>, + col: AnyColumn, + ) -> PopoverMenu { + let has_active_filters = self.engine.has_active_filters(col); + + PopoverMenu::new(ElementId::NamedInteger( + "filter-menu".into(), + col.get() as u64, + )) + .trigger_with_tooltip( + Button::new( + ElementId::NamedInteger("filter-button".into(), col.get() as u64), + if has_active_filters { "⛊" } else { "⛉" }, + ) + .size(ButtonSize::Compact) + .style(if has_active_filters { + ButtonStyle::Filled + } else { + ButtonStyle::Subtle + }), + Tooltip::text(if has_active_filters { + "Column has active filters. Click to manage" + } else { + "No filters applied. Click to add filters" + }), + ) + .menu({ + let view_entity = cx.entity(); + move |window, cx| { + let view = view_entity.read(cx); + let column_filters = match view.engine.get_filters_for_column(col) { + Ok(filters) => filters, + Err(err) => { + log::error!("Failed to get filters for column: {err}"); + return None; + } + }; + let filter_sort_order = view.settings.filter_sort_order; + let filter_menu = Self::create_filter_menu( + window, + cx, + view_entity.clone(), + col, + &column_filters, + has_active_filters, + filter_sort_order, + ); + Some(filter_menu) + } + }) + } + + fn create_filter_menu( + window: &mut ui::Window, + cx: &mut ui::App, + view_entity: gpui::Entity, + col: AnyColumn, + column_filters: &[(FilterEntry, FilterEntryState)], + has_active_filters: bool, + sort_order: FilterSortOrder, + ) -> gpui::Entity { + let mut available: Vec<&FilterEntry> = column_filters + .iter() + .filter_map(|(entry, state)| { + matches!(state, FilterEntryState::Available { .. }).then_some(entry) + }) + .collect(); + + match sort_order { + FilterSortOrder::AlphaThenCount => available.sort_by(|a, b| { + a.content + .cmp(&b.content) + .then_with(|| b.occurred_times().cmp(&a.occurred_times())) + }), + FilterSortOrder::CountThenAlpha => available.sort_by(|a, b| { + b.occurred_times() + .cmp(&a.occurred_times()) + .then_with(|| a.content.cmp(&b.content)) + }), + } + + let unavailable: Vec<(&FilterEntry, AnyColumn)> = column_filters + .iter() + .filter_map(|(entry, state)| { + if let FilterEntryState::Unavailable { blocked_by } = state { + Some((entry, *blocked_by)) + } else { + None + } + }) + .collect(); + + // Pre-build applied-state lookup before moving into the closure + let applied_states: Vec<(FilterEntry, bool)> = column_filters + .iter() + .filter_map(|(entry, state)| { + if let FilterEntryState::Available { is_applied } = state { + Some((entry.clone(), *is_applied)) + } else { + None + } + }) + .collect(); + + let available_cloned: Vec = available.iter().map(|e| (*e).clone()).collect(); + let unavailable_cloned: Vec<(FilterEntry, AnyColumn)> = unavailable + .into_iter() + .map(|(e, col)| (e.clone(), col)) + .collect(); + + ContextMenu::build(window, cx, move |menu, _, _| { + let mut menu = menu; + + if has_active_filters { + menu = menu + .toggleable_entry("Clear all", false, ui::IconPosition::Start, None, { + let view_entity = view_entity.clone(); + move |_window, cx| { + view_entity.update(cx, |view, cx| { + view.clear_filters(col, cx); + cx.notify(); + }); + } + }) + .separator(); + } + + for entry in &available_cloned { + let is_applied = applied_states + .iter() + .find(|(e, _)| e.content == entry.content) + .map_or(false, |(_, applied)| *applied); + + let label: SharedString = + format_filter_label(entry.content.as_ref(), entry.occurred_times()).into(); + let entry_value = entry.content.clone(); + + menu = menu.toggleable_entry(&label, is_applied, ui::IconPosition::Start, None, { + let view_entity = view_entity.clone(); + move |_window, cx| { + view_entity.update(cx, |view, cx| { + view.toggle_filter(col, entry_value.clone(), cx); + cx.notify(); + }); + } + }); + } + + if !unavailable_cloned.is_empty() { + menu = menu.separator().header("Hidden by other filters"); + for (entry, _blocked_by) in &unavailable_cloned { + let label: SharedString = + format_filter_label(entry.content.as_ref(), entry.occurred_times()).into(); + menu = menu.custom_entry( + { + let label = label.clone(); + move |_window, cx| { + div() + .px_2() + .text_color(cx.theme().colors().text_muted) + .child(label.clone()) + .into_any_element() + } + }, + |_, _| {}, + ); + } + } + + menu + }) + } +} + +fn format_filter_label(content: Option<&SharedString>, count: usize) -> String { + match content { + Some(s) => format!("{s} ({count})"), + None => format!(" ({count})"), + } } diff --git a/crates/csv_preview/src/settings.rs b/crates/csv_preview/src/settings.rs index 215d681c28f..2d5cc78c5f5 100644 --- a/crates/csv_preview/src/settings.rs +++ b/crates/csv_preview/src/settings.rs @@ -1,10 +1,10 @@ #[derive(Default, Clone, Copy, PartialEq)] pub enum RowRenderMechanism { /// More correct for multiline content, but slower. - #[allow(dead_code)] // Will be used when settings ui is added + #[default] VariableList, /// Default behaviour for now while resizable columns are being stabilized. - #[default] + #[allow(dead_code)] // Will be used when settings ui is added UniformList, } @@ -26,11 +26,21 @@ pub enum RowIdentifiers { RowNum, } +#[derive(Default, Clone, Copy, PartialEq)] +pub enum FilterSortOrder { + /// Sort alphabetically (A→Z), then by number of occurrences descending within ties + #[default] + AlphaThenCount, + /// Sort by number of occurrences descending, then alphabetically within ties + CountThenAlpha, +} + #[derive(Clone, Default)] pub(crate) struct CsvPreviewSettings { pub(crate) rendering_with: RowRenderMechanism, pub(crate) vertical_alignment: VerticalAlignment, pub(crate) numbering_type: RowIdentifiers, + pub(crate) filter_sort_order: FilterSortOrder, pub(crate) show_debug_info: bool, #[cfg(feature = "dev-tools")] pub(crate) show_perf_metrics_overlay: bool, diff --git a/crates/csv_preview/src/table_data_engine.rs b/crates/csv_preview/src/table_data_engine.rs index 382b41a2850..f2613215fa5 100644 --- a/crates/csv_preview/src/table_data_engine.rs +++ b/crates/csv_preview/src/table_data_engine.rs @@ -5,22 +5,32 @@ //! //! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles. -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use ui::table_row::TableRow; use crate::{ - table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows}, - types::{DataRow, DisplayRow, TableCell, TableLikeContent}, + table_data_engine::{ + filtering_by_column::{FilterEntry, FilterStack, calculate_available_filters, retain_rows}, + sorting_by_column::{AppliedSorting, sort_data_rows}, + }, + types::{AnyColumn, DataRow, DisplayRow, TableCell, TableLikeContent}, }; +pub mod filtering_by_column; pub mod sorting_by_column; #[derive(Default)] pub(crate) struct TableDataEngine { + pub filter_stack: FilterStack, + /// Pre-computed unique values per column, used to populate filter menus + all_filters: HashMap>, pub applied_sorting: Option, d2d_mapping: DisplayToDataMapping, - pub contents: TableLikeContent, + pub contents: Arc, } impl TableDataEngine { @@ -28,32 +38,47 @@ impl TableDataEngine { &self.d2d_mapping } - pub(crate) fn apply_sort(&mut self) { - self.d2d_mapping - .apply_sorting(self.applied_sorting, &self.contents.rows); - self.d2d_mapping.merge_mappings(); + pub(crate) fn set_d2d_mapping(&mut self, mapping: DisplayToDataMapping) { + self.d2d_mapping = mapping; } - /// Applies sorting and filtering to the data and produces display to data mapping - pub(crate) fn calculate_d2d_mapping(&mut self) { - self.d2d_mapping - .apply_sorting(self.applied_sorting, &self.contents.rows); - self.d2d_mapping.merge_mappings(); + /// Recomputes the unique filter entries for every column from the current table data. + /// Must be called after content changes (e.g. after parsing). + pub fn calculate_available_filters(&mut self) { + self.all_filters = + calculate_available_filters(&self.contents.rows, self.contents.number_of_cols); } } /// Relation of Display (rendered) rows to Data (src) rows with applied transformations /// Transformations applied: /// - sorting by column +/// - filtering by column values #[derive(Debug, Default)] pub struct DisplayToDataMapping { - /// All rows sorted, regardless of applied filtering. Applied every time sorting changes + /// All rows sorted, regardless of applied filtering. Recomputed every time sorting changes pub sorted_rows: Vec, - /// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows` + /// Rows that survive the active filters. Recomputed every time filters change + pub retained_rows: HashSet, + /// Merged result: sorted rows intersected with retained rows pub mapping: Arc>, } impl DisplayToDataMapping { + /// Computes the full display-to-data mapping from owned inputs. + /// Intended to be called from a background thread. + pub(crate) fn compute( + contents: &Arc, + filter_stack: &FilterStack, + sorting: Option, + ) -> Self { + let mut mapping = Self::default(); + mapping.apply_sorting(sorting, &contents.rows); + mapping.apply_filtering(filter_stack, &contents.rows); + mapping.merge_mappings(); + mapping + } + /// Get the data row for a given display row pub fn get_data_row(&self, display_row: DisplayRow) -> Option { self.mapping.get(&display_row).copied() @@ -77,11 +102,16 @@ impl DisplayToDataMapping { self.sorted_rows = sorted_rows; } - /// Take pre-computed sorting and filtering results, and apply them to the mapping + fn apply_filtering(&mut self, filter_stack: &FilterStack, rows: &[TableRow]) { + self.retained_rows = retain_rows(rows, filter_stack); + } + + /// Merges pre-computed sorting and filtering into the final display mapping fn merge_mappings(&mut self) { self.mapping = Arc::new( self.sorted_rows .iter() + .filter(|data_row| self.retained_rows.contains(data_row)) .enumerate() .map(|(display, data)| (DisplayRow(display), *data)) .collect(), diff --git a/crates/csv_preview/src/table_data_engine/filtering_by_column.rs b/crates/csv_preview/src/table_data_engine/filtering_by_column.rs new file mode 100644 index 00000000000..b2b8a78c05a --- /dev/null +++ b/crates/csv_preview/src/table_data_engine/filtering_by_column.rs @@ -0,0 +1,250 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use ui::{SharedString, table_row::TableRow}; + +use crate::{ + table_data_engine::TableDataEngine, + types::{AnyColumn, DataRow, TableCell}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FilterEntryState { + Available { is_applied: bool }, + Unavailable { blocked_by: AnyColumn }, +} + +#[derive(Debug, Clone)] +pub struct FilterEntry { + /// Content to display. None if cell is virtual + pub content: Option, + /// List of rows in which this value occurs + pub rows: Vec, +} + +impl FilterEntry { + pub(crate) fn occurred_times(&self) -> usize { + self.rows.len() + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct FilterStack { + /// Columns in the order their first filter was applied, used to compute cascade availability + activation_order: Vec, + /// Which cell values are currently allowed for each filtered column + retention_config: HashMap>>, +} + +impl TableDataEngine { + pub(crate) fn has_active_filters(&self, col: AnyColumn) -> bool { + self.filter_stack.retention_config.contains_key(&col) + } + + /// Get available filters for a specific column with cascade behavior. + /// + /// A filter entry is "unavailable" when all of its rows are hidden by a + /// filter on an earlier-activated column, meaning selecting it would show + /// zero rows. The cascade walk stops at `column` so that the column's own + /// current filter does not affect its own entry availability. + pub(crate) fn get_filters_for_column( + &self, + column: AnyColumn, + ) -> anyhow::Result>> { + let all_column_entries = self + .all_filters + .get(&column) + .ok_or_else(|| anyhow::anyhow!("Expected {column:?} to have filter entries"))?; + + let mut unavailable_entries: HashMap, AnyColumn> = HashMap::new(); + + for &column_applied_previously in &self.filter_stack.activation_order { + if column_applied_previously == column { + break; + } + + let retained_values = self + .filter_stack + .retention_config + .get(&column_applied_previously) + .ok_or_else(|| { + anyhow::anyhow!( + "Expected {column_applied_previously:?} to have retained entries \ + as it is present in the filter stack" + ) + })?; + + // Rows that survive the filter on `column_applied_previously` + let retained_rows: HashSet = self + .contents + .rows + .iter() + .enumerate() + .filter(|(_, row)| { + let cell_value = row + .get(column_applied_previously) + .and_then(|cell| cell.display_value().cloned()); + retained_values.contains(&cell_value) + }) + .map(|(index, _)| DataRow(index)) + .collect(); + + // An entry is unavailable when none of its rows survive the parent filter + for entry in all_column_entries { + if !entry.rows.iter().any(|row| retained_rows.contains(row)) { + unavailable_entries.insert(entry.content.clone(), column_applied_previously); + } + } + } + + let empty = HashSet::new(); + let active_column_filters = self + .filter_stack + .retention_config + .get(&column) + .unwrap_or(&empty); + + Ok(Arc::new( + all_column_entries + .iter() + .map(|entry| { + let state = if let Some(&blocked_by) = unavailable_entries.get(&entry.content) { + FilterEntryState::Unavailable { blocked_by } + } else { + FilterEntryState::Available { + is_applied: active_column_filters.contains(&entry.content), + } + }; + (entry.clone(), state) + }) + .collect(), + )) + } + + pub(crate) fn clear_filters_for_col(&mut self, col: AnyColumn) { + self.filter_stack + .activation_order + .retain(|&entry| entry != col); + self.filter_stack.retention_config.remove(&col); + } + + /// Toggle a filter value for a column. Returns `true` if the filter was + /// added, `false` if it was removed. + pub(crate) fn toggle_filter( + &mut self, + column: AnyColumn, + value: Option, + ) -> anyhow::Result { + let is_currently_applied = self + .filter_stack + .retention_config + .get(&column) + .is_some_and(|filters| filters.contains(&value)); + + if is_currently_applied { + self.remove_filter(column, value)?; + Ok(false) + } else { + self.apply_filter(column, value); + Ok(true) + } + } + + fn remove_filter( + &mut self, + column: AnyColumn, + value: Option, + ) -> anyhow::Result<()> { + let entries = self + .filter_stack + .retention_config + .get_mut(&column) + .ok_or_else(|| { + anyhow::anyhow!("Expected {column:?} to be present in active filters") + })?; + + debug_assert!( + entries.contains(&value), + "Expected value to be present in {column:?} active filters" + ); + + if entries.len() == 1 { + self.filter_stack.retention_config.remove(&column); + self.filter_stack + .activation_order + .retain(|&entry| entry != column); + } else { + entries.remove(&value); + } + Ok(()) + } + + fn apply_filter(&mut self, column: AnyColumn, value: Option) { + // Track the column only on its first activation to preserve cascade order + if !self.filter_stack.activation_order.contains(&column) { + self.filter_stack.activation_order.push(column); + } + self.filter_stack + .retention_config + .entry(column) + .or_default() + .insert(value); + } +} + +/// Calculate available filter entries for each column from the table data. +pub fn calculate_available_filters( + content_rows: &[TableRow], + number_of_cols: usize, +) -> HashMap> { + let mut available_filters = HashMap::new(); + + for col_idx in 0..number_of_cols { + let column = AnyColumn::new(col_idx); + let mut value_to_rows: HashMap, Vec> = HashMap::new(); + + for (row_index, row) in content_rows.iter().enumerate() { + let cell_value = row + .get(column) + .and_then(|cell| cell.display_value().cloned()); + value_to_rows + .entry(cell_value) + .or_default() + .push(DataRow(row_index)); + } + + let filter_entries: Vec = value_to_rows + .into_iter() + .map(|(content, rows)| FilterEntry { content, rows }) + .collect(); + + available_filters.insert(column, filter_entries); + } + + available_filters +} + +/// Returns the set of data rows that survive all active filters in the stack. +pub fn retain_rows( + content_rows: &[TableRow], + filter_stack: &FilterStack, +) -> HashSet { + let config = &filter_stack.retention_config; + if config.is_empty() { + return (0..content_rows.len()).map(DataRow).collect(); + } + + content_rows + .iter() + .enumerate() + .filter(|(_, row)| { + config.iter().all(|(col, allowed_values)| { + let cell_value = row.get(*col).and_then(|cell| cell.display_value().cloned()); + allowed_values.contains(&cell_value) + }) + }) + .map(|(index, _)| DataRow(index)) + .collect() +} diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index 87e5110ff52..a422d2811db 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -27,4 +27,4 @@ workspace = true [[bin]] name = "docs_preprocessor" -path = "src/main.rs" \ No newline at end of file +path = "src/main.rs" diff --git a/crates/docs_preprocessor/src/ai_discovery.rs b/crates/docs_preprocessor/src/ai_discovery.rs new file mode 100644 index 00000000000..4f3f9195827 --- /dev/null +++ b/crates/docs_preprocessor/src/ai_discovery.rs @@ -0,0 +1,549 @@ +use anyhow::{Context, Result}; +use mdbook::BookItem; +use mdbook::book::Book; +use regex::Regex; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::FRONT_MATTER_COMMENT; + +#[derive(Debug)] +pub(crate) struct DocsPage { + section: String, + title: String, + description: Option, + pub(crate) source_path: PathBuf, + content: String, +} + +pub(crate) fn write_ai_discovery_artifacts( + pages: &[DocsPage], + destination: &Path, + site_url: &str, +) -> Result<()> { + copy_markdown_sources(destination, site_url, pages)?; + write_llms_txt(destination, site_url, pages)?; + write_sitemap_xml(destination, site_url, pages)?; + Ok(()) +} + +pub(crate) fn docs_pages(book: &Book) -> Result> { + let mut pages = Vec::new(); + let mut section = "Docs".to_string(); + for item in book.iter() { + let BookItem::Chapter(chapter) = item else { + if let BookItem::PartTitle(part_title) = item { + section.clone_from(part_title); + } + continue; + }; + let Some(source_path) = chapter.source_path.as_ref() else { + continue; + }; + if source_path == Path::new("SUMMARY.md") { + continue; + } + pages.push(DocsPage { + section: section.clone(), + title: chapter.name.clone(), + description: docs_page_description(&chapter.content), + source_path: source_path.clone(), + content: chapter.content.clone(), + }); + } + Ok(pages) +} + +fn copy_markdown_sources(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + for page in pages { + let destination = destination.join(&page.source_path); + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create markdown destination {}", parent.display()) + })?; + } + let contents = rewrite_docs_links(&markdown_source_contents(&page.content), site_url); + std::fs::write( + &destination, + add_llms_markdown_directive(&contents, site_url), + ) + .with_context(|| { + format!( + "failed to write markdown page {} to {}", + page.source_path.display(), + destination.display() + ) + })?; + } + let getting_started = destination.join("getting-started.md"); + if getting_started.exists() { + std::fs::copy(&getting_started, destination.join("index.md")) + .context("failed to write index.md markdown alias")?; + } + Ok(()) +} + +fn markdown_source_contents(contents: &str) -> String { + front_matter_comment_regex() + .replace(contents, "") + .trim_start() + .to_string() +} + +fn docs_page_description(contents: &str) -> Option { + docs_page_metadata(contents).and_then(|metadata| { + metadata + .get("description") + .map(|description| { + description + .trim() + .trim_matches('"') + .split_whitespace() + .collect::>() + .join(" ") + }) + .filter(|description| !description.is_empty()) + }) +} + +fn docs_page_metadata(contents: &str) -> Option> { + let captures = front_matter_comment_regex().captures(contents)?; + serde_json::from_str(&captures[1]).ok() +} + +fn front_matter_comment_regex() -> &'static Regex { + static FRONT_MATTER_COMMENT_REGEX: OnceLock = OnceLock::new(); + FRONT_MATTER_COMMENT_REGEX + .get_or_init(|| Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "([^\\n]*)")).unwrap()) +} + +fn write_llms_txt(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("# Zed Docs\n\n"); + contents.push_str( + "> Official Zed documentation index with links to Markdown versions of each docs page.\n\n", + ); + contents.push_str( + "Use these links for concise Markdown copies of Zed documentation pages. Each linked page mirrors the corresponding `/docs/*.html` page without site navigation or styling.\n\n", + ); + let mut current_section = None; + for page in pages { + if current_section != Some(page.section.as_str()) { + if current_section.is_some() { + contents.push('\n'); + } + contents.push_str("## "); + contents.push_str(&markdown_text(&page.section)); + contents.push_str("\n\n"); + current_section = Some(page.section.as_str()); + } + contents.push_str("- ["); + contents.push_str(&markdown_text(&page.title)); + contents.push_str("]("); + contents.push_str(&absolute_docs_url(site_url, &page.source_path)); + contents.push(')'); + if let Some(description) = &page.description { + contents.push_str(": "); + contents.push_str(&markdown_text(description)); + } + contents.push('\n'); + } + std::fs::write(destination.join("llms.txt"), contents).context("failed to write llms.txt")?; + Ok(()) +} + +fn markdown_text(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") +} + +fn write_sitemap_xml(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("\n"); + contents.push_str("\n"); + for page in pages { + contents.push_str(" "); + contents.push_str(&xml_escape(&absolute_docs_url( + site_url, + &page.source_path.with_extension("html"), + ))); + contents.push_str(""); + contents.push_str("\n"); + } + contents.push_str("\n"); + std::fs::write(destination.join("sitemap.xml"), contents) + .context("failed to write sitemap.xml")?; + Ok(()) +} + +pub(crate) fn write_pages_redirects( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + let Some(deploy_root) = destination.parent() else { + return Ok(()); + }; + let mut contents = String::new(); + for (source, destination) in redirects { + write_redirect_line( + &mut contents, + &docs_path("/docs/", source), + &redirect_destination(site_url, destination), + ); + if let Some(extensionless_source) = strip_html_suffix(source) { + write_redirect_line( + &mut contents, + &docs_path("/docs/", &extensionless_source), + &redirect_destination( + site_url, + &strip_html_suffix(destination).unwrap_or_else(|| destination.to_string()), + ), + ); + } + if let Some(markdown_source) = html_path_to_markdown(source) { + if let Some(markdown_destination) = html_path_to_markdown(destination) { + write_redirect_line( + &mut contents, + &docs_path("/docs/", &markdown_source), + &redirect_destination(site_url, &markdown_destination), + ); + } + } + } + std::fs::write(deploy_root.join("_redirects"), contents) + .context("failed to write Cloudflare Pages _redirects")?; + Ok(()) +} + +pub(crate) fn write_markdown_redirect_aliases( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + for (source, redirect_destination_path) in redirects { + let Some(source_markdown) = html_path_to_markdown(source) else { + continue; + }; + let Some(destination_markdown) = html_path_to_markdown(redirect_destination_path) else { + continue; + }; + let source_markdown = destination.join(source_markdown.trim_start_matches('/')); + let destination_markdown = + destination.join(destination_markdown.trim_start_matches("/docs/")); + if !destination_markdown.exists() { + continue; + } + if let Some(parent) = source_markdown.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create markdown alias directory {}", + parent.display() + ) + })?; + } + let contents = format!( + "# Moved\n\n> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\nThis page moved to [the current docs page]({}).\n", + docs_url(site_url, Path::new("llms.txt")), + html_path_to_markdown(redirect_destination_path) + .map(|path| redirect_destination(site_url, &path)) + .unwrap_or_else(|| redirect_destination(site_url, redirect_destination_path)) + ); + std::fs::write(&source_markdown, contents).with_context(|| { + format!( + "failed to write markdown redirect alias from {} to {}", + redirect_destination_path, + source_markdown.display() + ) + })?; + } + Ok(()) +} + +fn write_redirect_line(contents: &mut String, source: &str, destination: &str) { + contents.push_str(source); + contents.push(' '); + contents.push_str(destination); + contents.push_str(" 301\n"); +} + +fn docs_path(site_url: &str, path: &str) -> String { + docs_url(site_url, Path::new(path.trim_start_matches('/'))) +} + +fn redirect_destination(site_url: &str, destination: &str) -> String { + if let Some(path) = destination.strip_prefix("/docs/") { + docs_url(site_url, Path::new(path)) + } else if destination == "/docs" { + docs_url(site_url, Path::new("")) + } else { + destination.to_string() + } +} + +fn strip_html_suffix(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + let path = path.strip_suffix(".html")?; + Some(format!("{path}{fragment}")) +} + +fn html_path_to_markdown(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + if !path.starts_with("/docs/") && path != "/docs" && !path.ends_with(".html") { + return None; + } + let markdown_path = path.strip_suffix(".html").unwrap_or(path); + Some(format!("{markdown_path}.md{fragment}")) +} + +fn split_fragment(path: &str) -> (&str, &str) { + match path.find('#') { + Some(index) => (&path[..index], &path[index..]), + None => (path, ""), + } +} + +pub(crate) fn rewrite_docs_links(contents: &str, site_url: &str) -> String { + const STABLE_DOCS_PREFIX: &str = "https://zed.dev/docs/"; + let channel_docs_prefix = absolute_docs_url(site_url, Path::new("")); + if channel_docs_prefix == STABLE_DOCS_PREFIX { + return contents.to_string(); + } + + let mut output = String::with_capacity(contents.len()); + let mut remaining = contents; + while let Some(index) = remaining.find(STABLE_DOCS_PREFIX) { + output.push_str(&remaining[..index]); + let after_prefix = &remaining[index + STABLE_DOCS_PREFIX.len()..]; + if after_prefix.starts_with("preview/") || after_prefix.starts_with("nightly/") { + output.push_str(STABLE_DOCS_PREFIX); + } else { + output.push_str(&channel_docs_prefix); + } + remaining = after_prefix; + } + output.push_str(remaining); + output +} + +pub(crate) fn add_markdown_alternate_link( + contents: &str, + html_file: &Path, + root_dir: &Path, + site_url: &str, +) -> String { + let Ok(relative_path) = html_file.strip_prefix(root_dir) else { + return contents.to_string(); + }; + let markdown_path = relative_path.with_extension("md"); + if !root_dir.join(&markdown_path).exists() { + return contents.to_string(); + } + let markdown_url = docs_url(site_url, &markdown_path); + let link = format!( + " \n", + markdown_url + ); + contents.replacen("", &(link + " "), 1) +} + +fn add_llms_markdown_directive(contents: &str, site_url: &str) -> String { + let directive = format!( + "> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\n", + docs_url(site_url, Path::new("llms.txt")), + ); + if let Some(rest) = contents.strip_prefix("---\n") { + if let Some(frontmatter_end) = rest.find("\n---\n") { + let split_at = "---\n".len() + frontmatter_end + "\n---\n".len(); + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&contents[..split_at]); + output.push('\n'); + output.push_str(&directive); + output.push_str(&contents[split_at..]); + return output; + } + } + + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&directive); + output.push_str(contents); + output +} + +fn docs_url(site_url: &str, path: &Path) -> String { + let mut url = site_url.to_string(); + if !url.ends_with('/') { + url.push('/'); + } + url.push_str(&path.to_string_lossy().replace('\\', "/")); + url +} + +fn absolute_docs_url(site_url: &str, path: &Path) -> String { + let url = docs_url(site_url, path); + if url.starts_with("http://") || url.starts_with("https://") { + url + } else { + format!("https://zed.dev{}", url) + } +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_llms_markdown_directive_inserts_after_frontmatter() { + let contents = "---\ntitle: Example\n---\n# Example\n"; + let output = add_llms_markdown_directive(contents, "/docs/"); + + assert!(output.starts_with("---\ntitle: Example\n---\n\n")); + assert!(output.contains( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt)." + )); + } + + #[test] + fn test_redirect_destination_uses_channel_site_url_for_docs_paths() { + assert_eq!( + redirect_destination("/docs/preview/", "/docs/ai/overview.html"), + "/docs/preview/ai/overview.html" + ); + assert_eq!( + redirect_destination("/docs/preview/", "/community-links"), + "/community-links" + ); + } + + #[test] + fn test_rewrite_docs_links_uses_channel_site_url() { + assert_eq!( + rewrite_docs_links( + "See [Code Actions](https://zed.dev/docs/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html).", + "/docs/preview/" + ), + "See [Code Actions](https://zed.dev/docs/preview/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html)." + ); + } + + #[test] + fn test_docs_path_uses_channel_site_url() { + assert_eq!( + docs_path("/docs/preview/", "/assistant.md"), + "/docs/preview/assistant.md" + ); + } + + #[test] + fn test_write_pages_redirects_keeps_sources_on_internal_pages_path() -> Result<()> { + let deploy_root = std::env::temp_dir().join(format!( + "docs_preprocessor_pages_redirects_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + let destination = deploy_root.join("docs"); + std::fs::create_dir_all(&destination)?; + let redirects = vec![ + ( + "/assistant.html".to_string(), + "/docs/ai/overview.html".to_string(), + ), + ( + "/community/feedback.html".to_string(), + "/community-links".to_string(), + ), + ]; + + write_pages_redirects(&destination, &redirects, "/docs/preview/")?; + + assert_eq!( + std::fs::read_to_string(deploy_root.join("_redirects"))?, + "/docs/assistant.html /docs/preview/ai/overview.html 301\n\ +/docs/assistant /docs/preview/ai/overview 301\n\ +/docs/assistant.md /docs/preview/ai/overview.md 301\n\ +/docs/community/feedback.html /community-links 301\n\ +/docs/community/feedback /community-links 301\n" + ); + std::fs::remove_dir_all(&deploy_root)?; + Ok(()) + } + + #[test] + fn test_write_ai_discovery_artifacts_generates_agent_facing_metadata() -> Result<()> { + let destination = std::env::temp_dir().join(format!( + "docs_preprocessor_ai_discovery_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&destination)?; + + let pages = vec![ + DocsPage { + section: "Docs".to_string(), + title: "Getting Started".to_string(), + description: Some("Start using Zed.".to_string()), + source_path: PathBuf::from("getting-started.md"), + content: format!( + "{}\n# Getting Started\n", + FRONT_MATTER_COMMENT.replace("{}", r#"{"description":"Start using Zed."}"#) + ), + }, + DocsPage { + section: "AI".to_string(), + title: "MCP".to_string(), + description: Some("Connect model context servers.".to_string()), + source_path: PathBuf::from("ai/mcp.md"), + content: format!( + "{}\n# MCP\n", + FRONT_MATTER_COMMENT + .replace("{}", r#"{"description":"Connect model context servers."}"#) + ), + }, + ]; + + write_ai_discovery_artifacts(&pages, &destination, "/docs/")?; + + let llms_txt = std::fs::read_to_string(destination.join("llms.txt"))?; + assert!(llms_txt.contains("## Docs")); + assert!(llms_txt.contains( + "- [Getting Started](https://zed.dev/docs/getting-started.md): Start using Zed." + )); + assert!(llms_txt.contains("## AI")); + assert!( + llms_txt.contains( + "- [MCP](https://zed.dev/docs/ai/mcp.md): Connect model context servers." + ) + ); + + let sitemap_xml = std::fs::read_to_string(destination.join("sitemap.xml"))?; + assert!(sitemap_xml.contains("https://zed.dev/docs/getting-started.html")); + assert!(sitemap_xml.contains("https://zed.dev/docs/ai/mcp.html")); + + let mcp_markdown = std::fs::read_to_string(destination.join("ai/mcp.md"))?; + assert!(mcp_markdown.starts_with( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt).\n\n# MCP" + )); + assert!(!mcp_markdown.contains("ZED_META")); + + let index_markdown = std::fs::read_to_string(destination.join("index.md"))?; + assert!(index_markdown.contains("# Getting Started")); + + std::fs::remove_dir_all(&destination)?; + Ok(()) + } +} diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index df3b556d6a4..4b0c2ca04bc 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -1,4 +1,10 @@ use anyhow::{Context, Result}; +mod ai_discovery; + +use ai_discovery::{ + add_markdown_alternate_link, docs_pages, rewrite_docs_links, write_ai_discovery_artifacts, + write_markdown_redirect_aliases, write_pages_redirects, +}; use mdbook::BookItem; use mdbook::book::{Book, Chapter}; use mdbook::preprocess::CmdPreprocessor; @@ -188,7 +194,7 @@ fn handle_preprocessing() -> Result<()> { template_big_table_of_actions(&mut book); template_and_validate_keybindings(&mut book, &mut errors); template_and_validate_actions(&mut book, &mut errors); - template_and_validate_json_snippets(&mut book, &mut errors); + template_and_validate_json_snippets(&mut book, &mut errors)?; if !errors.is_empty() { const ANSI_RED: &str = "\x1b[31m"; @@ -252,7 +258,7 @@ fn format_binding(binding: String) -> String { } fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -300,7 +306,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#action (.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#action\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -379,7 +385,10 @@ fn find_binding_with_overlay( .or_else(|| find_binding(os, action)) } -fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { +fn template_and_validate_json_snippets( + book: &mut Book, + errors: &mut HashSet, +) -> Result<()> { let params = SettingsJsonSchemaParams { language_names: &[], font_names: &[], @@ -393,7 +402,7 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
 Result<()> {
         .as_table_mut()
         .expect("output is table");
     let zed_html = output.remove("zed-html").expect("zed-html output defined");
+    let redirects = zed_html
+        .get("redirect")
+        .and_then(|redirects| redirects.as_table())
+        .map(|redirects| {
+            redirects
+                .iter()
+                .filter_map(|(source, destination)| {
+                    destination
+                        .as_str()
+                        .map(|destination| (source.clone(), destination.to_string()))
+                })
+                .collect::>()
+        });
     let default_description = zed_html
         .get("default-description")
         .expect("Default description not found")
@@ -694,6 +718,17 @@ fn handle_postprocessing() -> Result<()> {
     let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
     let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
     let docs_channel = std::env::var("DOCS_CHANNEL").unwrap_or_else(|_| "stable".to_string());
+    let site_url = std::env::var("MDBOOK_BOOK__SITE_URL")
+        .ok()
+        .filter(|site_url| !site_url.trim().is_empty())
+        .unwrap_or_else(|| {
+            match docs_channel.as_str() {
+                "nightly" => "/docs/nightly/",
+                "preview" => "/docs/preview/",
+                _ => "/docs/",
+            }
+            .to_string()
+        });
     let noindex = if docs_channel == "nightly" || docs_channel == "preview" {
         ""
     } else {
@@ -733,8 +768,10 @@ fn handle_postprocessing() -> Result<()> {
     }
 
     zlog::info!(logger => "Processing {} `.html` files", files.len());
+    let pages = docs_pages(&ctx.book)?;
+    write_ai_discovery_artifacts(&pages, &root_dir, &site_url)?;
     let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
-    for file in files {
+    for file in &files {
         let contents = std::fs::read_to_string(&file)?;
         let mut meta_description = None;
         let mut meta_title = None;
@@ -770,14 +807,19 @@ fn handle_postprocessing() -> Result<()> {
         let contents = contents.replace("#amplitude_key#", &litude_key);
         let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
         let contents = contents.replace("#noindex#", noindex);
+        let contents = rewrite_docs_links(&contents, &site_url);
+        let contents = add_markdown_alternate_link(&contents, file, &root_dir, &site_url);
         let contents = title_regex()
             .replace(&contents, |_: ®ex::Captures| {
                 format!("{}", meta_title)
             })
             .to_string();
-        // let contents = contents.replace("#title#", &meta_title);
         std::fs::write(file, contents)?;
     }
+    if let Some(redirects) = redirects {
+        write_markdown_redirect_aliases(&root_dir, &redirects, &site_url)?;
+        write_pages_redirects(&root_dir, &redirects, &site_url)?;
+    }
     return Ok(());
 
     fn pretty_path<'a>(
@@ -906,66 +948,4 @@ fn keymap_schema_for_actions(
 }
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-    use serde_json::json;
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_over_parameterized() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_falls_back_to_parameterized_match() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_regardless_of_order() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
-                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_later_section_overrides_earlier() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            { "bindings": { "ctrl-a": "some::Action" } },
-            { "bindings": { "ctrl-b": "some::Action" } }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "some::Action");
-        assert_eq!(binding.as_deref(), Some("ctrl-b"));
-    }
-}
+mod tests;
diff --git a/crates/docs_preprocessor/src/tests.rs b/crates/docs_preprocessor/src/tests.rs
new file mode 100644
index 00000000000..43438207d8c
--- /dev/null
+++ b/crates/docs_preprocessor/src/tests.rs
@@ -0,0 +1,61 @@
+use super::*;
+use serde_json::json;
+
+#[test]
+fn test_find_binding_prefers_exact_match_over_parameterized() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_falls_back_to_parameterized_match() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
+}
+
+#[test]
+fn test_find_binding_prefers_exact_match_regardless_of_order() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
+                "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_later_section_overrides_earlier() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        { "bindings": { "ctrl-a": "some::Action" } },
+        { "bindings": { "ctrl-b": "some::Action" } }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "some::Action");
+    assert_eq!(binding.as_deref(), Some("ctrl-b"));
+}
diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs
index 0f7896bc882..592d5e3b1af 100644
--- a/crates/edit_prediction/src/prediction.rs
+++ b/crates/edit_prediction/src/prediction.rs
@@ -104,6 +104,34 @@ impl EditPredictionResult {
             e2e_latency,
         }
     }
+
+    pub fn new_rejected(
+        id: EditPredictionId,
+        edited_buffer: &Entity,
+        edited_buffer_snapshot: &BufferSnapshot,
+        inputs: EditPredictionInputs,
+        model_version: Option,
+        trigger: PredictEditsRequestTrigger,
+        e2e_latency: std::time::Duration,
+        reject_reason: EditPredictionRejectReason,
+    ) -> Self {
+        Self {
+            prediction: EditPrediction {
+                id,
+                edits: Arc::default(),
+                cursor_position: None,
+                editable_range: None,
+                snapshot: edited_buffer_snapshot.clone(),
+                edit_preview: EditPreview::unchanged(edited_buffer_snapshot),
+                inputs,
+                buffer: edited_buffer.clone(),
+                model_version,
+                trigger,
+            },
+            reject_reason: Some(reject_reason),
+            e2e_latency,
+        }
+    }
 }
 
 #[derive(Clone)]
diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs
index 009d91c5b57..54b324253ad 100644
--- a/crates/edit_prediction/src/zeta.rs
+++ b/crates/edit_prediction/src/zeta.rs
@@ -9,7 +9,9 @@ use crate::{
     udiff::prediction_edits_for_single_file_diff,
 };
 use anyhow::{Context as _, Result};
-use cloud_llm_client::{AcceptEditPredictionBody, predict_edits_v3::RawCompletionRequest};
+use cloud_llm_client::{
+    AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest,
+};
 use edit_prediction_types::PredictedCursorPosition;
 use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*};
 use language::{
@@ -516,26 +518,41 @@ pub(crate) fn request_prediction_with_zeta(
                 snapshot: fallback_snapshot,
                 patch,
             } => {
-                let Some((buffer, snapshot, edits, cursor_position)) =
-                    prediction_edits_for_single_file_diff(&patch, &project, cx).await?
-                else {
-                    return Ok(Some(
-                        EditPredictionResult::new(
-                            id,
-                            &fallback_buffer,
-                            &fallback_snapshot,
-                            Arc::new([]),
-                            None,
-                            None,
-                            inputs,
-                            model_version,
-                            trigger,
-                            request_duration,
-                            cx,
-                        )
-                        .await,
-                    ));
-                };
+                let (buffer, snapshot, edits, cursor_position) =
+                    match prediction_edits_for_single_file_diff(&patch, &project, cx).await {
+                        Ok(Some(edits)) => edits,
+                        Ok(None) => {
+                            return Ok(Some(
+                                EditPredictionResult::new(
+                                    id,
+                                    &fallback_buffer,
+                                    &fallback_snapshot,
+                                    Arc::new([]),
+                                    None,
+                                    None,
+                                    inputs,
+                                    model_version,
+                                    trigger,
+                                    request_duration,
+                                    cx,
+                                )
+                                .await,
+                            ));
+                        }
+                        Err(error) => {
+                            log::error!("failed to apply edit prediction patch: {error:?}");
+                            return Ok(Some(EditPredictionResult::new_rejected(
+                                id,
+                                &fallback_buffer,
+                                &fallback_snapshot,
+                                inputs,
+                                model_version,
+                                trigger,
+                                request_duration,
+                                EditPredictionRejectReason::PatchApplyFailed,
+                            )));
+                        }
+                    };
                 let editable_range_in_buffer =
                     edits
                         .iter()
diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs
index f0b9cfb4f5d..2bd544736a5 100644
--- a/crates/editor/src/actions.rs
+++ b/crates/editor/src/actions.rs
@@ -669,10 +669,14 @@ actions!(
         MoveToEnd,
         /// Moves cursor to the end of the paragraph.
         MoveToEndOfParagraph,
+        /// Moves cursor to the start of the next comment paragraph.
+        MoveToNextCommentParagraph,
         /// Moves cursor to the end of the next subword.
         MoveToNextSubwordEnd,
         /// Moves cursor to the end of the next word.
         MoveToNextWordEnd,
+        /// Moves cursor to the start of the previous comment paragraph.
+        MoveToPreviousCommentParagraph,
         /// Moves cursor to the start of the previous subword.
         MoveToPreviousSubwordStart,
         /// Moves cursor to the start of the previous word.
diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs
index c8e1a4424b1..1a7b9d27d0d 100644
--- a/crates/editor/src/display_map/block_map.rs
+++ b/crates/editor/src/display_map/block_map.rs
@@ -963,9 +963,14 @@ impl BlockMap {
             // Ensure the edit starts at a transform boundary.
             // If the edit starts within an isomorphic transform, preserve its prefix
             // If the edit lands within a replacement block, expand the edit to include the start of the replaced input range
-            let transform = cursor.item().unwrap();
+            // The cursor can sit past the end of the tree when a companion edit
+            // is anchored at the new end-of-file and maps to `old_start` at the
+            // very end of the old transforms; in that case there is no transform
+            // preceding the edit and nothing to preserve.
             let transform_rows_before_edit = old_start - *cursor.start();
-            if transform_rows_before_edit > RowDelta(0) {
+            if transform_rows_before_edit > RowDelta(0)
+                && let Some(transform) = cursor.item()
+            {
                 if transform.block.is_none() {
                     // Preserve any portion of the old isomorphic transform that precedes this edit.
                     push_isomorphic(
@@ -5019,6 +5024,61 @@ mod tests {
         );
     }
 
+    // Regression test for ZED-9V4: `BlockMap::sync` walks the (old) transform
+    // tree with a `WrapRow` cursor and used to `cursor.item().unwrap()` for
+    // every edit, assuming each edit's `old.start` lands strictly inside the
+    // tree. The companion (split-diff) branch of `sync` can compose an edit
+    // anchored at the trailing boundary of the old transforms
+    // (`old.start == input_rows`), at which point the cursor is past the end of
+    // the tree and `item()` is `None`. That used to abort the process.
+    #[gpui::test]
+    fn test_sync_edit_anchored_at_end_of_transforms(cx: &mut gpui::TestAppContext) {
+        cx.update(init_test);
+
+        let buffer = cx.update(|cx| MultiBuffer::build_simple("aaa\nbbb\nccc\n", cx));
+        let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx));
+        let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
+
+        let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
+        let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot);
+        let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
+        let (wrap_map, old_wrap_snapshot) =
+            cx.update(|cx| WrapMap::new(tab_snapshot, test_font(), px(14.0), None, cx));
+        let block_map = BlockMap::new(old_wrap_snapshot.clone(), 0, 0);
+
+        // The tree now spans exactly `old_end` input rows.
+        let old_end = old_wrap_snapshot.max_point().row() + RowDelta(1);
+
+        // Grow the buffer so the new snapshot is larger than the old transforms.
+        let buffer_snapshot = buffer.update(cx, |buffer, cx| {
+            buffer.edit(
+                [(Point::new(3, 0)..Point::new(3, 0), "ddd\neee\n")],
+                None,
+                cx,
+            );
+            buffer.snapshot(cx)
+        });
+        let (inlay_snapshot, inlay_edits) =
+            inlay_map.sync(buffer_snapshot, subscription.consume().into_inner());
+        let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits);
+        let (tab_snapshot, tab_edits) =
+            tab_map.sync(fold_snapshot, fold_edits, 4.try_into().unwrap());
+        let (new_wrap_snapshot, _) = wrap_map.update(cx, |wrap_map, cx| {
+            wrap_map.sync(tab_snapshot, tab_edits, cx)
+        });
+        let new_end = new_wrap_snapshot.max_point().row() + RowDelta(1);
+
+        // An edit anchored exactly at the end of the old transforms, of the shape
+        // the companion branch of `sync` can produce.
+        let edits = Patch::new(vec![text::Edit {
+            old: old_end..old_end,
+            new: old_end..new_end,
+        }]);
+
+        let snapshot = block_map.read(new_wrap_snapshot, edits, None);
+        assert_eq!(snapshot.snapshot.text(), "aaa\nbbb\nccc\nddd\neee\n");
+    }
+
     fn init_test(cx: &mut gpui::App) {
         let settings = SettingsStore::test(cx);
         cx.set_global(settings);
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index 87589880b53..a8ec9667ca8 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -9568,6 +9568,9 @@ impl Editor {
                 ranges,
                 path_key,
             } => {
+                if let Some(hovered_link_state) = self.hovered_link_state.as_mut() {
+                    hovered_link_state.symbol_range = None;
+                }
                 self.refresh_document_highlights(cx);
                 let buffer_id = buffer.read(cx).remote_id();
                 if self.buffer.read(cx).diff_for(buffer_id).is_none()
diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs
index 2fa319300e5..2a4ac54cf59 100644
--- a/crates/editor/src/editor_tests.rs
+++ b/crates/editor/src/editor_tests.rs
@@ -29,7 +29,8 @@ use language::{
     LanguageConfig, LanguageConfigOverride, LanguageMatcher, LanguageName, LanguageQueries,
     LanguageToolchainStore, Override, Point,
     language_settings::{
-        CompletionSettingsContent, FormatterList, LanguageSettingsContent, LspInsertMode,
+        CompletionSettingsContent, FormatOnSave, FormatterList, LanguageSettingsContent,
+        LspInsertMode,
     },
     tree_sitter_python,
 };
@@ -2897,6 +2898,237 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext)
     cx.assert_editor_state(&"ˇone\ntwo\n \nthree\nfour\nfive\n\nsix");
 }
 
+#[gpui::test]
+async fn test_move_to_next_and_previous_comment_paragraph(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                line_comments: vec!["// ".into()],
+                ..LanguageConfig::default()
+            },
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_override_query("[(line_comment)(block_comment)] @comment.inclusive")
+        .unwrap(),
+    );
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    // A blank comment line (`//`) splits one comment block into two paragraphs;
+    // a code line splits blocks; and a trailing comment preceded by code
+    // (`let x = 1; // ...`) is not a comment line at all.
+    cx.set_state(indoc! {"
+        ˇ// first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    cx.run_until_parked();
+
+    let next = |cx: &mut EditorTestContext| {
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_next_comment_paragraph(&MoveToNextCommentParagraph, window, cx)
+        });
+    };
+    let prev = |cx: &mut EditorTestContext| {
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx)
+        });
+    };
+
+    // Forward: skip the second line of the first paragraph, the blank comment
+    // line, the code line, and the trailing comment line.
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        ˇ// second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        ˇ// third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        ˇ// fourth paragraph
+    "});
+    // No paragraph after the last one: the caret stays put.
+    next(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        ˇ// fourth paragraph
+    "});
+
+    // Backward is the mirror image.
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        ˇ// third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // first paragraph line one
+        // first paragraph line two
+        //
+        ˇ// second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+    // No paragraph before the first one: the caret stays put.
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// first paragraph line one
+        // first paragraph line two
+        //
+        // second paragraph
+        fn code() {}
+        // third paragraph
+        let x = 1; // trailing comment, ignored
+        // fourth paragraph
+    "});
+}
+
+#[gpui::test]
+async fn test_move_to_previous_comment_paragraph_skips_current_paragraph(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                line_comments: vec!["// ".into()],
+                ..LanguageConfig::default()
+            },
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_override_query("[(line_comment)(block_comment)] @comment.inclusive")
+        .unwrap(),
+    );
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    let prev = |cx: &mut EditorTestContext| {
+        cx.update_editor(|editor, window, cx| {
+            editor.move_to_previous_comment_paragraph(&MoveToPreviousCommentParagraph, window, cx)
+        });
+    };
+
+    // Caret in the middle of the last line of the second paragraph: moving to
+    // the previous paragraph must skip the entire current paragraph and land on
+    // the first paragraph's start, not on the current paragraph's own start.
+    cx.set_state(indoc! {"
+        // alpha one
+        // alpha two
+        // alpha three
+
+        // beta one
+        // beta ˇtwo
+    "});
+    cx.run_until_parked();
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// alpha one
+        // alpha two
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+
+    // Same when the caret is part-way through the *first* line of the second
+    // paragraph.
+    cx.set_state(indoc! {"
+        // alpha one
+        // alpha two
+        // alpha three
+
+        // beˇta one
+        // beta two
+    "});
+    cx.run_until_parked();
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        ˇ// alpha one
+        // alpha two
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+
+    // Inside the first paragraph there is no previous paragraph, so the caret
+    // stays put rather than jumping to the current paragraph's own start.
+    cx.set_state(indoc! {"
+        // alpha one
+        // alpha ˇtwo
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+    cx.run_until_parked();
+    prev(&mut cx);
+    cx.assert_editor_state(indoc! {"
+        // alpha one
+        // alpha ˇtwo
+        // alpha three
+
+        // beta one
+        // beta two
+    "});
+}
+
 #[gpui::test]
 async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -2958,6 +3190,90 @@ async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_scroll_line_up_down_cursor_margin(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx).await;
+    let line_height = cx.update_editor(|editor, window, cx| {
+        editor.set_vertical_scroll_margin(2, cx);
+        editor
+            .style(cx)
+            .text
+            .line_height_in_pixels(window.rem_size())
+    });
+    let window = cx.window;
+    // 5 visible lines with margin=2: valid cursor rows are [top+2 .. top+2] (only the middle row)
+    cx.simulate_window_resize(window, size(px(1000.), 5. * line_height));
+
+    // Cursor at row 0 — autoscroll leaves viewport at y=0.
+    cx.set_state(indoc! {"
+        ˇone
+        two
+        three
+        four
+        five
+        six
+        seven
+        eight
+        nine
+        ten
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.);
+
+        // Scroll down 1: top=1, visible rows 1-5, margin=2 → min_row=3, max_row=3.
+        // Cursor at row 0 < min_row=3 → clamped to row 3.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 1.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            3,
+            "cursor clamped to min_row after scrolling down"
+        );
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Scroll up 1: top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2.
+        // Cursor at row 3 > max_row=2 → clamped to row 2.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            2,
+            "cursor clamped to max_row after scrolling up"
+        );
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Half page down (2 lines): top=2, visible rows 2-6, margin=2 → min_row=4, max_row=4.
+        // Cursor at row 2 < min_row=4 → clamped to row 4.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 2.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            4,
+            "cursor clamped to min_row after scrolling half page down"
+        );
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Half page up (2 lines): top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2.
+        // Cursor at row 4 > max_row=2 → clamped to row 2.
+        editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx);
+        assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.);
+        let snapshot = editor.display_snapshot(cx);
+        assert_eq!(
+            editor.selections.newest_display(&snapshot).head().row().0,
+            2,
+            "cursor clamped to max_row after scrolling half page up"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_autoscroll(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -15087,6 +15403,89 @@ async fn setup_range_format_test(
     .await
 }
 
+/// Like `setup_range_format_test`, but backs the buffer with a FakeFs git
+/// repository so that `GitStore::get_unstaged_diff` returns a real diff.
+/// `head_content` sets the HEAD base, `index_content` sets the staged base.
+/// The buffer starts empty; the caller must `editor.set_text(...)` to set the
+/// working-tree content (the diff recomputes from buffer changes).
+async fn setup_range_format_test_with_git<'a>(
+    cx: &'a mut TestAppContext,
+    head_content: &str,
+    index_content: &str,
+) -> (
+    Entity,
+    Entity,
+    &'a mut gpui::VisualTestContext,
+    lsp::FakeLanguageServer,
+) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "",
+        }),
+    )
+    .await;
+
+    fs.set_head_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", index_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").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 {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    // Open the unstaged diff so GitStore tracks this buffer. Without this,
+    // `get_unstaged_diff` returns None and compute_format_target cannot
+    // produce range-based FormatTarget.
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        window.focus(&editor.focus_handle(cx), cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    (project, editor, cx, fake_server)
+}
+
 fn refresh_editor_actions(cx: &mut VisualTestContext) {
     cx.executor().run_until_parked();
     cx.update(|window, cx| {
@@ -15299,6 +15698,940 @@ async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) {
     assert!(!cx.read(|cx| editor.is_dirty(cx)));
 }
 
+#[gpui::test]
+async fn test_modifications_format_on_save(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    fake_server
+        .set_request_handler::(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap()
+            );
+            Ok(Some(vec![lsp::TextEdit::new(
+                lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)),
+                ", ".to_string(),
+            )]))
+        })
+        .next()
+        .await;
+    save.await;
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one, two\nthree\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+}
+
+#[gpui::test]
+async fn test_modifications_format_skips_without_git_diff(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+    assert!(
+        cx.read(|cx| editor.is_dirty(cx)),
+        "editor should be dirty before save"
+    );
+
+    let _no_range_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("rangeFormatting must not be called when no git diff is available");
+        })
+        .next();
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("full formatting must not be called for Modifications without a git diff");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+    assert!(
+        !cx.read(|cx| editor.is_dirty(cx)),
+        "the buffer should be saved despite the skipped formatting"
+    );
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one\ntwo\nthree\n"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_lsp_no_range_support(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "",
+        }),
+    )
+    .await;
+
+    let head_content = "one\ntwo\nthree\n";
+    fs.set_head_and_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").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 {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(false)),
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\nTWO\nthree\n", window, cx)
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let _no_range_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("rangeFormatting must not be called when range formatting is unsupported");
+        })
+        .next();
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("full formatting must not be called for Modifications when range formatting is unsupported");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "one\nTWO\nthree\n"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_lsp_returns_empty_edits(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("aaa\nbbb\nccc\nddd\neee\n", window, cx)
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    fake_server
+        .set_request_handler::(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap()
+            );
+            Ok(Some(Vec::new()))
+        })
+        .next()
+        .await;
+    save.await;
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "aaa\nbbb\nccc\nddd\neee\n"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_multiple_hunks(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\nline5\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\nline5\n", window, cx);
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx =
+        fake_server.set_request_handler::(move |params, _| {
+            let count = request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move {
+                assert_eq!(
+                    params.text_document.uri,
+                    lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap()
+                );
+                match count {
+                    0 => Ok(Some(vec![lsp::TextEdit::new(
+                        lsp::Range::new(lsp::Position::new(1, 5), lsp::Position::new(1, 5)),
+                        "!".to_string(),
+                    )])),
+                    1 => Ok(Some(vec![lsp::TextEdit::new(
+                        lsp::Range::new(lsp::Position::new(4, 5), lsp::Position::new(4, 5)),
+                        "!".to_string(),
+                    )])),
+                    _ => panic!("unexpected third range formatting request"),
+                }
+            }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(request_count.load(atomic::Ordering::SeqCst), 2);
+    assert_eq!(
+        editor.update(cx, |editor, cx| editor.text(cx)),
+        "line0\nLINE1!\nline2\nline3\nLINE4!\nline5\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+}
+
+#[gpui::test]
+async fn test_modifications_format_excludes_staged_changes(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let staged_content = "line0\nLINE1\nline2\nline3\nline4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, staged_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n";
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(working_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx =
+        fake_server.set_request_handler::(move |params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            assert_eq!(
+                params.range.start.line, 4,
+                "only the unstaged hunk (LINE4) should be formatted"
+            );
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        1,
+        "staged hunk (LINE1) must not be formatted, only the unstaged hunk (LINE4)"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_range_excludes_staged_hunk(cx: &mut TestAppContext) {
+    let head_content = "a\nb\nc\n";
+    let staged_content = "A\nb\nc\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, staged_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("A\nB\nc\n", window, cx)
+    });
+    cx.run_until_parked();
+
+    let captured_start_line = Arc::new(AtomicUsize::new(usize::MAX));
+    let captured_start_line_clone = captured_start_line.clone();
+    let mut responded_rx =
+        fake_server.set_request_handler::(move |params, _| {
+            captured_start_line_clone
+                .store(params.range.start.line as usize, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    save.await;
+
+    let start_line = captured_start_line.load(atomic::Ordering::SeqCst);
+    assert_ne!(
+        start_line,
+        usize::MAX,
+        "expected at least one range formatting request"
+    );
+    assert_eq!(
+        start_line, 1,
+        "format range must exclude the staged row and start at the unstaged row"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_pure_unstaged_with_git(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n";
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(working_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx = fake_server.set_request_handler::(
+        move |_params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        2,
+        "unstaged hunks (LINE1, LINE4) must be formatted via the git diff path"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_no_unstaged_changes_with_git(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let staged_content = "line0\nLINE1\nline2\nline3\nLINE4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, staged_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(staged_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when all changes are staged");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn test_modifications_format_no_changes_with_git(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(head_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let _no_format_handler = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when buffer matches HEAD");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn test_modifications_format_crlf_line_endings(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "line0\r\nline1\r\nline2\r\nline3\r\nline4\r\n",
+        }),
+    )
+    .await;
+
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    fs.set_head_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").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 {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(
+            buffer.line_ending(),
+            language::LineEnding::Windows,
+            "buffer should detect CRLF line endings from the working tree file"
+        );
+    });
+
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        window.focus(&editor.focus_handle(cx), cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\n", window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx = fake_server.set_request_handler::(
+        move |_params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        2,
+        "CRLF line endings must not cause spurious diff hunks"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_format_merge_boundary_one_row_gap(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\nline3\nline4\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::Modifications);
+    });
+
+    let working_content = "line0\nLINE1\nline2\nLINE3\nline4\n";
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(working_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let request_count = Arc::new(AtomicUsize::new(0));
+    let request_count_clone = request_count.clone();
+    let mut responded_rx = fake_server.set_request_handler::(
+        move |_params, _| {
+            request_count_clone.fetch_add(1, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        },
+    );
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    responded_rx.next().await;
+    responded_rx.next().await;
+    save.await;
+
+    assert_eq!(
+        request_count.load(atomic::Ordering::SeqCst),
+        2,
+        "hunks separated by exactly one unchanged row must not merge"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_if_available_empty_diff_skips_formatting(cx: &mut TestAppContext) {
+    let head_content = "line0\nline1\nline2\n";
+    let (project, editor, cx, fake_server) =
+        setup_range_format_test_with_git(cx, head_content, head_content).await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text(head_content, window, cx)
+    });
+    cx.run_until_parked();
+
+    let _no_range_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when diff is empty");
+        })
+        .next();
+    let _no_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("full formatting must not be called when a diff is available but empty");
+        })
+        .next();
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    save.await;
+    cx.run_until_parked();
+    assert!(
+        !cx.read(|cx| editor.is_dirty(cx)),
+        "the buffer should be saved despite the skipped formatting"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_if_available_no_git_falls_back_to_full_format(cx: &mut TestAppContext) {
+    let (project, editor, cx, fake_server) = setup_range_format_test_with_capabilities(
+        cx,
+        lsp::ServerCapabilities {
+            document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+            document_formatting_provider: Some(lsp::OneOf::Left(true)),
+            ..lsp::ServerCapabilities::default()
+        },
+    )
+    .await;
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("one\ntwo\nthree\n", window, cx)
+    });
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let _no_range_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("range formatting must not be called when no git diff is available");
+        })
+        .next();
+
+    let formatting_called = Arc::new(AtomicBool::new(false));
+    let formatting_called_clone = formatting_called.clone();
+    let mut formatting_rx =
+        fake_server.set_request_handler::(move |_, _| {
+            formatting_called_clone.store(true, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    formatting_rx.next().await;
+    save.await;
+
+    assert!(
+        formatting_called.load(atomic::Ordering::SeqCst),
+        "ModificationsIfAvailable must fall back to full-buffer formatting when no git diff is available"
+    );
+}
+
+#[gpui::test]
+async fn test_modifications_if_available_lsp_no_range_support_falls_back_to_full_format(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".git": {},
+            "file.rs": "",
+        }),
+    )
+    .await;
+
+    let head_content = "line0\nline1\nline2\n";
+    fs.set_head_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+        "deadbeef",
+    );
+    fs.set_index_for_repo(
+        std::path::Path::new(path!("/project/.git")),
+        &[("file.rs", head_content.to_string())],
+    );
+
+    let project = Project::test(fs, [path!("/project").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 {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(false)),
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/project/file.rs"), cx)
+        })
+        .await
+        .unwrap();
+    project
+        .update(cx, |project, cx| {
+            project.open_unstaged_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        build_editor_with_project(project.clone(), buffer, window, cx)
+    });
+    editor.update_in(cx, |editor, window, cx| {
+        window.focus(&editor.focus_handle(cx), cx);
+    });
+
+    let fake_server = fake_servers.next().await.unwrap();
+
+    update_test_language_settings(cx, &|settings| {
+        settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable);
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_text("line0\nLINE1\nline2\n", window, cx);
+    });
+    cx.run_until_parked();
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let _no_range_format = fake_server
+        .set_request_handler::(move |_, _| async move {
+            panic!("rangeFormatting must not be called when LSP lacks range support");
+        })
+        .next();
+
+    let formatting_called = Arc::new(AtomicBool::new(false));
+    let formatting_called_clone = formatting_called.clone();
+    let mut formatting_rx =
+        fake_server.set_request_handler::(move |_, _| {
+            formatting_called_clone.store(true, atomic::Ordering::SeqCst);
+            async move { Ok(Some(Vec::new())) }
+        });
+
+    let save = editor
+        .update_in(cx, |editor, window, cx| {
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                    force_format: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    formatting_rx.next().await;
+    save.await;
+
+    assert!(
+        formatting_called.load(atomic::Ordering::SeqCst),
+        "ModificationsIfAvailable must fall back to full-file formatting when the LSP lacks range support"
+    );
+}
+
 #[gpui::test]
 async fn test_range_format_not_called_for_clean_buffer(cx: &mut TestAppContext) {
     let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index a82b3ea4488..fe06b33f431 100644
--- a/crates/editor/src/element.rs
+++ b/crates/editor/src/element.rs
@@ -287,22 +287,22 @@ impl EditorElement {
         register_action(editor, window, Editor::scroll_cursor_bottom);
         register_action(editor, window, Editor::scroll_cursor_center_top_bottom);
         register_action(editor, window, |editor, _: &LineDown, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Line(1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx)
         });
         register_action(editor, window, |editor, _: &LineUp, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Line(-1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx)
         });
         register_action(editor, window, |editor, _: &HalfPageDown, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(0.5), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx)
         });
         register_action(editor, window, |editor, _: &HalfPageUp, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(-0.5), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx)
         });
         register_action(editor, window, |editor, _: &PageDown, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(1.), window, cx)
         });
         register_action(editor, window, |editor, _: &PageUp, window, cx| {
-            editor.scroll_screen(&ScrollAmount::Page(-1.), window, cx)
+            editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-1.), window, cx)
         });
         register_action(editor, window, Editor::move_to_previous_word_start);
         register_action(editor, window, Editor::move_to_previous_subword_start);
@@ -312,6 +312,8 @@ impl EditorElement {
         register_action(editor, window, Editor::move_to_end_of_line);
         register_action(editor, window, Editor::move_to_start_of_paragraph);
         register_action(editor, window, Editor::move_to_end_of_paragraph);
+        register_action(editor, window, Editor::move_to_next_comment_paragraph);
+        register_action(editor, window, Editor::move_to_previous_comment_paragraph);
         register_action(editor, window, Editor::move_to_beginning);
         register_action(editor, window, Editor::move_to_end);
         register_action(editor, window, Editor::move_to_start_of_excerpt);
diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs
index d9871fa2cb5..ab5b035a05a 100644
--- a/crates/editor/src/git.rs
+++ b/crates/editor/src/git.rs
@@ -1050,11 +1050,7 @@ impl Editor {
         );
     }
 
-    pub(super) fn restore_diff_hunks(
-        &mut self,
-        hunks: Vec,
-        cx: &mut Context,
-    ) {
+    pub fn restore_diff_hunks(&mut self, hunks: Vec, cx: &mut Context) {
         let mut revert_changes = Vec::new();
         for hunks in hunks {
             let Some(buffer) = hunks.buffer else {
@@ -2110,6 +2106,17 @@ impl Editor {
         .detach_and_log_err(cx);
     }
 
+    pub fn restore_diff_hunks_in_ranges(
+        &mut self,
+        ranges: Vec>,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let hunks = self.diff_hunks_in_ranges(&ranges, &snapshot).collect();
+        self.apply_restore(hunks, window, cx);
+    }
+
     fn toggle_diff_hunks_in_ranges(
         &mut self,
         ranges: Vec>,
diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs
index 4e97c8c3fe3..4acdb10ff75 100644
--- a/crates/editor/src/items.rs
+++ b/crates/editor/src/items.rs
@@ -19,12 +19,14 @@ use gpui::{
 };
 use language::{
     Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, PLAIN_TEXT,
-    Point, SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
+    Point, SelectionGoal,
+    language_settings::{FormatOnSave, LanguageSettings},
+    proto::serialize_anchor as serialize_text_anchor,
 };
 use lsp::DiagnosticSeverity;
 use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey};
 use project::{
-    File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger,
+    File, Project, ProjectItem as _, ProjectPath, git_store::GitStore, lsp_store::FormatTrigger,
     project_settings::ProjectSettings, search::SearchQuery,
 };
 use rope::TextSummary;
@@ -39,7 +41,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection};
+use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _};
 use ui::{IconDecorationKind, prelude::*};
 use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath};
 use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
@@ -974,16 +976,21 @@ impl Item for Editor {
 
         cx.spawn_in(window, async move |this, cx| {
             if options.format {
-                this.update_in(cx, |editor, window, cx| {
-                    editor.perform_format(
-                        project.clone(),
+                let format_task = this.update_in(cx, |editor, window, cx| {
+                    let format_target = compute_format_target(
+                        &buffers_to_save,
                         format_trigger,
-                        FormatTarget::Buffers(buffers_to_save.clone()),
-                        window,
+                        editor.buffer(),
+                        project.read(cx).git_store(),
                         cx,
-                    )
-                })?
-                .await?;
+                    );
+                    format_target.map(|target| {
+                        editor.perform_format(project.clone(), format_trigger, target, window, cx)
+                    })
+                })?;
+                if let Some(format_task) = format_task {
+                    format_task.await?;
+                }
             }
 
             if !buffers_to_save.is_empty() {
@@ -2267,6 +2274,115 @@ fn chunk_search_range(
     }))
 }
 
+/// Decides what to format based on the `format_on_save` settings of the saved buffers.
+///
+/// In the modifications modes, only lines with unstaged changes are formatted.
+/// When no git diff is available for a buffer, `modifications` skips formatting while `modifications_if_available`
+/// falls back to formatting entire buffers.
+/// When a diff is available but empty, nothing is formatted in either mode.
+fn compute_format_target(
+    buffers: &HashSet>,
+    trigger: FormatTrigger,
+    multi_buffer: &Entity,
+    git_store: &Entity,
+    cx: &App,
+) -> Option {
+    if trigger == FormatTrigger::Manual {
+        return Some(FormatTarget::Buffers(buffers.clone()));
+    }
+
+    let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
+    let git_store = git_store.read(cx);
+
+    let mut fall_back_to_full_format = false;
+    let mut modified_ranges: Vec> = Vec::new();
+
+    for buffer_entity in buffers.iter() {
+        let buffer = buffer_entity.read(cx);
+        let settings = LanguageSettings::for_buffer(buffer, cx);
+        match settings.format_on_save {
+            FormatOnSave::On | FormatOnSave::Off => {
+                return Some(FormatTarget::Buffers(buffers.clone()));
+            }
+            FormatOnSave::Modifications | FormatOnSave::ModificationsIfAvailable => {}
+        }
+
+        let Some(diff_snapshot) = git_store
+            .get_unstaged_diff(buffer.remote_id(), cx)
+            .map(|diff| diff.read(cx).snapshot(cx))
+        else {
+            if settings.format_on_save == FormatOnSave::ModificationsIfAvailable {
+                fall_back_to_full_format = true;
+            }
+            continue;
+        };
+
+        let anchor_ranges = compute_modified_ranges(&buffer.snapshot(), &diff_snapshot);
+        let flat_anchors = anchor_ranges
+            .iter()
+            .flat_map(|range| [range.start, range.end])
+            .collect::>();
+        let multi_buffer_anchors =
+            multi_buffer_snapshot.text_anchors_to_visible_anchors(flat_anchors);
+        for pair in multi_buffer_anchors.chunks_exact(2) {
+            let (Some(start), Some(end)) = (&pair[0], &pair[1]) else {
+                continue;
+            };
+            modified_ranges
+                .push(start.to_point(&multi_buffer_snapshot)..end.to_point(&multi_buffer_snapshot));
+        }
+    }
+
+    if fall_back_to_full_format {
+        Some(FormatTarget::Buffers(buffers.clone()))
+    } else if modified_ranges.is_empty() {
+        None
+    } else {
+        Some(FormatTarget::Ranges(modified_ranges))
+    }
+}
+
+/// Computes the buffer ranges that have unstaged changes, expanded to full lines and
+/// with adjacent hunks merged, for use with format-on-save. An empty result means the
+/// buffer has no formatable modifications.
+fn compute_modified_ranges(
+    buffer_snapshot: &language::BufferSnapshot,
+    diff_snapshot: &buffer_diff::BufferDiffSnapshot,
+) -> Vec> {
+    let mut merged: Vec> = Vec::new();
+    for hunk in diff_snapshot.hunks(buffer_snapshot) {
+        let range = hunk.buffer_range;
+        if range.start.cmp(&range.end, buffer_snapshot).is_eq() {
+            // Deletion-only hunks produce no buffer content to format.
+            continue;
+        }
+        let start_point = range.start.to_point(buffer_snapshot);
+        let end_point = range.end.to_point(buffer_snapshot);
+        let start_row = start_point.row;
+        let end_row = if end_point.column == 0 && end_point.row > start_point.row {
+            end_point.row - 1
+        } else {
+            end_point.row
+        };
+        let line_start = text::Point::new(start_row, 0);
+        let line_end = text::Point::new(end_row, buffer_snapshot.line_len(end_row));
+        let expanded =
+            buffer_snapshot.anchor_before(line_start)..buffer_snapshot.anchor_after(line_end);
+
+        if let Some(last) = merged.last_mut() {
+            let last_end_point = last.end.to_point(buffer_snapshot);
+            if start_row <= last_end_point.row + 1 {
+                if expanded.end.to_point(buffer_snapshot) > last_end_point {
+                    last.end = expanded.end;
+                }
+                continue;
+            }
+        }
+        merged.push(expanded);
+    }
+    merged
+}
+
 #[cfg(test)]
 mod tests {
     use crate::editor_tests::init_test;
@@ -2921,4 +3037,124 @@ mod tests {
             "Editor::deserialize should not add items to panes as a side effect"
         );
     }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_git_diff(cx: &mut gpui::TestAppContext) {
+        let base_text = "line0\nline1\nline2\nline3\nline4\nline5\nline6\n";
+        // Modify line1 and line5 to create two non-adjacent hunks.
+        let buffer_text = "line0\nMOD1\nline2\nline3\nline4\nMOD5\nline6\n";
+
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(ranges.len(), 2, "expected 2 non-adjacent ranges");
+
+        buffer.update(cx, |buffer, _cx| {
+            let text_snapshot: &text::BufferSnapshot = buffer;
+            let r0 = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
+            let r1 = ranges[1].start.to_point(text_snapshot)..ranges[1].end.to_point(text_snapshot);
+            assert_eq!(r0.start.row, 1, "first hunk should start at row 1");
+            assert_eq!(r0.end.row, 1, "first hunk should end at row 1");
+            assert_eq!(r1.start.row, 5, "second hunk should start at row 5");
+            assert_eq!(r1.end.row, 5, "second hunk should end at row 5");
+        });
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_unchanged_buffer(cx: &mut gpui::TestAppContext) {
+        let buffer_text = "line0\nline1\nline2\n";
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(
+                    buffer_text,
+                    &buffer.text_snapshot(),
+                    cx,
+                )
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(
+            ranges,
+            Vec::new(),
+            "buffer that matches its diff base should produce no modified ranges"
+        );
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_deletion_only(cx: &mut gpui::TestAppContext) {
+        let base_text = "line0\nline1\nline2\n";
+        // Buffer has line1 deleted (pure deletion).
+        let buffer_text = "line0\nline2\n";
+
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        // Verify the diff has a deletion hunk.
+        let hunk_count = buffer.update(cx, |buffer, _cx| {
+            let text_snapshot: &text::BufferSnapshot = buffer;
+            diff_snapshot.hunks(text_snapshot).count()
+        });
+        assert!(hunk_count > 0, "diff should have hunks");
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(
+            ranges,
+            Vec::new(),
+            "deletion-only hunks should be skipped, leaving no ranges"
+        );
+    }
+
+    #[gpui::test]
+    fn test_compute_modified_ranges_adjacent_hunks(cx: &mut gpui::TestAppContext) {
+        let base_text = "line0\nline1\nline2\nline3\nline4\n";
+        // Modify lines 2 and 3 which are adjacent; they should merge into one range.
+        let buffer_text = "line0\nline1\nMOD2\nMOD3\nline4\n";
+
+        let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
+        let diff_snapshot = buffer.update(cx, |buffer, cx| {
+            let diff = cx.new(|cx| {
+                buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
+            });
+            diff.read(cx).snapshot(cx)
+        });
+
+        let ranges = buffer.update(cx, |buffer, _cx| {
+            compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
+        });
+
+        assert_eq!(
+            ranges.len(),
+            1,
+            "adjacent hunks (rows 2 and 3) should be merged into one range"
+        );
+        buffer.update(cx, |buffer, _cx| {
+            let text_snapshot: &text::BufferSnapshot = buffer;
+            let r = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
+            assert_eq!(r.start.row, 2, "merged range should start at row 2");
+            assert_eq!(r.end.row, 3, "merged range should end at row 3");
+        });
+    }
 }
diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs
index 5742c9d20ce..92b1a30cf22 100644
--- a/crates/editor/src/movement.rs
+++ b/crates/editor/src/movement.rs
@@ -582,6 +582,99 @@ pub fn end_of_paragraph(
     map.max_point()
 }
 
+/// Returns whether `row` is part of a comment paragraph: a line whose first
+/// non-whitespace character lies within a comment scope and which contains at
+/// least one alphanumeric character.
+///
+/// This intentionally excludes:
+/// - blank lines and code lines,
+/// - end-of-line comments preceded by code (the first non-whitespace character
+///   is then code, not a comment),
+/// - "blank"/divider comment lines such as a bare `//` or `// -----` (no
+///   alphanumeric content), which act as paragraph separators.
+fn is_comment_paragraph_line(snapshot: &MultiBufferSnapshot, row: u32) -> bool {
+    let buffer_row = MultiBufferRow(row);
+    if snapshot.is_line_blank(buffer_row) {
+        return false;
+    }
+    let indent_len = snapshot.indent_size_for_line(buffer_row).len;
+    let indent_end = Point::new(row, indent_len);
+    let in_comment = snapshot.language_scope_at(indent_end).is_some_and(|scope| {
+        matches!(
+            scope.override_name(),
+            Some("comment") | Some("comment.inclusive")
+        )
+    });
+    if !in_comment {
+        return false;
+    }
+    let line_end = Point::new(row, snapshot.line_len(buffer_row));
+    snapshot
+        .text_for_range(indent_end..line_end)
+        .flat_map(|chunk| chunk.chars())
+        .any(|c| c.is_alphanumeric())
+}
+
+/// Returns the position of the first non-whitespace character of the next or
+/// previous comment paragraph, relative to `from`.
+///
+/// A comment paragraph is a run of consecutive comment lines (see
+/// [`is_comment_paragraph_line`]); paragraphs are separated by blank lines, code
+/// lines, and blank/divider comment lines. If no such paragraph exists in the
+/// requested direction, `from` is returned unchanged.
+///
+/// Both directions always move to a *different* paragraph than the one the
+/// caret is in: when the caret is inside a comment paragraph, the entire
+/// current paragraph is skipped, so `Prev` lands on the previous paragraph's
+/// start rather than the current paragraph's own start.
+pub fn comment_paragraph(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    direction: Direction,
+) -> DisplayPoint {
+    let snapshot = map.buffer_snapshot();
+    let from_point = from.to_point(map);
+    let max_row = snapshot.max_row().0;
+
+    let is_paragraph_start = |row: u32| {
+        is_comment_paragraph_line(snapshot, row)
+            && (row == 0 || !is_comment_paragraph_line(snapshot, row - 1))
+    };
+    let paragraph_start_point =
+        |row: u32| Point::new(row, snapshot.indent_size_for_line(MultiBufferRow(row)).len);
+
+    let target = match direction {
+        Direction::Next => (from_point.row..=max_row).find_map(|row| {
+            let point = paragraph_start_point(row);
+            (point > from_point && is_paragraph_start(row)).then_some(point)
+        }),
+        Direction::Prev => {
+            // If the caret is within a comment paragraph, skip over the whole
+            // current paragraph so we land on the *previous* paragraph rather
+            // than stopping at the current paragraph's own start.
+            let mut boundary_row = from_point.row;
+            if is_comment_paragraph_line(snapshot, boundary_row) {
+                while boundary_row > 0 && is_comment_paragraph_line(snapshot, boundary_row - 1) {
+                    boundary_row -= 1;
+                }
+                (0..boundary_row)
+                    .rev()
+                    .find_map(|row| is_paragraph_start(row).then(|| paragraph_start_point(row)))
+            } else {
+                (0..=from_point.row).rev().find_map(|row| {
+                    let point = paragraph_start_point(row);
+                    (point < from_point && is_paragraph_start(row)).then_some(point)
+                })
+            }
+        }
+    };
+
+    match target {
+        Some(point) => map.clip_point(point.to_display_point(map), Bias::Right),
+        None => from,
+    }
+}
+
 pub fn start_of_excerpt(
     map: &DisplaySnapshot,
     display_point: DisplayPoint,
diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs
index 4f9880c438b..471f68b16b8 100644
--- a/crates/editor/src/navigation.rs
+++ b/crates/editor/src/navigation.rs
@@ -608,6 +608,68 @@ impl Editor {
         })
     }
 
+    pub fn move_to_next_comment_paragraph(
+        &mut self,
+        _: &MoveToNextCommentParagraph,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate();
+            return;
+        }
+        // Keep the destination paragraph near the top of the viewport so the
+        // whole paragraph below the caret stays visible after a jump.
+        self.change_selections(
+            SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
+            window,
+            cx,
+            |s| {
+                s.move_with(&mut |map, selection| {
+                    selection.collapse_to(
+                        movement::comment_paragraph(
+                            map,
+                            selection.head(),
+                            workspace::searchable::Direction::Next,
+                        ),
+                        SelectionGoal::None,
+                    )
+                });
+            },
+        )
+    }
+
+    pub fn move_to_previous_comment_paragraph(
+        &mut self,
+        _: &MoveToPreviousCommentParagraph,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate();
+            return;
+        }
+        // Keep the destination paragraph near the top of the viewport so the
+        // whole paragraph below the caret stays visible after a jump.
+        self.change_selections(
+            SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
+            window,
+            cx,
+            |s| {
+                s.move_with(&mut |map, selection| {
+                    selection.collapse_to(
+                        movement::comment_paragraph(
+                            map,
+                            selection.head(),
+                            workspace::searchable::Direction::Prev,
+                        ),
+                        SelectionGoal::None,
+                    )
+                });
+            },
+        )
+    }
+
     pub fn select_to_start_of_paragraph(
         &mut self,
         _: &SelectToStartOfParagraph,
diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs
index 956e4dd2e09..a0e1f585617 100644
--- a/crates/editor/src/runnables.rs
+++ b/crates/editor/src/runnables.rs
@@ -537,6 +537,7 @@ impl Editor {
             let quick_launch = match e {
                 ClickEvent::Keyboard(_) => true,
                 ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
+                ClickEvent::Touch(_) => true,
             };
 
             window.focus(&editor.focus_handle(cx), cx);
diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs
index ec7f9036c4a..dcd96c675c5 100644
--- a/crates/editor/src/scroll.rs
+++ b/crates/editor/src/scroll.rs
@@ -5,7 +5,7 @@ pub(crate) mod scroll_amount;
 use crate::editor_settings::ScrollBeyondLastLine;
 use crate::{
     Anchor, DisplayPoint, DisplayRow, Editor, EditorEvent, EditorMode, EditorSettings,
-    MultiBufferSnapshot, RowExt, SizingBehavior, ToPoint,
+    MultiBufferSnapshot, RowExt, SelectionEffects, SizingBehavior, ToPoint,
     display_map::{DisplaySnapshot, ToDisplayPoint},
     hover_popover::hide_hover,
     persistence::EditorDb,
@@ -945,6 +945,63 @@ impl Editor {
         self.set_scroll_position(new_position, window, cx);
     }
 
+    pub fn scroll_screen_with_cursor_margin(
+        &mut self,
+        amount: &ScrollAmount,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.scroll_screen(amount, window, cx);
+
+        let Some(visible_line_count) = self.visible_line_count() else {
+            return;
+        };
+        let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
+        let top = self
+            .scroll_manager
+            .scroll_top_display_point(&display_snapshot, cx);
+        let vertical_scroll_margin =
+            (self.vertical_scroll_margin() as u32).min(visible_line_count as u32 / 2);
+
+        let max_point = display_snapshot.max_point();
+        let min_row = if top.row().0 == 0 {
+            DisplayRow(0)
+        } else {
+            DisplayRow(top.row().0 + vertical_scroll_margin)
+        };
+        let max_row = if top.row().0 + visible_line_count as u32 >= max_point.row().0 {
+            max_point.row()
+        } else {
+            DisplayRow(
+                (top.row().0 + visible_line_count as u32)
+                    .saturating_sub(1 + vertical_scroll_margin),
+            )
+        };
+
+        self.change_selections(
+            SelectionEffects::no_scroll().nav_history(false),
+            window,
+            cx,
+            |s| {
+                s.move_with(&mut |map, selection| {
+                    let head = selection.head();
+                    let new_row = if head.row() < min_row {
+                        min_row
+                    } else if head.row() > max_row {
+                        max_row
+                    } else {
+                        head.row()
+                    };
+                    if new_row != head.row() {
+                        let new_head =
+                            map.clip_point(DisplayPoint::new(new_row, head.column()), Bias::Left);
+                        selection.collapse_to(new_head, selection.goal);
+                    }
+                })
+            },
+        );
+    }
+
     /// Returns an ordering. The newest selection is:
     ///     Ordering::Equal => on screen
     ///     Ordering::Less => above or to the left of the screen
diff --git a/crates/extension_api/wit/since_v0.8.0/settings.rs b/crates/extension_api/wit/since_v0.8.0/settings.rs
index 7c77dc79baf..0f8d2c52b2c 100644
--- a/crates/extension_api/wit/since_v0.8.0/settings.rs
+++ b/crates/extension_api/wit/since_v0.8.0/settings.rs
@@ -6,6 +6,8 @@ use std::{collections::HashMap, num::NonZeroU32};
 pub struct LanguageSettings {
     /// How many columns a tab should occupy.
     pub tab_size: NonZeroU32,
+    /// Whether to indent with hard tabs (true) or spaces (false).
+    pub hard_tabs: bool,
     /// The preferred line length (column at which to wrap).
     pub preferred_line_length: u32,
 }
diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs
index 15e5ff94062..25cd0c2d368 100644
--- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs
+++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs
@@ -968,6 +968,7 @@ impl ExtensionImports for WasmState {
                         );
                         Ok(serde_json::to_string(&settings::LanguageSettings {
                             tab_size: settings.tab_size,
+                            hard_tabs: settings.hard_tabs,
                             preferred_line_length: settings.preferred_line_length,
                         })?)
                     }
diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs
index 31096cc38bc..f9db079a491 100644
--- a/crates/feature_flags/src/flags.rs
+++ b/crates/feature_flags/src/flags.rs
@@ -118,14 +118,3 @@ impl FeatureFlag for AutoWatchFeatureFlag {
     type Value = PresenceFlag;
 }
 register_feature_flag!(AutoWatchFeatureFlag);
-
-/// Wraps agent-run terminal commands in an OS-level sandbox where supported
-/// (currently macOS Seatbelt only). When off, terminal commands run with the
-/// agent's full ambient permissions, as they always have.
-pub struct SandboxingFeatureFlag;
-
-impl FeatureFlag for SandboxingFeatureFlag {
-    const NAME: &'static str = "sandboxing";
-    type Value = PresenceFlag;
-}
-register_feature_flag!(SandboxingFeatureFlag);
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index ba23efb166b..00e91cb7998 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/crates/fs/src/fake_git_repo.rs
@@ -262,7 +262,18 @@ impl GitRepository for FakeGitRepository {
 
     fn show(&self, commit: String) -> BoxFuture<'_, Result> {
         self.with_state_async(false, move |state| {
-            let sha = state.refs.get(&commit).cloned().unwrap_or(commit);
+            let sha = match state.refs.get(&commit) {
+                Some(sha) => sha.clone(),
+                // Real git fails to show an unresolvable revision (e.g. HEAD on an
+                // unborn branch), so only fall back to treating the input as a sha.
+                None => {
+                    anyhow::ensure!(
+                        commit.parse::().is_ok(),
+                        "unable to resolve revision: {commit}"
+                    );
+                    commit
+                }
+            };
             Ok(CommitDetails {
                 sha: sha.into(),
                 message: "initial commit".into(),
diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs
index ec75be0180e..ec6e0554b9a 100644
--- a/crates/fs/src/fs.rs
+++ b/crates/fs/src/fs.rs
@@ -295,6 +295,7 @@ pub struct Metadata {
     pub len: u64,
     pub is_fifo: bool,
     pub is_executable: bool,
+    pub is_writable: bool,
 }
 
 /// Filesystem modification time. The purpose of this newtype is to discourage use of operations
@@ -1025,6 +1026,7 @@ impl Fs for RealFs {
             is_dir: metadata.file_type().is_dir(),
             is_fifo,
             is_executable,
+            is_writable: !metadata.permissions().readonly(),
         }))
     }
 
@@ -3085,6 +3087,7 @@ impl Fs for FakeFs {
                     is_symlink,
                     is_fifo: false,
                     is_executable: false,
+                    is_writable: true,
                 },
                 FakeFsEntry::Dir {
                     inode, mtime, len, ..
@@ -3096,6 +3099,7 @@ impl Fs for FakeFs {
                     is_symlink,
                     is_fifo: false,
                     is_executable: false,
+                    is_writable: true,
                 },
                 FakeFsEntry::Symlink { .. } => unreachable!(),
             }))
diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs
index 016d4ca20f3..99664ae7480 100644
--- a/crates/fs/src/fs_watcher.rs
+++ b/crates/fs/src/fs_watcher.rs
@@ -56,13 +56,22 @@ impl FsWatcher {
             log::trace!("path to watch is already watched: {path:?}");
             return Ok(());
         }
-        if let Some(registration) = register_existing_path(
-            path,
+        match register_existing_path(
+            path.clone(),
             case_insensitive,
             self.tx.clone(),
             self.pending_path_events.clone(),
         )? {
-            self.registrations.lock().insert(key, registration);
+            Some(registration) => {
+                self.registrations.lock().insert(key, registration);
+            }
+            None => {
+                // Registration was skipped (e.g. the native watch-limit cooldown
+                // is active). Retry in the background rather than silently leaving
+                // the path unwatched forever.
+                log::warn!("watch registration for {path:?} was skipped; retrying in background");
+                self.add_pending_path(path);
+            }
         }
         Ok(())
     }
diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs
index fe9947f23b5..4bb16a39515 100644
--- a/crates/git/src/git.rs
+++ b/crates/git/src/git.rs
@@ -21,6 +21,8 @@ pub const GITIGNORE: &str = ".gitignore";
 pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
 pub const LFS_DIR: &str = "lfs";
 pub const OBJECTS_DIR: &str = "objects";
+pub const REFS_DIR: &str = "refs";
+pub const REFTABLE_DIR: &str = "reftable";
 pub const HOOKS_DIR: &str = "hooks";
 pub const LOGS_DIR: &str = "logs";
 pub const LOGS_REF_STASH: &str = "logs/refs/stash";
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index 6c88ee4e688..7bea8283d70 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -18,6 +18,7 @@ use smallvec::SmallVec;
 use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
 use text::LineEnding;
 
+use std::borrow::Cow;
 use std::collections::HashSet;
 use std::ffi::{OsStr, OsString};
 use std::sync::atomic::AtomicBool;
@@ -757,20 +758,22 @@ pub enum LogSource {
 }
 
 impl LogSource {
-    fn get_args(&self) -> Result> {
+    fn get_args(&self) -> Vec> {
         match self {
-            LogSource::All => Ok(vec![
-                "--ignore-missing", // needed in case of unborn HEAD
-                "--branches",
-                "--remotes",
-                "--tags",
-                "HEAD",
-            ]),
-            LogSource::Branch(branch) => Ok(vec![branch.as_str()]),
-            LogSource::Sha(oid) => Ok(vec![
-                str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")?,
-            ]),
-            LogSource::Path(path) => Ok(vec!["--follow", "--", path.as_unix_str()]),
+            LogSource::All => vec![
+                Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD
+                Cow::Borrowed("--branches"),
+                Cow::Borrowed("--remotes"),
+                Cow::Borrowed("--tags"),
+                Cow::Borrowed("HEAD"),
+            ],
+            LogSource::Branch(branch) => vec![Cow::Borrowed(branch.as_str())],
+            LogSource::Sha(oid) => vec![Cow::Owned(oid.to_string())],
+            LogSource::Path(path) => vec![
+                Cow::Borrowed("--follow"),
+                Cow::Borrowed("--"),
+                Cow::Borrowed(path.as_unix_str()),
+            ],
         }
     }
 }
@@ -3111,8 +3114,9 @@ impl GitRepository for RealGitRepository {
         let git = self.git_binary();
 
         async move {
+            let log_source_args = log_source.get_args();
             let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()];
-            git_log_command.extend(log_source.get_args()?);
+            git_log_command.extend(log_source_args.iter().map(|arg| arg.as_ref()));
             let mut command = git.build_command(&git_log_command);
             command.stdout(Stdio::piped());
             command.stderr(Stdio::piped());
@@ -3182,6 +3186,7 @@ impl GitRepository for RealGitRepository {
         let git = self.git_binary();
 
         async move {
+            let log_source_args = log_source.get_args();
             let mut args = vec!["log", SEARCH_COMMIT_FORMAT];
             let hash_query = commit_hash_search_query(search_args.query.as_str())
                 .map(|query| query.to_ascii_lowercase());
@@ -3197,7 +3202,7 @@ impl GitRepository for RealGitRepository {
                 args.push(search_args.query.as_str());
             }
 
-            args.extend(log_source.get_args()?);
+            args.extend(log_source_args.iter().map(|arg| arg.as_ref()));
             let mut command = git.build_command(&args);
             command.stdout(Stdio::piped());
             command.stderr(Stdio::null());
@@ -3942,7 +3947,7 @@ mod tests {
 
     #[allow(clippy::disallowed_methods)]
     #[track_caller]
-    fn git_command(working_directory: &Path, arguments: I)
+    fn git_command_output(working_directory: &Path, arguments: I) -> String
     where
         I: IntoIterator,
         S: AsRef,
@@ -3963,6 +3968,19 @@ mod tests {
             "git command failed: {}",
             String::from_utf8_lossy(&output.stderr)
         );
+        String::from_utf8(output.stdout)
+            .expect("git command output was not valid UTF-8")
+            .trim()
+            .to_string()
+    }
+
+    #[track_caller]
+    fn git_command(working_directory: &Path, arguments: I)
+    where
+        I: IntoIterator,
+        S: AsRef,
+    {
+        git_command_output(working_directory, arguments);
     }
 
     fn git_init_repo(path: &Path) {
@@ -4481,6 +4499,42 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_initial_graph_data_accepts_sha_log_source(cx: &mut TestAppContext) {
+        disable_git_global_config();
+
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+
+        git_init_repo(repo_dir.path());
+        fs::write(repo_dir.path().join("file"), "initial").unwrap();
+        git_command(repo_dir.path(), ["add", "file"]);
+        git_command(repo_dir.path(), ["commit", "-m", "Initial commit"]);
+
+        let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"])
+            .parse()
+            .unwrap();
+
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        let (request_tx, request_rx) = async_channel::unbounded();
+
+        repo.initial_graph_data(LogSource::Sha(commit_sha), LogOrder::DateOrder, request_tx)
+            .await
+            .unwrap();
+
+        let graph_data = request_rx.recv().await.unwrap();
+        assert_eq!(graph_data.len(), 1);
+        assert_eq!(graph_data[0].sha, commit_sha);
+    }
+
     #[gpui::test]
     async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
         cx.executor().allow_parking();
diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs
index 96c05303719..7c548952d7b 100644
--- a/crates/git_ui/src/diff_multibuffer.rs
+++ b/crates/git_ui/src/diff_multibuffer.rs
@@ -365,6 +365,28 @@ impl DiffMultibuffer {
         }
     }
 
+    pub(crate) fn restore_selected_hunks(
+        &mut self,
+        move_to_next: bool,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let editor = self.editor.read(cx).rhs_editor().clone();
+        let ranges = self.hunk_action_ranges(cx);
+        editor.update(cx, |editor, cx| {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let hunks: Vec<_> = editor.diff_hunks_in_ranges(&ranges, &snapshot).collect();
+            if !hunks.is_empty() {
+                editor.apply_restore(hunks, window, cx);
+            }
+        });
+        if move_to_next {
+            editor
+                .focus_handle(cx)
+                .dispatch_action(&GoToHunk, window, cx);
+        }
+    }
+
     fn handle_editor_event(
         &mut self,
         editor: &Entity,
diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs
index 90efa57d228..c28b7644316 100644
--- a/crates/git_ui/src/git_graph.rs
+++ b/crates/git_ui/src/git_graph.rs
@@ -1210,32 +1210,32 @@ pub fn open_or_reuse_graph(
         graph.repo_id == repo_id && graph.log_source == log_source
     });
 
-    if let Some(existing) = existing {
-        if let Some(sha) = sha {
-            existing.update(cx, |graph, cx| {
+    let git_graph = if let Some(existing) = existing {
+        workspace.activate_item(&existing, true, true, window, cx);
+        existing
+    } else {
+        let workspace_handle = workspace.weak_handle();
+        let git_graph = cx.new(|cx| {
+            GitGraph::new(
+                repo_id,
+                git_store,
+                workspace_handle,
+                Some(log_source),
+                window,
+                cx,
+            )
+        });
+        workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
+        git_graph
+    };
+
+    if let Some(sha) = sha {
+        cx.defer(move |cx| {
+            git_graph.update(cx, |graph, cx| {
                 graph.select_commit_by_sha(sha.as_str(), cx);
             });
-        }
-        workspace.activate_item(&existing, true, true, window, cx);
-        return;
+        });
     }
-
-    let workspace_handle = workspace.weak_handle();
-    let git_graph = cx.new(|cx| {
-        let mut graph = GitGraph::new(
-            repo_id,
-            git_store,
-            workspace_handle,
-            Some(log_source),
-            window,
-            cx,
-        );
-        if let Some(sha) = sha {
-            graph.select_commit_by_sha(sha.as_str(), cx);
-        }
-        graph
-    });
-    workspace.add_item_to_active_pane(Box::new(git_graph), None, true, window, cx);
 }
 
 fn lane_center_x(bounds: Bounds, lane: f32) -> Pixels {
@@ -6911,6 +6911,106 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_open_at_commit_reuses_loaded_graph(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            Path::new("/project"),
+            json!({ ".git": {}, "file.txt": "content" }),
+        )
+        .await;
+
+        let first_sha = Oid::from_bytes(&[1; 20]).expect("valid commit SHA");
+        let second_sha = Oid::from_bytes(&[2; 20]).expect("valid commit SHA");
+        fs.set_graph_commits(
+            Path::new("/project/.git"),
+            vec![
+                Arc::new(InitialGraphCommitData {
+                    sha: second_sha,
+                    parents: smallvec![first_sha],
+                    ref_names: vec!["HEAD -> main".into()],
+                }),
+                Arc::new(InitialGraphCommitData {
+                    sha: first_sha,
+                    parents: smallvec![],
+                    ref_names: Vec::new(),
+                }),
+            ],
+        );
+        fs.set_commit_data(
+            Path::new("/project/.git"),
+            [first_sha, second_sha].map(|sha| {
+                (
+                    CommitData {
+                        sha,
+                        parents: smallvec![],
+                        author_name: "Author".into(),
+                        author_email: "author@example.com".into(),
+                        commit_timestamp: 1_700_000_000,
+                        subject: "Commit subject".into(),
+                        message: "Commit message".into(),
+                    },
+                    false,
+                )
+            }),
+        );
+
+        let project = Project::test(fs, [Path::new("/project")], cx).await;
+        cx.run_until_parked();
+
+        let repository = project.read_with(cx, |project, cx| {
+            project
+                .active_repository(cx)
+                .expect("should have a repository")
+        });
+        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
+        });
+        let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
+        let git_graph = cx.new_window_entity(|window, cx| {
+            GitGraph::new(
+                repository.read(cx).id,
+                project.read(cx).git_store().clone(),
+                workspace.downgrade(),
+                None,
+                window,
+                cx,
+            )
+        });
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
+        });
+        cx.run_until_parked();
+
+        git_graph.update(cx, |graph, cx| {
+            graph.select_commit_by_sha(first_sha, cx);
+        });
+        cx.run_until_parked();
+        git_graph.update(cx, |graph, cx| {
+            graph.select_commit_by_sha(second_sha, cx);
+        });
+        cx.run_until_parked();
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            open_or_reuse_graph(
+                workspace,
+                repository.read(cx).id,
+                project.read(cx).git_store().clone(),
+                LogSource::All,
+                Some(first_sha.to_string()),
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        git_graph.read_with(&*cx, |graph, _| {
+            assert_eq!(graph.selected_entry_idx, Some(1));
+        });
+    }
+
     #[gpui::test]
     async fn test_git_graph_navigation(cx: &mut TestAppContext) {
         init_test(cx);
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index 6c0375fcbfa..d5e6e65b132 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -82,8 +82,9 @@ use theme_settings::ThemeSettings;
 use time::OffsetDateTime;
 use ui::{
     ButtonLike, Checkbox, Chip, ContextMenu, ContextMenuEntry, Divider, ElevationIndex,
-    IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes,
-    Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
+    IconButtonShape, IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle,
+    ProjectEmptyState, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar,
+    prelude::*,
 };
 use util::paths::PathStyle;
 use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath};
@@ -136,6 +137,8 @@ actions!(
         SetGroupByNone,
         /// Groups entries by status.
         SetGroupByStatus,
+        /// Groups entries by staging state.
+        SetGroupByStaging,
         /// Toggles showing entries in tree vs flat view.
         ToggleTreeView,
         /// Expands the selected entry to show its children.
@@ -203,6 +206,11 @@ fn git_panel_context_menu(
             .context(focus_handle.clone())
             .action_disabled_when(!has_unstaged_changes, "Stage All", StageAll.boxed_clone())
             .action_disabled_when(!has_staged_changes, "Unstage All", UnstageAll.boxed_clone())
+            .action_disabled_when(
+                !has_tracked_changes,
+                "Restore All Changes",
+                RestoreTrackedFiles.boxed_clone(),
+            )
             .separator()
             .action_disabled_when(
                 !(has_new_changes || has_tracked_changes),
@@ -332,6 +340,23 @@ fn git_panel_view_options_menu(
                         }
                     })
             })
+            .item({
+                let view_options_menu_state = view_options_menu_state.clone();
+                ContextMenuEntry::new("Staging")
+                    .toggle(
+                        IconPosition::End,
+                        state.group_by == GitPanelGroupBy::Staging,
+                    )
+                    .handler(move |window, cx| {
+                        if state.group_by != GitPanelGroupBy::Staging {
+                            view_options_menu_state.set(GitPanelViewOptionsMenuState {
+                                group_by: GitPanelGroupBy::Staging,
+                                ..state
+                            });
+                            window.dispatch_action(Box::new(SetGroupByStaging), cx);
+                        }
+                    })
+            })
     })
 }
 
@@ -406,11 +431,38 @@ enum GitPanelTab {
     History,
 }
 
+#[derive(Debug, PartialEq, Eq, Clone)]
+enum CommitHistory {
+    Loading,
+    /// A non-empty list can still grow on later fetches.
+    /// An empty list means the repository has no commits.
+    Loaded(Rc<[CommitHistoryEntry]>),
+    Error(SharedString),
+}
+
+fn commit_history_from_response(
+    entries: Rc<[CommitHistoryEntry]>,
+    is_loading: bool,
+    error: Option,
+) -> CommitHistory {
+    if !entries.is_empty() {
+        CommitHistory::Loaded(entries)
+    } else if let Some(error) = error {
+        CommitHistory::Error(error)
+    } else if is_loading {
+        CommitHistory::Loading
+    } else {
+        CommitHistory::Loaded(Rc::from([]))
+    }
+}
+
 #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
 enum Section {
     Conflict,
     Tracked,
     New,
+    Staged,
+    Unstaged,
 }
 
 #[derive(Debug, PartialEq, Eq, Clone)]
@@ -418,6 +470,26 @@ struct GitHeaderEntry {
     header: Section,
 }
 
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+struct ProjectedChangeEntry {
+    section: Section,
+    index: usize,
+}
+
+#[derive(Clone, Copy)]
+struct StagingAction {
+    stage: bool,
+    icon: IconName,
+    label: &'static str,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum DiffTarget {
+    Uncommitted,
+    Staged,
+    Unstaged,
+}
+
 impl GitHeaderEntry {
     pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
         let this = &self.header;
@@ -428,6 +500,10 @@ impl GitHeaderEntry {
             }
             Section::Tracked => !status.is_created(),
             Section::New => status.is_created(),
+            Section::Staged => GitPanel::stage_status_for_entry(status_entry, repo).has_staged(),
+            Section::Unstaged => {
+                GitPanel::stage_status_for_entry(status_entry, repo).has_unstaged()
+            }
         }
     }
     pub fn title(&self) -> &'static str {
@@ -435,6 +511,8 @@ impl GitHeaderEntry {
             Section::Conflict => "Conflicts",
             Section::Tracked => "Tracked",
             Section::New => "Untracked",
+            Section::Staged => "Staged",
+            Section::Unstaged => "Unstaged",
         }
     }
 }
@@ -767,7 +845,7 @@ pub struct GitPanel {
     entries: Vec,
     view_mode: GitPanelViewMode,
     tree_expanded_dirs: HashMap,
-    entries_indices: HashMap,
+    projected_entries_by_path: HashMap>,
     single_staged_entry: Option,
     single_tracked_entry: Option,
     focus_handle: FocusHandle,
@@ -806,7 +884,7 @@ pub struct GitPanel {
     stash_entries: GitStash,
     active_tab: GitPanelTab,
     commit_history_scroll_handle: UniformListScrollHandle,
-    commit_history_entries: Option>,
+    commit_history: CommitHistory,
     focused_history_entry: Option,
     history_keyboard_nav: bool,
     _commit_message_buffer_subscription: Option,
@@ -824,7 +902,7 @@ struct BulkStaging {
     anchor: RepoPath,
 }
 
-#[derive(Clone)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 struct CommitHistoryEntry {
     sha: Oid,
     tag_names: Vec,
@@ -1071,7 +1149,7 @@ impl GitPanel {
                 entries: Vec::new(),
                 view_mode: GitPanelViewMode::from_settings(cx),
                 tree_expanded_dirs: HashMap::default(),
-                entries_indices: HashMap::default(),
+                projected_entries_by_path: HashMap::default(),
                 focus_handle: cx.focus_handle(),
                 fs,
                 new_count: 0,
@@ -1108,7 +1186,7 @@ impl GitPanel {
                 stash_entries: Default::default(),
                 active_tab: GitPanelTab::Changes,
                 commit_history_scroll_handle: UniformListScrollHandle::new(),
-                commit_history_entries: None,
+                commit_history: CommitHistory::Loading,
                 focused_history_entry: None,
                 history_keyboard_nav: false,
                 _commit_message_buffer_subscription: None,
@@ -1126,7 +1204,18 @@ impl GitPanel {
     }
 
     pub fn entry_by_path(&self, path: &RepoPath) -> Option {
-        self.entries_indices.get(path).copied()
+        self.projected_entries_by_path
+            .get(path)?
+            .first()
+            .map(|entry| entry.index)
+    }
+
+    fn entry_by_path_in_section(&self, path: &RepoPath, section: Section) -> Option {
+        self.projected_entries_by_path
+            .get(path)?
+            .iter()
+            .find(|entry| entry.section == section)
+            .map(|entry| entry.index)
     }
 
     pub fn select_entry_by_path(
@@ -1139,7 +1228,7 @@ impl GitPanel {
             return;
         };
 
-        let (repo_path, section) = {
+        let (repo_path, default_section) = {
             let repo = git_repo.read(cx);
             let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
                 return;
@@ -1149,7 +1238,15 @@ impl GitPanel {
                 .status_for_path(&repo_path)
                 .map(|status| status.status)
                 .map(|status| {
-                    if repo.had_conflict_on_last_merge_head_change(&repo_path) {
+                    if GitPanelSettings::get_global(cx).group_by == GitPanelGroupBy::Staging {
+                        if status.is_conflicted() {
+                            Section::Conflict
+                        } else if status.staging().has_staged() {
+                            Section::Staged
+                        } else {
+                            Section::Unstaged
+                        }
+                    } else if repo.had_conflict_on_last_merge_head_change(&repo_path) {
                         Section::Conflict
                     } else if status.is_created() {
                         Section::New
@@ -1160,6 +1257,15 @@ impl GitPanel {
 
             (repo_path, section)
         };
+        let selected_section = self.selected_entry.and_then(|index| {
+            let selected_entry = self.entries.get(index)?.status_entry()?;
+            if selected_entry.repo_path == repo_path {
+                self.section_for_entry_index(index)
+            } else {
+                None
+            }
+        });
+        let section = selected_section.or(default_section);
 
         let mut needs_rebuild = false;
         if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
@@ -1183,7 +1289,10 @@ impl GitPanel {
             self.update_visible_entries(window, cx);
         }
 
-        let Some(ix) = self.entry_by_path(&repo_path) else {
+        let Some(ix) = section
+            .and_then(|section| self.entry_by_path_in_section(&repo_path, section))
+            .or_else(|| self.entry_by_path(&repo_path))
+        else {
             return;
         };
 
@@ -1553,13 +1662,34 @@ impl GitPanel {
     fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context) {
         maybe!({
             let workspace = self.workspace.upgrade()?;
+            let selected_index = self.selected_entry?;
+            let entry = self.entries.get(selected_index)?.status_entry()?.clone();
+            let target =
+                Self::diff_target_for_section(self.section_for_entry_index(selected_index));
 
-            if let Some(project_diff) = workspace.read(cx).item_of_type::(cx) {
-                let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
-
-                project_diff.update(cx, |project_diff, cx| {
-                    project_diff.move_to_entry(entry.clone(), window, cx);
-                });
+            match target {
+                DiffTarget::Staged => {
+                    if let Some(staged_diff) = workspace.read(cx).item_of_type::(cx) {
+                        staged_diff.update(cx, |staged_diff, cx| {
+                            staged_diff.move_to_entry(entry, window, cx);
+                        });
+                    }
+                }
+                DiffTarget::Unstaged => {
+                    if let Some(unstaged_diff) = workspace.read(cx).item_of_type::(cx)
+                    {
+                        unstaged_diff.update(cx, |unstaged_diff, cx| {
+                            unstaged_diff.move_to_entry(entry, window, cx);
+                        });
+                    }
+                }
+                DiffTarget::Uncommitted => {
+                    if let Some(project_diff) = workspace.read(cx).item_of_type::(cx) {
+                        project_diff.update(cx, |project_diff, cx| {
+                            project_diff.move_to_entry(entry, window, cx);
+                        });
+                    }
+                }
             }
 
             Some(())
@@ -1625,6 +1755,14 @@ impl GitPanel {
         self.selected_entry.and_then(|i| self.entries.get(i))
     }
 
+    fn change_entries_by_path(&self) -> impl Iterator {
+        // A grouping can project one changed file into multiple list rows.
+        self.entries
+            .iter()
+            .filter_map(GitListEntry::status_entry)
+            .unique_by(|entry| entry.repo_path.clone())
+    }
+
     fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
         if self.active_tab == GitPanelTab::History {
             self.open_selected_history_commit(window, cx);
@@ -1639,11 +1777,15 @@ impl GitPanel {
             return;
         }
         maybe!({
-            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
+            let selected_index = self.selected_entry?;
+            let entry = self.entries.get(selected_index)?.status_entry()?;
             let workspace = self.workspace.upgrade()?;
             let git_repo = self.active_repository.as_ref()?;
+            let target =
+                Self::diff_target_for_section(self.section_for_entry_index(selected_index));
 
-            if let Some(project_diff) = workspace.read(cx).active_item_as::(cx)
+            if target == DiffTarget::Uncommitted
+                && let Some(project_diff) = workspace.read(cx).active_item_as::(cx)
                 && let Some(project_path) = project_diff.read(cx).active_project_path(cx)
                 && Some(&entry.repo_path)
                     == git_repo
@@ -1657,8 +1799,16 @@ impl GitPanel {
             };
 
             self.workspace
-                .update(cx, |workspace, cx| {
-                    ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
+                .update(cx, |workspace, cx| match target {
+                    DiffTarget::Uncommitted => {
+                        ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
+                    }
+                    DiffTarget::Staged => {
+                        StagedDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
+                    }
+                    DiffTarget::Unstaged => {
+                        UnstagedDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
+                    }
                 })
                 .ok();
             self.focus_handle.focus(window, cx);
@@ -1995,10 +2145,9 @@ impl GitPanel {
         cx: &mut Context,
     ) {
         let entries = self
-            .entries
-            .iter()
-            .filter_map(|entry| entry.status_entry().cloned())
+            .change_entries_by_path()
             .filter(|status_entry| !status_entry.status.is_created())
+            .cloned()
             .collect::>();
 
         match entries.len() {
@@ -2045,9 +2194,7 @@ impl GitPanel {
             return;
         };
         let to_delete = self
-            .entries
-            .iter()
-            .filter_map(|entry| entry.status_entry())
+            .change_entries_by_path()
             .filter(|status_entry| status_entry.status.is_created())
             .cloned()
             .collect::>();
@@ -2277,11 +2424,13 @@ impl GitPanel {
                     (stage, repo_paths)
                 }
                 GitListEntry::Header(section) => {
-                    let goal_staged_state = !self.header_state(section.header).selected();
+                    let goal_staged_state = match section.header {
+                        Section::Staged => false,
+                        Section::Unstaged => true,
+                        _ => !self.header_state(section.header).selected(),
+                    };
                     let entries = self
-                        .entries
-                        .iter()
-                        .filter_map(|entry| entry.status_entry())
+                        .change_entries_by_path()
                         .filter(|status_entry| {
                             section.contains(status_entry, &repo)
                                 && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool()
@@ -2293,9 +2442,15 @@ impl GitPanel {
                     (goal_staged_state, entries)
                 }
                 GitListEntry::Directory(entry) => {
-                    let goal_staged_state = match self.stage_status_for_directory(entry, repo) {
-                        StageStatus::Staged => StageStatus::Unstaged,
-                        StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged,
+                    let goal_staged_state = match entry.key.section {
+                        Section::Staged => StageStatus::Unstaged,
+                        Section::Unstaged => StageStatus::Staged,
+                        _ => match self.stage_status_for_directory(entry, repo) {
+                            StageStatus::Staged => StageStatus::Unstaged,
+                            StageStatus::Unstaged | StageStatus::PartiallyStaged => {
+                                StageStatus::Staged
+                            }
+                        },
                     };
                     let goal_stage = goal_staged_state == StageStatus::Staged;
 
@@ -2346,6 +2501,7 @@ impl GitPanel {
                             let repo_paths = entries
                                 .iter()
                                 .map(|entry| entry.repo_path.clone())
+                                .unique()
                                 .collect();
                             if stage {
                                 repo.stage_entries(repo_paths, cx)
@@ -2459,7 +2615,18 @@ impl GitPanel {
         window: &mut Window,
         cx: &mut Context,
     ) {
-        if let Some(selected_entry) = self.get_selected_entry().cloned() {
+        let Some(selected_index) = self.selected_entry else {
+            return;
+        };
+        let Some(selected_entry) = self.entries.get(selected_index).cloned() else {
+            return;
+        };
+
+        if let Some(action) = self.staging_action_for_entry_index(selected_index)
+            && let Some(entry) = selected_entry.status_entry()
+        {
+            self.change_file_stage(action.stage, vec![entry.clone()], cx);
+        } else {
             self.toggle_staged_for_entry(&selected_entry, window, cx);
         }
     }
@@ -2699,9 +2866,7 @@ impl GitPanel {
             cx.background_spawn(async move { commit_task.await? })
         } else {
             let changed_files = self
-                .entries
-                .iter()
-                .filter_map(|entry| entry.status_entry())
+                .change_entries_by_path()
                 .filter(|status_entry| !status_entry.status.is_created())
                 .map(|status_entry| status_entry.repo_path.clone())
                 .collect::>();
@@ -3945,6 +4110,24 @@ impl GitPanel {
         }
     }
 
+    fn set_group_by_staging(
+        &mut self,
+        _: &SetGroupByStaging,
+        _: &mut Window,
+        cx: &mut Context,
+    ) {
+        if let Some(workspace) = self.workspace.upgrade() {
+            let workspace = workspace.read(cx);
+            let fs = workspace.app_state().fs.clone();
+            cx.update_global::(|store, _cx| {
+                store.update_settings_file(fs, move |settings, _cx| {
+                    settings.git_panel.get_or_insert_default().group_by =
+                        Some(GitPanelGroupBy::Staging);
+                });
+            });
+        }
+    }
+
     fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) {
         let current_setting = GitPanelSettings::get_global(cx).tree_view;
         if let Some(workspace) = self.workspace.upgrade() {
@@ -4070,13 +4253,20 @@ impl GitPanel {
         let new_active_repository = self.project.read(cx).active_repository(cx);
         let active_repository_changed = self.active_repository.as_ref().map(Entity::entity_id)
             != new_active_repository.as_ref().map(Entity::entity_id);
-        if active_repository_changed && self.amend_pending {
-            // Leaving a repository with a pending amend: undo it so the amend
-            // state doesn't carry over to the newly active repository. The
-            // commit editor still holds the previous repository's buffer here
-            // (`reopen_commit_buffer` swaps it asynchronously below), so this
-            // restores the pre-amend draft into that repository's buffer.
-            self.set_amend_pending(false, cx);
+        if active_repository_changed {
+            if self.amend_pending {
+                // Leaving a repository with a pending amend: undo it so the amend
+                // state doesn't carry over to the newly active repository. The
+                // commit editor still holds the previous repository's buffer here
+                // (`reopen_commit_buffer` swaps it asynchronously below), so this
+                // restores the pre-amend draft into that repository's buffer.
+                self.set_amend_pending(false, cx);
+            }
+            self.git_access = None;
+            self._repo_subscriptions.clear();
+            if self.active_tab == GitPanelTab::History {
+                self.set_commit_history(CommitHistory::Loading, cx);
+            }
         }
         self.active_repository = new_active_repository;
         self.reopen_commit_buffer(window, cx);
@@ -4193,18 +4383,17 @@ impl GitPanel {
 
     fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context) {
         let path_style = self.project.read(cx).path_style(cx);
+        let selected_change = self.selected_entry.and_then(|index| {
+            let entry = self.entries.get(index)?.status_entry()?;
+            Some((entry.repo_path.clone(), self.section_for_entry_index(index)))
+        });
         let bulk_staging = self.bulk_staging.take();
         let last_staged_path_prev_index = bulk_staging
             .as_ref()
             .and_then(|op| self.entry_by_path(&op.anchor));
 
-        let active_repository = self.project.read(cx).active_repository(cx);
-        if active_repository != self.active_repository {
-            self.active_repository = active_repository;
-            self.git_access = None;
-        }
         self.entries.clear();
-        self.entries_indices.clear();
+        self.projected_entries_by_path.clear();
         self.single_staged_entry.take();
         self.single_tracked_entry.take();
         self.conflicted_count = 0;
@@ -4220,7 +4409,9 @@ impl GitPanel {
 
         let settings = GitPanelSettings::get_global(cx);
         let sort_by = settings.sort_by;
-        let group_by_status = settings.group_by == GitPanelGroupBy::Status;
+        let group_by = settings.group_by;
+        let group_by_file_status = group_by == GitPanelGroupBy::Status;
+        let group_by_staging_state = group_by == GitPanelGroupBy::Staging;
         let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_));
 
         if let Some(active_repo) = self.active_repository.as_ref() {
@@ -4253,6 +4444,9 @@ impl GitPanel {
         let mut changed_entries = Vec::new();
         let mut new_entries = Vec::new();
         let mut conflict_entries = Vec::new();
+        let mut staged_entries = Vec::new();
+        let mut unstaged_entries = Vec::new();
+        let mut tracked_entries = Vec::new();
         let mut single_staged_entry = None;
         let mut staged_count = 0;
         let mut seen_directories = HashSet::default();
@@ -4291,14 +4485,27 @@ impl GitPanel {
                 diff_stat: entry.diff_stat,
             };
 
+            if !is_conflict && !is_new {
+                tracked_entries.push(entry.clone());
+            }
+
             if staging.has_staged() {
                 staged_count += 1;
                 single_staged_entry = Some(entry.clone());
             }
 
-            if group_by_status && is_conflict {
+            if group_by_staging_state && entry.status.is_conflicted() {
                 conflict_entries.push(entry);
-            } else if group_by_status && is_new {
+            } else if group_by_staging_state {
+                if staging.has_staged() {
+                    staged_entries.push(entry.clone());
+                }
+                if staging.has_unstaged() {
+                    unstaged_entries.push(entry);
+                }
+            } else if group_by_file_status && is_conflict {
+                conflict_entries.push(entry);
+            } else if group_by_file_status && is_new {
                 new_entries.push(entry);
             } else {
                 changed_entries.push(entry);
@@ -4330,8 +4537,8 @@ impl GitPanel {
             }
         }
 
-        if conflict_entries.is_empty() && changed_entries.len() == 1 {
-            self.single_tracked_entry = changed_entries.first().cloned();
+        if tracked_entries.len() == 1 {
+            self.single_tracked_entry = tracked_entries.pop();
         }
 
         if !is_tree_view {
@@ -4348,11 +4555,14 @@ impl GitPanel {
             sort_entries(&mut conflict_entries);
             sort_entries(&mut changed_entries);
             sort_entries(&mut new_entries);
+            sort_entries(&mut staged_entries);
+            sort_entries(&mut unstaged_entries);
         }
 
         let mut push_entry =
             |this: &mut Self,
              entry: GitListEntry,
+             section: Section,
              is_visible: bool,
              logical_indices: Option<&mut Vec>| {
                 if let Some(estimate) =
@@ -4366,7 +4576,13 @@ impl GitPanel {
 
                 if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone())
                 {
-                    this.entries_indices.insert(repo_path, this.entries.len());
+                    this.projected_entries_by_path
+                        .entry(repo_path)
+                        .or_default()
+                        .push(ProjectedChangeEntry {
+                            section,
+                            index: this.entries.len(),
+                        });
                 }
 
                 if let (Some(indices), true) = (logical_indices, is_visible) {
@@ -4376,15 +4592,19 @@ impl GitPanel {
                 this.entries.push(entry);
             };
 
-        macro_rules! take_section_entries {
-            () => {
-                [
-                    (Section::Conflict, std::mem::take(&mut conflict_entries)),
-                    (Section::Tracked, std::mem::take(&mut changed_entries)),
-                    (Section::New, std::mem::take(&mut new_entries)),
-                ]
-            };
-        }
+        let section_entries = if group_by_staging_state {
+            vec![
+                (Section::Conflict, std::mem::take(&mut conflict_entries)),
+                (Section::Staged, std::mem::take(&mut staged_entries)),
+                (Section::Unstaged, std::mem::take(&mut unstaged_entries)),
+            ]
+        } else {
+            vec![
+                (Section::Conflict, std::mem::take(&mut conflict_entries)),
+                (Section::Tracked, std::mem::take(&mut changed_entries)),
+                (Section::New, std::mem::take(&mut new_entries)),
+            ]
+        };
 
         match &mut self.view_mode {
             GitPanelViewMode::Tree(tree_state) => {
@@ -4395,15 +4615,16 @@ impl GitPanel {
                 // because push_entry mutably borrows self
                 let mut tree_state = std::mem::take(tree_state);
 
-                for (section, entries) in take_section_entries!() {
+                for (section, entries) in section_entries {
                     if entries.is_empty() {
                         continue;
                     }
 
-                    if section != Section::Tracked || group_by_status {
+                    if section != Section::Tracked || group_by != GitPanelGroupBy::None {
                         push_entry(
                             self,
                             GitListEntry::Header(GitHeaderEntry { header: section }),
+                            section,
                             true,
                             Some(&mut tree_state.logical_indices),
                         );
@@ -4415,6 +4636,7 @@ impl GitPanel {
                         push_entry(
                             self,
                             entry,
+                            section,
                             is_visible,
                             Some(&mut tree_state.logical_indices),
                         );
@@ -4428,22 +4650,23 @@ impl GitPanel {
                 self.view_mode = GitPanelViewMode::Tree(tree_state);
             }
             GitPanelViewMode::Flat => {
-                for (section, entries) in take_section_entries!() {
+                for (section, entries) in section_entries {
                     if entries.is_empty() {
                         continue;
                     }
 
-                    if section != Section::Tracked || group_by_status {
+                    if section != Section::Tracked || group_by != GitPanelGroupBy::None {
                         push_entry(
                             self,
                             GitListEntry::Header(GitHeaderEntry { header: section }),
+                            section,
                             true,
                             None,
                         );
                     }
 
                     for entry in entries {
-                        push_entry(self, GitListEntry::Status(entry), true, None);
+                        push_entry(self, GitListEntry::Status(entry), section, true, None);
                     }
                 }
             }
@@ -4468,6 +4691,11 @@ impl GitPanel {
             self.bulk_staging = bulk_staging;
         }
 
+        if let Some((path, section)) = selected_change {
+            self.selected_entry = section
+                .and_then(|section| self.entry_by_path_in_section(&path, section))
+                .or_else(|| self.entry_by_path(&path));
+        }
         self.select_first_entry_if_none(window, cx);
         self.select_last_entry_if_out_of_bounds(window, cx);
 
@@ -4486,6 +4714,8 @@ impl GitPanel {
             Section::New => (self.new_staged_count, self.new_count),
             Section::Tracked => (self.tracked_staged_count, self.tracked_count),
             Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
+            Section::Staged => (self.entry_count, self.entry_count),
+            Section::Unstaged => (0, self.entry_count),
         };
         if staged_count == 0 {
             ToggleState::Unselected
@@ -4496,6 +4726,59 @@ impl GitPanel {
         }
     }
 
+    fn section_for_entry_index(&self, ix: usize) -> Option
{ + self.entries.get(..=ix)?.iter().rev().find_map(|entry| { + if let GitListEntry::Header(header) = entry { + Some(header.header) + } else { + None + } + }) + } + + fn staging_action_for_section(section: Section) -> Option { + match section { + Section::Staged => Some(StagingAction { + stage: false, + icon: IconName::Dash, + label: "Unstage", + }), + Section::Unstaged => Some(StagingAction { + stage: true, + icon: IconName::Plus, + label: "Stage", + }), + _ => None, + } + } + + fn staging_action_for_entry_index(&self, ix: usize) -> Option { + self.section_for_entry_index(ix) + .and_then(Self::staging_action_for_section) + } + + fn diff_target_for_section(section: Option
) -> DiffTarget { + match section { + Some(Section::Staged) => DiffTarget::Staged, + Some(Section::Unstaged) => DiffTarget::Unstaged, + _ => DiffTarget::Uncommitted, + } + } + + fn staging_action_button( + id: ElementId, + icon: IconName, + action: &'static str, + disabled: bool, + ) -> IconButton { + IconButton::new(id, icon) + .disabled(disabled) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .style(ButtonStyle::Subtle) + .aria_label(action) + } + fn update_counts(&mut self, repo: &Repository) { self.show_placeholders = false; self.conflicted_count = 0; @@ -4507,7 +4790,8 @@ impl GitPanel { self.entry_count = 0; self.diff_stat_total = DiffStat::default(); - for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) { + let change_entries = self.change_entries_by_path().cloned().collect::>(); + for status_entry in change_entries { self.entry_count += 1; if let Some(diff_stat) = status_entry.diff_stat { self.diff_stat_total.added = @@ -4518,23 +4802,21 @@ impl GitPanel { .saturating_add(diff_stat.deleted); } - let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo) - .as_bool() - .unwrap_or(true); + let stage_status = GitPanel::stage_status_for_entry(&status_entry, repo); if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) { self.conflicted_count += 1; - if is_staging_or_staged { + if stage_status.has_staged() { self.conflicted_staged_count += 1; } } else if status_entry.status.is_created() { self.new_count += 1; - if is_staging_or_staged { + if stage_status.has_staged() { self.new_staged_count += 1; } } else { self.tracked_count += 1; - if is_staging_or_staged { + if stage_status.has_staged() { self.tracked_staged_count += 1; } } @@ -4548,9 +4830,12 @@ impl GitPanel { } pub(crate) fn has_unstaged_changes(&self) -> bool { - self.tracked_count > self.tracked_staged_count - || self.new_count > self.new_staged_count - || self.conflicted_count > self.conflicted_staged_count + self.change_entries_by_path() + .any(|entry| entry.staging.has_unstaged()) + } + + fn primary_changes_action_stages(&self) -> bool { + self.entry_count == 0 || self.has_unstaged_changes() } fn has_tracked_changes(&self) -> bool { @@ -4558,7 +4843,8 @@ impl GitPanel { } pub fn has_unstaged_conflicts(&self) -> bool { - self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count + self.change_entries_by_path() + .any(|entry| entry.status.is_conflicted() && entry.staging.has_unstaged()) } fn show_error_toast(&self, action: impl Into, e: anyhow::Error, cx: &mut App) { @@ -5110,12 +5396,11 @@ impl GitPanel { } fn render_git_changes_actions_button(&self, cx: &mut Context) -> impl IntoElement { - let (text, action, stage, tooltip) = - if self.total_staged_count() == self.entry_count && self.entry_count > 0 { - ("Unstage All", UnstageAll.boxed_clone(), false, "git reset") - } else { - ("Stage All", StageAll.boxed_clone(), true, "git add --all") - }; + let (text, action, stage, tooltip) = if self.primary_changes_action_stages() { + ("Stage All", StageAll.boxed_clone(), true, "git add --all") + } else { + ("Unstage All", UnstageAll.boxed_clone(), false, "git reset") + }; SplitButton::new( ButtonLike::new_rounded_left("git-changes-actions-split-button-left") @@ -5705,44 +5990,43 @@ impl GitPanel { fn render_history_tab(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex().flex_1().size_full().overflow_hidden().map(|this| { let has_repo = self.active_repository.is_some(); - let has_commits = self - .commit_history_entries - .as_ref() - .map_or(false, |entries| !entries.is_empty()); - let is_loading = self.commit_history_entries.is_none() && has_repo; - if is_loading { - this.child( - h_flex() - .flex_1() - .justify_center() - .child(Label::new("Loading Commit History…").color(Color::Muted)), - ) - } else if !has_repo || !has_commits { - this.child( - h_flex() - .flex_1() - .justify_center() - .child(Label::new("No commits yet").color(Color::Muted)), - ) - } else { - match self.render_commit_history(window, cx) { - Some(history) => this.child(history), - None => this.child( - h_flex() - .flex_1() - .justify_center() - .child(Label::new("Failed to load commits").color(Color::Muted)), - ), + match &self.commit_history { + _ if !has_repo => { + this.child(Self::render_history_placeholder("No repository found")) } + CommitHistory::Error(_) => this.child(Self::render_history_placeholder( + "Failed to load commit history", + )), + CommitHistory::Loading => { + this.child(Self::render_history_placeholder("Loading Commit History…")) + } + CommitHistory::Loaded(entries) if entries.is_empty() => { + this.child(Self::render_history_placeholder("No commits yet")) + } + CommitHistory::Loaded(_) => match self.render_commit_history(window, cx) { + Some(history) => this.child(history), + None => this.child(Self::render_history_placeholder("Failed to load commits")), + }, } }) } + fn render_history_placeholder(message: &'static str) -> impl IntoElement { + h_flex() + .flex_1() + .justify_center() + .child(Label::new(message).color(Color::Muted)) + } + + fn commit_history_entries(&self) -> &[CommitHistoryEntry] { + match &self.commit_history { + CommitHistory::Loaded(entries) => entries, + CommitHistory::Loading | CommitHistory::Error(_) => &[], + } + } + fn select_next_history_entry(&mut self, cx: &mut Context) { - let count = self - .commit_history_entries - .as_ref() - .map_or(0, |entries| entries.len()); + let count = self.commit_history_entries().len(); if count == 0 { return; } @@ -5758,10 +6042,7 @@ impl GitPanel { } fn select_previous_history_entry(&mut self, cx: &mut Context) { - let count = self - .commit_history_entries - .as_ref() - .map_or(0, |entries| entries.len()); + let count = self.commit_history_entries().len(); if count == 0 { return; } @@ -5780,11 +6061,7 @@ impl GitPanel { let Some(index) = self.focused_history_entry else { return; }; - let Some(entry) = self - .commit_history_entries - .as_ref() - .and_then(|entries| entries.get(index)) - else { + let Some(entry) = self.commit_history_entries().get(index) else { return; }; let Some(active_repository) = self.active_repository.as_ref() else { @@ -5828,12 +6105,10 @@ impl GitPanel { GitPanelTab::History => { self.focus_handle.focus(window, cx); self.load_commit_history(cx); - self.focused_history_entry = Some(0); } GitPanelTab::Changes => { self.focus_handle.focus(window, cx); - self.commit_history_entries.take(); - self.focused_history_entry = None; + self.set_commit_history(CommitHistory::Loading, cx); self._repo_subscriptions.clear(); } } @@ -5845,12 +6120,9 @@ impl GitPanel { return; }; - let Some(branch) = active_repository.read(cx).branch.as_ref() else { + let Some(log_source) = Self::commit_history_log_source(active_repository, cx) else { return; }; - - let branch_name = branch.name().to_string(); - let log_source = LogSource::Branch(branch_name.into()); let log_order = LogOrder::DateOrder; // Kick off the git log fetch so data is ready when the user switches to History. @@ -5861,13 +6133,13 @@ impl GitPanel { } fn load_commit_history(&mut self, cx: &mut Context) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.clone() else { return; }; if self._repo_subscriptions.is_empty() { self._repo_subscriptions.push(cx.subscribe( - active_repository, + &active_repository, |this, _repo, event, cx| { if let RepositoryEvent::GraphEvent(_, _) = event { if this.active_tab == GitPanelTab::History { @@ -5877,7 +6149,7 @@ impl GitPanel { }, )); self._repo_subscriptions - .push(cx.observe(active_repository, |_this, _repo, cx| { + .push(cx.observe(&active_repository, |_this, _repo, cx| { cx.notify(); })); } @@ -5886,26 +6158,53 @@ impl GitPanel { } fn fetch_commit_history_entries(&mut self, cx: &mut Context) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.clone() else { return; }; - let Some(branch) = active_repository.read(cx).branch.as_ref() else { + let Some(log_source) = Self::commit_history_log_source(&active_repository, cx) else { + // No HEAD commit at all (unborn/empty repository). + self.set_commit_history(CommitHistory::Loaded(Rc::from([])), cx); return; }; - - let branch_name = branch.name().to_string(); - let log_source = LogSource::Branch(branch_name.into()); let log_order = LogOrder::DateOrder; - self.commit_history_entries = Some(active_repository.update(cx, |repository, cx| { + let (entries, is_loading, error) = active_repository.update(cx, |repository, cx| { let response = repository.graph_data(log_source, log_order, 0..usize::MAX, cx); - response + let entries: Rc<[CommitHistoryEntry]> = response .commits .iter() .map(CommitHistoryEntry::from) - .collect() - })); + .collect(); + (entries, response.is_loading, response.error) + }); + + self.set_commit_history(commit_history_from_response(entries, is_loading, error), cx); + } + + fn set_commit_history(&mut self, commit_history: CommitHistory, cx: &mut Context) { + let changed = self.commit_history != commit_history; + self.commit_history = commit_history; + // Keep the focused entry within range as the history grows or clears. + let count = self.commit_history_entries().len(); + let focused = self.focused_history_entry.unwrap_or(0); + self.focused_history_entry = (count > 0).then(|| focused.min(count - 1)); + if changed { + cx.notify(); + } + } + + fn commit_history_log_source( + active_repository: &Entity, + cx: &App, + ) -> Option { + let repository = active_repository.read(cx); + let head_commit = repository.head_commit.as_ref()?; + if let Some(branch) = repository.branch.as_ref() { + Some(LogSource::Branch(branch.name().to_string().into())) + } else { + Some(LogSource::Sha(head_commit.sha.as_ref().parse().ok()?)) + } } fn git_remote(&self, cx: &mut App) -> Option { @@ -5925,7 +6224,10 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> Option { - let entries = self.commit_history_entries.clone()?; + let CommitHistory::Loaded(entries) = &self.commit_history else { + return None; + }; + let entries = entries.clone(); let active_repository = self.active_repository.as_ref()?; let workspace = self.workspace.clone(); let repo_weak = active_repository.downgrade(); @@ -6518,10 +6820,14 @@ impl GitPanel { let toggle_state = self.header_state(header.header); let section = header.header; let weak = cx.weak_entity(); + let staging_action = Self::staging_action_for_section(section); + let staging_conflict = GitPanelSettings::get_global(cx).group_by + == GitPanelGroupBy::Staging + && section == Section::Conflict; h_flex() .id(id) - .cursor_pointer() + .when(!staging_conflict, |this| this.cursor_pointer()) .group(group_name) .h(self.list_item_height()) .w_full() @@ -6537,14 +6843,26 @@ impl GitPanel { .color(Color::Muted) .size(LabelSize::Small), ) - .child( + .child(if staging_conflict { + div().into_any_element() + } else if let Some(action) = staging_action { + Self::staging_action_button( + checkbox_id, + action.icon, + action.label, + !has_write_access, + ) + .tooltip(move |_window, cx| Tooltip::simple(format!("{} all", action.label), cx)) + .into_any_element() + } else { Checkbox::new(checkbox_id, toggle_state) .disabled(!has_write_access) .fill() - .elevation(ElevationIndex::Surface), - ) + .elevation(ElevationIndex::Surface) + .into_any_element() + }) .on_click(move |_, window, cx| { - if !has_write_access { + if !has_write_access || staging_conflict { return; } @@ -6582,13 +6900,15 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { + let staging_action = self.staging_action_for_entry_index(ix); let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else { return; }; - let stage_title = if entry.status.staging().is_fully_staged() { - "Unstage File" - } else { - "Stage File" + let stage_title = match staging_action { + Some(StagingAction { stage: true, .. }) => "Stage File", + Some(StagingAction { stage: false, .. }) => "Unstage File", + None if entry.status.staging().is_fully_staged() => "Unstage File", + None => "Stage File", }; let restore_title = if entry.status.is_created() { "Trash File" @@ -6739,6 +7059,11 @@ impl GitPanel { ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into()); let stage_status = GitPanel::stage_status_for_entry(entry, &repo); + let section = self.section_for_entry_index(ix); + let staging_conflict = settings.group_by == GitPanelGroupBy::Staging + && section == Some(Section::Conflict) + && status.is_conflicted(); + let staging_action = self.staging_action_for_entry_index(ix); let mut is_staged: ToggleState = match stage_status { StageStatus::Staged => ToggleState::Selected, StageStatus::Unstaged => ToggleState::Unselected, @@ -6851,7 +7176,53 @@ impl GitPanel { .flex_none() .occlude() .cursor_pointer() - .child( + .child(if staging_conflict { + Self::staging_action_button( + checkbox_id, + IconName::Check, + "Mark as Resolved", + !has_write_access, + ) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, _window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + this.change_file_stage(true, vec![entry.clone()], cx); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| Tooltip::simple("Mark as Resolved", cx)) + .into_any_element() + } else if let Some(action) = staging_action { + Self::staging_action_button( + checkbox_id, + action.icon, + action.label, + !has_write_access, + ) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, _window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + this.change_file_stage(action.stage, vec![entry.clone()], cx); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| Tooltip::simple(action.label, cx)) + .into_any_element() + } else { Checkbox::new(checkbox_id, is_staged) .disabled(!has_write_access) .fill() @@ -6891,8 +7262,9 @@ impl GitPanel { let tooltip_name = action.to_string(); Tooltip::for_action(tooltip_name, &ToggleStaged, cx) - }), - ), + }) + .into_any_element() + }), ) .on_click({ cx.listener(move |this, event: &ClickEvent, window, cx| { @@ -6993,6 +7365,13 @@ impl GitPanel { StageStatus::Unstaged => ToggleState::Unselected, StageStatus::PartiallyStaged => ToggleState::Indeterminate, }; + let staging_action = if settings.group_by == GitPanelGroupBy::Staging { + Self::staging_action_for_section(entry.key.section) + } else { + None + }; + let staging_conflict = + settings.group_by == GitPanelGroupBy::Staging && entry.key.section == Section::Conflict; let name_row = h_flex() .min_w_0() @@ -7037,7 +7416,35 @@ impl GitPanel { .flex_none() .occlude() .cursor_pointer() - .child( + .child(if staging_conflict { + div().into_any_element() + } else if let Some(action) = staging_action { + Self::staging_action_button( + checkbox_id, + action.icon, + action.label, + !has_write_access, + ) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + let list_entry = GitListEntry::Directory(entry.clone()); + this.toggle_staged_for_entry(&list_entry, window, cx); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| { + Tooltip::simple(format!("{} folder", action.label), cx) + }) + .into_any_element() + } else { Checkbox::new(checkbox_id, toggle_state) .disabled(!has_write_access) .fill() @@ -7066,8 +7473,9 @@ impl GitPanel { StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", }; Tooltip::simple(format!("{action} folder"), cx) - }), - ), + }) + .into_any_element() + }), ) .on_click({ let key = entry.key.clone(); @@ -7401,6 +7809,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::set_sort_by_name)) .on_action(cx.listener(Self::set_group_by_none)) .on_action(cx.listener(Self::set_group_by_status)) + .on_action(cx.listener(Self::set_group_by_staging)) .on_action(cx.listener(Self::toggle_tree_view)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) @@ -8164,7 +8573,7 @@ pub(crate) fn commit_title_exceeds_limit(title: &str, max_length: usize) -> bool mod tests { use git::{ repository::repo_path, - status::{StatusCode, UnmergedStatus, UnmergedStatusCode}, + status::{StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}, }; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, px}; use indoc::indoc; @@ -8515,6 +8924,47 @@ mod tests { assert_editor_opened_with_path(&workspace, Path::new("src/a/foo.rs"), &mut cx); } + async fn history_panel_for_project( + fs: Arc, + cx: &mut TestAppContext, + ) -> Entity { + let project = Project::test(fs, [Path::new(path!("/root/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + cx.executor().run_until_parked(); + + let panel = workspace.update_in(cx, GitPanel::new); + panel.update_in(cx, |panel, window, cx| { + panel.activate_history_tab(&ActivateHistoryTab, window, cx); + }); + cx.run_until_parked(); + panel + } + + async fn wait_for_commit_history_to_settle(panel: &Entity, cx: &mut TestAppContext) { + cx.condition(panel, |panel, _| { + !matches!(panel.commit_history, CommitHistory::Loading) + }) + .await; + } + #[test] fn test_format_git_error_toast_message_prefers_raw_rpc_message() { let rpc_error = RpcError::from_proto( @@ -8556,6 +9006,154 @@ mod tests { ); } + #[gpui::test] + async fn test_history_tab_stops_loading_for_unborn_branch(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "project": { ".git": {} } })) + .await; + + let dot_git = Path::new(path!("/root/project/.git")); + fs.set_branch_name(dot_git, Some("main")); + fs.with_git_state(dot_git, false, |state| { + state.refs.remove("HEAD"); + }) + .unwrap(); + + let panel = history_panel_for_project(fs.clone(), cx).await; + + wait_for_commit_history_to_settle(&panel, cx).await; + panel.read_with(cx, |panel, _| { + assert_eq!(panel.commit_history, CommitHistory::Loaded(Rc::from([]))); + }); + } + + #[gpui::test] + async fn test_history_tab_loads_detached_head(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "project": { ".git": {} } })) + .await; + + let dot_git = Path::new(path!("/root/project/.git")); + let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap(); + fs.with_git_state(dot_git, false, |state| { + state.current_branch_name = None; + state.refs.insert("HEAD".into(), sha.to_string()); + state.graph_commits = vec![Arc::new(git::repository::InitialGraphCommitData { + sha, + parents: SmallVec::new(), + ref_names: Vec::new(), + })]; + }) + .unwrap(); + + let panel = history_panel_for_project(fs.clone(), cx).await; + + wait_for_commit_history_to_settle(&panel, cx).await; + panel.read_with(cx, |panel, _| { + assert_eq!( + panel.commit_history, + CommitHistory::Loaded(Rc::from([CommitHistoryEntry { + sha, + tag_names: Vec::new(), + }])) + ); + }); + } + + #[gpui::test] + async fn test_history_tab_surfaces_load_error(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "project": { ".git": {} } })) + .await; + + let dot_git = Path::new(path!("/root/project/.git")); + let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap(); + fs.with_git_state(dot_git, false, |state| { + state.current_branch_name = None; + state.refs.insert("HEAD".into(), sha.to_string()); + state.graph_commits = vec![Arc::new(git::repository::InitialGraphCommitData { + sha, + parents: SmallVec::new(), + ref_names: Vec::new(), + })]; + }) + .unwrap(); + fs.set_graph_error(dot_git, Some("simulated git log failure".into())); + + let panel = history_panel_for_project(fs.clone(), cx).await; + + wait_for_commit_history_to_settle(&panel, cx).await; + panel.read_with(cx, |panel, _| { + assert!(matches!(panel.commit_history, CommitHistory::Error(_))); + }); + } + + #[gpui::test] + async fn test_history_tab_without_repository(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree("/root", json!({ "project": {} })).await; + + let panel = history_panel_for_project(fs.clone(), cx).await; + + panel.read_with(cx, |panel, _| { + assert_eq!(panel.commit_history, CommitHistory::Loading); + }); + } + + #[test] + fn test_commit_history_from_response() { + let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap(); + let error = SharedString::from("git log failed"); + let entries: Rc<[CommitHistoryEntry]> = Rc::from([CommitHistoryEntry { + sha, + tag_names: Vec::new(), + }]); + let no_entries: Rc<[CommitHistoryEntry]> = Rc::from([]); + + // Commits win even while the fetch task still reports `is_loading`. + assert_eq!( + commit_history_from_response(entries.clone(), true, None), + CommitHistory::Loaded(entries.clone()) + ); + assert_eq!( + commit_history_from_response(entries.clone(), false, None), + CommitHistory::Loaded(entries.clone()) + ); + // Commits also take precedence over a concurrently reported error. + assert_eq!( + commit_history_from_response(entries.clone(), true, Some(error.clone())), + CommitHistory::Loaded(entries) + ); + + // With no commits a terminal error beats the loading state. + assert_eq!( + commit_history_from_response(no_entries.clone(), true, Some(error.clone())), + CommitHistory::Error(error.clone()) + ); + assert_eq!( + commit_history_from_response(no_entries.clone(), false, Some(error.clone())), + CommitHistory::Error(error) + ); + + // When no commits and no error, loading vs. finished-empty hinges on `is_loading`. + assert_eq!( + commit_history_from_response(no_entries.clone(), true, None), + CommitHistory::Loading + ); + assert_eq!( + commit_history_from_response(no_entries.clone(), false, None), + CommitHistory::Loaded(no_entries) + ); + } + #[gpui::test] async fn test_entry_worktree_paths(cx: &mut TestAppContext) { init_test(cx); @@ -8746,6 +9344,553 @@ mod tests { ); } + #[gpui::test] + async fn test_group_by_staging_section_membership_and_order(cx: &mut TestAppContext) { + use GitListEntry::*; + + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "conflict.rs": "conflicted content", + "new.rs": "new content", + "partial.rs": "partial content", + "partial_new.rs": "partial new content", + "staged.rs": "staged content", + "unstaged.rs": "unstaged content", + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "conflict.rs", + UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + } + .into(), + ), + ("new.rs", FileStatus::Untracked), + ( + "partial.rs", + TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ( + "partial_new.rs", + TrackedStatus { + index_status: StatusCode::Added, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + let entries = panel.read_with(&mut cx, |panel, _| { + assert_eq!(panel.entry_count, 6); + assert_eq!( + panel + .change_entries_by_path() + .filter(|entry| entry.status.is_created()) + .map(|entry| &*entry.repo_path) + .sorted() + .collect::>(), + [rel_path("new.rs"), rel_path("partial_new.rs")] + ); + + let partial_path = repo_path("partial.rs"); + let projections = panel + .projected_entries_by_path + .get(&partial_path) + .expect("partially staged entry should have projections"); + assert_eq!( + projections.as_slice(), + &[ + ProjectedChangeEntry { + section: Section::Staged, + index: 3, + }, + ProjectedChangeEntry { + section: Section::Unstaged, + index: 8, + }, + ] + ); + assert_eq!( + panel + .staging_action_for_entry_index(projections[0].index) + .map(|action| action.stage), + Some(false) + ); + assert_eq!( + panel + .staging_action_for_entry_index(projections[1].index) + .map(|action| action.stage), + Some(true) + ); + panel.entries.clone() + }); + + #[rustfmt::skip] + pretty_assertions::assert_matches!( + entries.as_slice(), + &[ + Header(GitHeaderEntry { header: Section::Conflict }), + Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }), + Header(GitHeaderEntry { header: Section::Staged }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Staged, .. }), + Header(GitHeaderEntry { header: Section::Unstaged }), + Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + ], + ); + assert_entry_paths( + &entries, + &[ + None, + Some("conflict.rs"), + None, + Some("partial.rs"), + Some("partial_new.rs"), + Some("staged.rs"), + None, + Some("new.rs"), + Some("partial.rs"), + Some("partial_new.rs"), + Some("unstaged.rs"), + ], + ); + + let worktree_id = + cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); + panel.update_in(&mut cx, |panel, window, cx| { + panel.select_entry_by_path( + ProjectPath { + worktree_id, + path: rel_path("partial.rs").into_arc(), + }, + window, + cx, + ); + }); + panel.read_with(&cx, |panel, _| { + assert_eq!( + panel.selected_entry, + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged) + ); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged); + panel.select_entry_by_path( + ProjectPath { + worktree_id, + path: rel_path("partial.rs").into_arc(), + }, + window, + cx, + ); + }); + panel.read_with(&cx, |panel, _| { + assert_eq!( + panel.selected_entry, + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged) + ); + }); + + panel.update_in(&mut cx, |panel, _window, _cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged); + }); + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "conflict.rs", + UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + } + .into(), + ), + ("new.rs", FileStatus::Untracked), + ("partial.rs", StatusCode::Modified.worktree()), + ( + "partial_new.rs", + TrackedStatus { + index_status: StatusCode::Added, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ("unstaged.rs", StatusCode::Modified.worktree()), + ], + ); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + panel.read_with(&cx, |panel, _| { + let selected_entry = panel + .get_selected_entry() + .and_then(GitListEntry::status_entry) + .expect("selected change should remain selected"); + assert_eq!(selected_entry.repo_path, repo_path("partial.rs")); + assert_eq!( + panel.selected_entry, + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged) + ); + }); + } + + #[gpui::test] + async fn test_staging_conflict_mark_resolved_transition(cx: &mut TestAppContext) { + use GitListEntry::*; + + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "conflict.rs": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n", + }), + ) + .await; + + let unresolved_status = FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[("conflict.rs", unresolved_status)], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + cx.executor().run_until_parked(); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + let conflict_entry = panel.read_with(&cx, |panel, _| { + pretty_assertions::assert_matches!( + panel.entries.as_slice(), + &[ + Header(GitHeaderEntry { + header: Section::Conflict + }), + Status(GitStatusEntry { + status: FileStatus::Unmerged(..), + .. + }), + ], + ); + panel + .entries + .get(1) + .and_then(GitListEntry::status_entry) + .cloned() + .expect("conflict entry should exist") + }); + + panel.update_in(&mut cx, |panel, _window, cx| { + panel.change_file_stage(true, vec![conflict_entry.clone()], cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _| { + assert!(matches!( + panel.entries.as_slice(), + [ + Header(GitHeaderEntry { + header: Section::Conflict + }), + Status(GitStatusEntry { + status: FileStatus::Unmerged(..), + .. + }), + ] + )); + }); + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[("conflict.rs", FileStatus::index(StatusCode::Modified))], + ); + cx.run_until_parked(); + await_git_panel_entries(&panel, &mut cx).await; + + panel.read_with(&cx, |panel, _| { + pretty_assertions::assert_matches!( + panel.entries.as_slice(), + &[ + Header(GitHeaderEntry { + header: Section::Staged + }), + Status(GitStatusEntry { + staging: StageStatus::Staged, + .. + }), + ], + ); + assert_eq!(panel.entry_count, 1); + }); + } + + #[gpui::test] + async fn test_group_by_staging_primary_action_stages_partially_staged_files( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "partial.rs": "partial content", + "staged.rs": "staged content", + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "partial.rs", + TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + } + .into(), + ), + ("staged.rs", FileStatus::index(StatusCode::Modified)), + ], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + panel.read_with(&mut cx, |panel, _| { + assert_eq!(panel.entry_count, 2); + assert_eq!(panel.total_staged_count(), panel.entry_count); + assert!(panel.has_unstaged_changes()); + assert!(panel.primary_changes_action_stages()); + }); + } + + #[gpui::test] + async fn test_group_by_staging_open_diff_uses_section_diff(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "partial.rs": "partial content", + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[( + "partial.rs", + TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + } + .into(), + )], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }) + }); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + cx.executor().run_until_parked(); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged); + panel.open_diff(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.open_solo_diff(&menu::SecondaryConfirm, window, cx); + }); + cx.run_until_parked(); + + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = + panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged); + panel.open_diff(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + }); + } + #[gpui::test] async fn test_bulk_staging(cx: &mut TestAppContext) { use GitListEntry::*; @@ -10410,6 +11555,19 @@ mod tests { // "Update tracked" let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); assert_eq!(message, Some("Update tracked".to_string())); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Staging); + }); + }); + }); + await_git_panel_entries(&panel, cx).await; + + let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx)); + assert_eq!(message, Some("Update tracked".to_string())); } #[test] diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs index 9b607c72cbc..30ab5bd0590 100644 --- a/crates/git_ui/src/staged_diff.rs +++ b/crates/git_ui/src/staged_diff.rs @@ -198,13 +198,21 @@ impl StagedDiff { if let Some(entry) = entry { staged_diff.update(cx, |staged_diff, cx| { - staged_diff - .diff - .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + staged_diff.move_to_entry(entry, window, cx); }); } } + pub(crate) fn move_to_entry( + &mut self, + entry: GitStatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + } + pub(crate) fn new( project: Entity, workspace: Entity, diff --git a/crates/git_ui/src/unstaged_diff.rs b/crates/git_ui/src/unstaged_diff.rs index 44f700c722a..02cb8f12c24 100644 --- a/crates/git_ui/src/unstaged_diff.rs +++ b/crates/git_ui/src/unstaged_diff.rs @@ -81,12 +81,38 @@ impl DiffHunkDelegate for UnstagedDiffDelegate { } } + fn restore( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + if hunks.is_empty() || editor.read_only(cx) { + return; + } + editor.transact(window, cx, |editor, window, cx| { + editor.restore_diff_hunks(hunks, cx); + let selections = editor + .selections + .all::(&editor.display_snapshot(cx)); + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selections_state| { + selections_state.select(selections); + }, + ); + }); + } + fn render_hunk_controls( &self, row: u32, status: &DiffHunkStatus, hunk_range: Range, - _is_created_file: bool, + is_created_file: bool, line_height: Pixels, editor: &Entity, _window: &mut Window, @@ -98,6 +124,7 @@ impl DiffHunkDelegate for UnstagedDiffDelegate { { return gpui::Empty.into_any_element(); } + let hunk_range_for_restore = hunk_range.clone(); let hunk_range = hunk_range.start..hunk_range.start; h_flex() .h(line_height) @@ -130,6 +157,29 @@ impl DiffHunkDelegate for UnstagedDiffDelegate { } }), ) + .child( + Button::new(("restore", row as u64), "Restore") + .tooltip(Tooltip::text("Restore Hunk")) + .on_click({ + let editor = editor.clone(); + let hunk_range = hunk_range_for_restore; + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let hunks: Vec<_> = editor + .diff_hunks_in_ranges( + std::slice::from_ref(&hunk_range), + &snapshot, + ) + .collect(); + if !hunks.is_empty() { + editor.apply_restore(hunks, window, cx); + } + }); + } + }) + .disabled(is_created_file), + ) .into_any_element() } @@ -202,13 +252,21 @@ impl UnstagedDiff { if let Some(entry) = entry { unstaged_diff.update(cx, |unstaged_diff, cx| { - unstaged_diff - .diff - .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + unstaged_diff.move_to_entry(entry, window, cx); }); } } + pub(crate) fn move_to_entry( + &mut self, + entry: GitStatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + } + pub(crate) fn new( project: Entity, workspace: Entity, @@ -270,6 +328,9 @@ impl UnstagedDiff { .diff_hunks_in_ranges(&ranges, &snapshot) .next() .is_some(); + let restore = editor + .diff_hunks_in_ranges(&ranges, &snapshot) + .any(|h| !h.is_created_file()); let mut stage_all = false; self.workspace .read_with(cx, |workspace, cx| { @@ -278,9 +339,12 @@ impl UnstagedDiff { } }) .ok(); + let restore_all = snapshot.diff_hunks().any(|h| !h.is_created_file()); ButtonStates { stage, + restore, + restore_all, prev_next, selection, stage_all, @@ -297,10 +361,23 @@ impl UnstagedDiff { diff.stage_or_unstage_selected_hunks(true, move_to_next, window, cx) }); } + + fn restore_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.restore_selected_hunks(move_to_next, window, cx) + }); + } } struct ButtonStates { stage: bool, + restore: bool, + restore_all: bool, prev_next: bool, selection: bool, stage_all: bool, @@ -579,6 +656,20 @@ impl UnstagedDiffToolbar { }); } + fn restore_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return; + }; + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.restore_selected_unstaged_hunks(move_to_next, window, cx); + }); + } + fn stage_all(&mut self, window: &mut Window, cx: &mut Context) { self.workspace .update(cx, |workspace, cx| { @@ -591,6 +682,24 @@ impl UnstagedDiffToolbar { }) .ok(); } + + fn restore_all(&mut self, window: &mut Window, cx: &mut Context) { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return; + }; + let diff = unstaged_diff.read(cx).diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); + let hunks: Vec<_> = snapshot + .diff_hunks() + .filter(|h| !h.is_created_file()) + .collect(); + if !hunks.is_empty() { + editor.update(cx, |editor, cx| { + editor.apply_restore(hunks, window, cx); + }); + } + } } impl EventEmitter for UnstagedDiffToolbar {} @@ -704,7 +813,15 @@ impl Render for UnstagedDiffToolbar { this.stage_selected_unstaged_hunks(true, window, cx) })), ) - }), + }) + .child( + Button::new("restore", "Restore") + .disabled(!button_states.restore) + .tooltip(Tooltip::text("Restore Selected Hunks")) + .on_click(cx.listener(|this, _, window, cx| { + this.restore_selected_unstaged_hunks(false, window, cx) + })), + ), ) .child(Divider::vertical()) .child( @@ -718,5 +835,13 @@ impl Render for UnstagedDiffToolbar { )) .on_click(cx.listener(|this, _, window, cx| this.stage_all(window, cx))), ) + .child(Divider::vertical()) + .child( + Button::new("restore-all", "Restore All") + .width(rems_from_px(80.)) + .disabled(!button_states.restore_all) + .tooltip(Tooltip::text("Restore All Changes")) + .on_click(cx.listener(|this, _, window, cx| this.restore_all(window, cx))), + ) } } diff --git a/crates/google_ai/src/completion.rs b/crates/google_ai/src/completion.rs index ab071156483..16c4f86eb41 100644 --- a/crates/google_ai/src/completion.rs +++ b/crates/google_ai/src/completion.rs @@ -2,8 +2,9 @@ use anyhow::Result; use futures::{Stream, StreamExt}; use language_model_core::{ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, - StopReason, TokenUsage, + LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolUse, + LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason, + TokenUsage, }; use std::pin::Pin; use std::sync::Arc; @@ -20,20 +21,18 @@ pub fn into_google( mut request: LanguageModelRequest, model_id: String, mode: GoogleModelMode, -) -> crate::GenerateContentRequest { - fn map_content(content: Vec) -> Vec { - content - .into_iter() - .flat_map(|content| match content { +) -> Result { + fn map_content(content: Vec) -> Result> { + let mut mapped_parts = Vec::new(); + for content in content { + match content { MessageContent::Text(text) => { if !text.is_empty() { - vec![Part::TextPart(TextPart { + mapped_parts.push(Part::TextPart(TextPart { text, thought: false, thought_signature: None, - })] - } else { - vec![] + })); } } MessageContent::Thinking { @@ -41,39 +40,37 @@ pub fn into_google( signature: Some(signature), } => { if !signature.is_empty() { - vec![Part::TextPart(TextPart { + mapped_parts.push(Part::TextPart(TextPart { text, thought: true, thought_signature: Some(signature), - })] - } else { - vec![] + })); } } - MessageContent::Thinking { .. } => { - vec![] - } - MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => vec![], + MessageContent::Thinking { .. } => {} + MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => {} MessageContent::Image(image) => { - vec![Part::InlineDataPart(InlineDataPart { + mapped_parts.push(Part::InlineDataPart(InlineDataPart { inline_data: GenerativeContentBlob { mime_type: "image/png".to_string(), data: image.source.to_string(), }, - })] + })); } MessageContent::ToolUse(tool_use) => { - // Normalize empty string signatures to None let thought_signature = tool_use.thought_signature.filter(|s| !s.is_empty()); + let LanguageModelToolUseInput::Json(input) = tool_use.input else { + anyhow::bail!("Google AI does not support custom tool calls"); + }; - vec![Part::FunctionCallPart(crate::FunctionCallPart { + mapped_parts.push(Part::FunctionCallPart(crate::FunctionCallPart { function_call: crate::FunctionCall { name: tool_use.name.to_string(), - args: tool_use.input, + args: input, id: Some(tool_use.id.to_string()), }, thought_signature, - })] + })); } MessageContent::ToolResult(tool_result) => { let mut text_output = String::new(); @@ -98,7 +95,7 @@ pub fn into_google( } else { text_output }; - let mut parts = vec![Part::FunctionResponsePart(crate::FunctionResponsePart { + mapped_parts.push(Part::FunctionResponsePart(crate::FunctionResponsePart { function_response: crate::FunctionResponse { name: tool_result.tool_name.to_string(), // The API expects a valid JSON object @@ -107,12 +104,12 @@ pub fn into_google( }), id: Some(tool_result.tool_use_id.to_string()), }, - })]; - parts.extend(images.into_iter().map(Part::InlineDataPart)); - parts + })); + mapped_parts.extend(images.into_iter().map(Part::InlineDataPart)); } - }) - .collect() + } + } + Ok(mapped_parts) } let thinking_config = thinking_config_for_request(&request, &model_id, mode); @@ -124,33 +121,59 @@ pub fn into_google( { let message = request.messages.remove(0); Some(SystemInstruction { - parts: map_content(message.content), + parts: map_content(message.content)?, }) } else { None }; - crate::GenerateContentRequest { + let tools = if request.tools.is_empty() { + None + } else { + Some(vec![crate::Tool { + function_declarations: request + .tools + .into_iter() + .map(|tool| match tool.input { + LanguageModelRequestToolInput::Function { input_schema, .. } => { + Ok(FunctionDeclaration { + name: tool.name, + description: tool.description, + parameters: input_schema, + }) + } + LanguageModelRequestToolInput::Custom { .. } => { + Err(anyhow::anyhow!("Google AI does not support custom tools")) + } + }) + .collect::>()?, + }]) + }; + + Ok(crate::GenerateContentRequest { model: ModelName { model_id }, system_instruction: system_instructions, contents: request .messages .into_iter() - .filter_map(|message| { - let parts = map_content(message.content); + .map(|message| { + let parts = map_content(message.content)?; if parts.is_empty() { - None + Ok(None) } else { - Some(Content { + Ok(Some(Content { parts, role: match message.role { Role::User => crate::Role::User, Role::Assistant => crate::Role::Model, Role::System => crate::Role::User, // Google AI doesn't have a system role }, - }) + })) } }) + .collect::>>()? + .into_iter() + .flatten() .collect(), generation_config: Some(GenerationConfig { candidate_count: Some(1), @@ -162,19 +185,7 @@ pub fn into_google( top_k: None, }), safety_settings: None, - tools: (!request.tools.is_empty()).then(|| { - vec![crate::Tool { - function_declarations: request - .tools - .into_iter() - .map(|tool| FunctionDeclaration { - name: tool.name, - description: tool.description, - parameters: tool.input_schema, - }) - .collect(), - }] - }), + tools, tool_config: request.tool_choice.map(|choice| ToolConfig { function_calling_config: FunctionCallingConfig { mode: match choice { @@ -185,7 +196,7 @@ pub fn into_google( allowed_function_names: None, }, }), - } + }) } fn thinking_config_for_request( @@ -404,7 +415,9 @@ impl GoogleEventMapper { name, is_input_complete: true, raw_input: function_call_part.function_call.args.to_string(), - input: function_call_part.function_call.args, + input: LanguageModelToolUseInput::Json( + function_call_part.function_call.args, + ), thought_signature, }, ))); @@ -493,7 +506,8 @@ mod tests { GoogleModelMode::Thinking { budget_tokens: None, }, - ); + ) + .unwrap(); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.include_thoughts, Some(true)); @@ -515,7 +529,8 @@ mod tests { GoogleModelMode::Thinking { budget_tokens: None, }, - ); + ) + .unwrap(); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.thinking_budget, Some(0)); @@ -533,7 +548,8 @@ mod tests { GoogleModelMode::Thinking { budget_tokens: None, }, - ); + ) + .unwrap(); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.thinking_level, Some(ThinkingLevel::Minimal)); @@ -561,7 +577,8 @@ mod tests { GoogleModelMode::Thinking { budget_tokens: None, }, - ); + ) + .unwrap(); let Part::TextPart(text_part) = &request.contents[0].parts[0] else { panic!("expected text part"); diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index fa6fcace40f..eab072e470b 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -29,9 +29,7 @@ test-support = [ bench = ["test-support", "dep:criterion", "dep:hdrhistogram"] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] -wayland = [ - "bitflags", -] +wayland = [] x11 = [ "scap?/x11", ] @@ -51,7 +49,7 @@ accesskit.workspace = true anyhow.workspace = true async-task = "4.7" backtrace = { workspace = true, optional = true } -bitflags = { workspace = true, optional = true } +bitflags.workspace = true collections.workspace = true criterion = { workspace = true, optional = true } @@ -256,3 +254,7 @@ path = "examples/mouse_pressure.rs" [[example]] name = "a11y" path = "examples/a11y.rs" + +[[example]] +name = "view_example" +path = "examples/view_example/view_example_main.rs" diff --git a/crates/gpui/examples/grid_layout.rs b/crates/gpui/examples/grid_layout.rs index 650a3e37bbc..9c6d723eba0 100644 --- a/crates/gpui/examples/grid_layout.rs +++ b/crates/gpui/examples/grid_layout.rs @@ -1,68 +1,63 @@ #![cfg_attr(target_family = "wasm", no_main)] use gpui::{ - App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, + App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, container_query, div, + prelude::*, px, rgb, size, }; use gpui_platform::application; // https://en.wikipedia.org/wiki/Holy_grail_(web_design) +// +// Resize the window: the layout is chosen by `container_query` based on the +// measured size of the container, collapsing to a single stacked column when +// it becomes too narrow for the three-column grid. struct HolyGrailExample {} impl Render for HolyGrailExample { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let block = |color: Hsla| { - div() - .size_full() - .bg(color) - .border_1() - .border_dashed() - .rounded_md() - .border_color(gpui::white()) - .items_center() - }; + container_query(|container_size, _window, _cx| { + let block = |color: Hsla| { + div() + .size_full() + .bg(color) + .border_1() + .border_dashed() + .rounded_md() + .border_color(gpui::white()) + .items_center() + }; - div() - .gap_1() - .grid() - .bg(rgb(0x505050)) - .size(px(500.0)) - .shadow_lg() - .border_1() - .size_full() - .grid_cols(5) - .grid_rows(5) - .child( - block(gpui::white()) - .row_span(1) - .col_span_full() - .child("Header"), - ) - .child( - block(gpui::red()) - .col_span(1) - .h_56() - .child("Table of contents"), - ) - .child( - block(gpui::green()) - .col_span(3) - .row_span(3) - .child("Content"), - ) - .child( - block(gpui::blue()) - .col_span(1) - .row_span(3) - .child("AD :(") - .text_color(gpui::white()), - ) - .child( - block(gpui::black()) - .row_span(1) - .col_span_full() - .text_color(gpui::white()) - .child("Footer"), - ) + let header = block(gpui::white()).child(format!("Header — {}", container_size.width)); + let table_of_contents = block(gpui::red()).child("Table of contents"); + let content = block(gpui::green()).child("Content"); + let ad = block(gpui::blue()).child("AD :(").text_color(gpui::white()); + let footer = block(gpui::black()) + .text_color(gpui::white()) + .child("Footer"); + + let container = div().gap_1().bg(rgb(0x505050)).shadow_lg().size_full(); + + if container_size.width < px(400.) { + container + .flex() + .flex_col() + .child(header.h_12().flex_none()) + .child(table_of_contents.h_20().flex_none()) + .child(content.flex_1()) + .child(ad.h_20().flex_none()) + .child(footer.h_12().flex_none()) + } else { + container + .grid() + .grid_cols(5) + .grid_rows(5) + .child(header.row_span(1).col_span_full()) + .child(table_of_contents.col_span(1).h_56()) + .child(content.col_span(3).row_span(3)) + .child(ad.col_span(1).row_span(3)) + .child(footer.row_span(1).col_span_full()) + } + }) } } diff --git a/crates/gpui/examples/view_example/example_editor.rs b/crates/gpui/examples/view_example/example_editor.rs new file mode 100644 index 00000000000..2064d7cacef --- /dev/null +++ b/crates/gpui/examples/view_example/example_editor.rs @@ -0,0 +1,549 @@ +//! `Editor` — the workhorse entity. It owns the cursor, blink, focus, keyboard +//! handling, and the specialized text-shaping renderer. The *text itself* lives +//! in a shared `Entity` it's handed at construction, so the value is +//! readable/writable from outside while the editing machinery stays in here. +//! +//! This is the piece that proves the point: a text input is genuinely +//! complicated, and `View` lets all of that complexity live in one entity that +//! anything can embed. + +use std::ops::Range; +use std::time::Duration; + +use gpui::{ + App, Bounds, Context, ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable, + InteractiveElement, LayoutId, PaintQuad, Pixels, ShapedLine, SharedString, Subscription, Task, + TextRun, UTF16Selection, Window, fill, hsla, point, prelude::*, px, relative, size, +}; +use unicode_segmentation::*; + +use crate::{Backspace, Delete, End, Home, Left, Right}; + +pub struct Editor { + pub value: Entity, + pub focus_handle: FocusHandle, + pub cursor: usize, + pub cursor_visible: bool, + _blink_task: Task<()>, + _subscriptions: Vec, +} + +impl Editor { + /// An editor that owns its own string internally, seeded with `text`. + /// Nothing to allocate or wire up at the call site. + pub fn new(text: impl Into, window: &mut Window, cx: &mut Context) -> Self { + let value = cx.new(|_| text.into()); + Self::over(value, window, cx) + } + + /// An editor over a string *you* own, so the value is shared in and out. + pub fn over(value: Entity, window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + + let focus_sub = cx.on_focus(&focus_handle, window, |this, _window, cx| { + this.start_blink(cx); + }); + let blur_sub = cx.on_blur(&focus_handle, window, |this, _window, cx| { + this.stop_blink(cx); + }); + + // The value is shared: anything can write it while we hold a cursor into + // it. Observe it so external writes (a) clamp the cursor back onto a char + // boundary before the next IME round-trip can slice out of bounds, and + // (b) notify us, so an `editor.cached(..)` subtree re-renders — the cache + // is keyed on *our* notify, not the value's. + let value_sub = cx.observe(&value, |this, value, cx| { + let content = value.read(cx); + let mut cursor = this.cursor.min(content.len()); + while cursor > 0 && !content.is_char_boundary(cursor) { + cursor -= 1; + } + this.cursor = cursor; + cx.notify(); + }); + + Self { + value, + focus_handle, + cursor: 0, + cursor_visible: false, + _blink_task: Task::ready(()), + _subscriptions: vec![focus_sub, blur_sub, value_sub], + } + } + + /// The current text. Read this from anywhere to get the value out. + pub fn text(&self, cx: &App) -> String { + self.value.read(cx).clone() + } + + fn start_blink(&mut self, cx: &mut Context) { + self.cursor_visible = true; + self._blink_task = Self::spawn_blink_task(cx); + } + + fn stop_blink(&mut self, cx: &mut Context) { + self.cursor_visible = false; + self._blink_task = Task::ready(()); + cx.notify(); + } + + fn spawn_blink_task(cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + let result = this.update(cx, |editor, cx| { + editor.cursor_visible = !editor.cursor_visible; + cx.notify(); + }); + if result.is_err() { + break; + } + } + }) + } + + fn reset_blink(&mut self, cx: &mut Context) { + self.cursor_visible = true; + self._blink_task = Self::spawn_blink_task(cx); + } + + pub fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor > 0 { + self.cursor = previous_boundary(&content, self.cursor); + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor < content.len() { + self.cursor = next_boundary(&content, self.cursor); + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + self.cursor = 0; + self.reset_blink(cx); + cx.notify(); + } + + pub fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + self.cursor = self.text(cx).len(); + self.reset_blink(cx); + cx.notify(); + } + + pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor > 0 { + let prev = previous_boundary(&content, self.cursor); + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.drain(prev..cursor); + cx.notify(); + }); + self.cursor = prev; + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor < content.len() { + let next = next_boundary(&content, self.cursor); + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.drain(cursor..next); + cx.notify(); + }); + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn insert_newline(&mut self, cx: &mut Context) { + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.insert(cursor, '\n'); + cx.notify(); + }); + self.cursor += 1; + self.reset_blink(cx); + cx.notify(); + } +} + +fn previous_boundary(content: &str, offset: usize) -> usize { + content + .grapheme_indices(true) + .rev() + .find_map(|(idx, _)| (idx < offset).then_some(idx)) + .unwrap_or(0) +} + +fn next_boundary(content: &str, offset: usize) -> usize { + content + .grapheme_indices(true) + .find_map(|(idx, _)| (idx > offset).then_some(idx)) + .unwrap_or(content.len()) +} + +fn offset_from_utf16(content: &str, offset: usize) -> usize { + let mut utf8_offset = 0; + let mut utf16_count = 0; + for ch in content.chars() { + if utf16_count >= offset { + break; + } + utf16_count += ch.len_utf16(); + utf8_offset += ch.len_utf8(); + } + utf8_offset +} + +fn offset_to_utf16(content: &str, offset: usize) -> usize { + let mut utf16_offset = 0; + let mut utf8_count = 0; + for ch in content.chars() { + if utf8_count >= offset { + break; + } + utf8_count += ch.len_utf8(); + utf16_offset += ch.len_utf16(); + } + utf16_offset +} + +fn range_to_utf16(content: &str, range: &Range) -> Range { + offset_to_utf16(content, range.start)..offset_to_utf16(content, range.end) +} + +fn range_from_utf16(content: &str, range_utf16: &Range) -> Range { + offset_from_utf16(content, range_utf16.start)..offset_from_utf16(content, range_utf16.end) +} + +impl Focusable for Editor { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EntityInputHandler for Editor { + fn text_for_range( + &mut self, + range_utf16: Range, + actual_range: &mut Option>, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + let content = self.text(cx); + let range = range_from_utf16(&content, &range_utf16); + actual_range.replace(range_to_utf16(&content, &range)); + Some(content[range].to_string()) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + let content = self.text(cx); + let utf16_cursor = offset_to_utf16(&content, self.cursor); + Some(UTF16Selection { + range: utf16_cursor..utf16_cursor, + reversed: false, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) {} + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let content = self.text(cx); + let range = range_utf16 + .as_ref() + .map(|r| range_from_utf16(&content, r)) + .unwrap_or(self.cursor..self.cursor); + + let new_content = content[..range.start].to_owned() + new_text + &content[range.end..]; + self.cursor = range.start + new_text.len(); + self.value.update(cx, |s, cx| { + *s = new_content; + cx.notify(); + }); + self.reset_blink(cx); + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _new_selected_range_utf16: Option>, + window: &mut Window, + cx: &mut Context, + ) { + self.replace_text_in_range(range_utf16, new_text, window, cx); + } + + fn bounds_for_range( + &mut self, + _range_utf16: Range, + _bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } +} + +impl gpui::Render for Editor { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + EditorText { + editor: cx.entity(), + } + } +} + +// --------------------------------------------------------------------------- +// EditorText — the specialized renderer: shapes the text and paints the cursor. +// --------------------------------------------------------------------------- + +struct EditorText { + editor: Entity, +} + +struct EditorTextPrepaint { + lines: Vec, + cursor: Option, +} + +impl IntoElement for EditorText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for EditorText { + type RequestLayoutState = (); + type PrepaintState = EditorTextPrepaint; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let editor = self.editor.read(cx); + let content = editor.value.read(cx); + let line_count = content.split('\n').count().max(1); + let line_height = window.line_height(); + let mut style = gpui::Style::default(); + style.size.width = relative(1.).into(); + style.size.height = (line_height * line_count as f32).into(); + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let editor = self.editor.read(cx); + let content = editor.value.read(cx).clone(); + let cursor_offset = editor.cursor; + let cursor_visible = editor.cursor_visible; + let is_focused = editor.focus_handle.is_focused(window); + + let style = window.text_style(); + let text_color = style.color; + let font_size = style.font_size.to_pixels(window.rem_size()); + let line_height = window.line_height(); + + let is_placeholder = content.is_empty(); + + let lines: Vec = if is_placeholder { + let placeholder: SharedString = "Type here...".into(); + let run = TextRun { + len: placeholder.len(), + font: style.font(), + color: hsla(0., 0., 0.5, 0.5), + background_color: None, + underline: None, + strikethrough: None, + }; + vec![ + window + .text_system() + .shape_line(placeholder, font_size, &[run], None), + ] + } else { + content + .split('\n') + .map(|line_str| { + let text: SharedString = SharedString::from(line_str.to_string()); + let run = TextRun { + len: text.len(), + font: style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + window + .text_system() + .shape_line(text, font_size, &[run], None) + }) + .collect() + }; + + let cursor = if is_focused && cursor_visible && !is_placeholder { + let (cursor_line, offset_in_line) = cursor_line_and_offset(&content, cursor_offset); + let cursor_line = cursor_line.min(lines.len().saturating_sub(1)); + let cursor_x = lines[cursor_line].x_for_index(offset_in_line); + Some(fill( + Bounds::new( + point( + bounds.left() + cursor_x, + bounds.top() + line_height * cursor_line as f32, + ), + size(px(1.5), line_height), + ), + text_color, + )) + } else if is_focused && cursor_visible && is_placeholder { + Some(fill( + Bounds::new( + point(bounds.left(), bounds.top()), + size(px(1.5), line_height), + ), + text_color, + )) + } else { + None + }; + + EditorTextPrepaint { lines, cursor } + } + + fn paint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus_handle = self.editor.read(cx).focus_handle.clone(); + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.editor.clone()), + cx, + ); + + let line_height = window.line_height(); + for (i, line) in prepaint.lines.iter().enumerate() { + let origin = point(bounds.left(), bounds.top() + line_height * i as f32); + line.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx) + .unwrap(); + } + + if let Some(cursor) = prepaint.cursor.take() { + window.paint_quad(cursor); + } + } +} + +fn cursor_line_and_offset(content: &str, cursor: usize) -> (usize, usize) { + let mut line_index = 0; + let mut line_start = 0; + for (i, ch) in content.char_indices() { + if i >= cursor { + break; + } + if ch == '\n' { + line_index += 1; + line_start = i + 1; + } + } + (line_index, cursor - line_start) +} + +pub fn standard_actions(editor: Entity) -> impl FnOnce(E) -> E { + move |element| { + element + .on_action({ + let editor = editor.clone(); + move |a: &Left, window, cx| editor.update(cx, |e, cx| e.left(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Right, window, cx| editor.update(cx, |e, cx| e.right(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Home, window, cx| editor.update(cx, |e, cx| e.home(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &End, window, cx| editor.update(cx, |e, cx| e.end(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Backspace, window, cx| { + editor.update(cx, |e, cx| e.backspace(a, window, cx)) + } + }) + .on_action(move |a: &Delete, window, cx| { + editor.update(cx, |e, cx| e.delete(a, window, cx)) + }) + } +} diff --git a/crates/gpui/examples/view_example/example_input.rs b/crates/gpui/examples/view_example/example_input.rs new file mode 100644 index 00000000000..25d74013deb --- /dev/null +++ b/crates/gpui/examples/view_example/example_input.rs @@ -0,0 +1,121 @@ +//! `Input` — a single-line text input. The shaping layer over `Editor`. +//! +//! Construct it two ways, depending on how much state you want to own: +//! * `Input::new(value: Entity)` — you hold just the string; the input +//! allocates the `Editor` internally via `use_state`. Value readable, cursor hidden. +//! * `Input::editor(editor: Entity)` — you hold the editor; cursor/selection +//! are now yours to read and drive too. +//! +//! Either way the chrome is identical. Because the string (or editor) is the +//! input's *identity*, the internal `use_state(Editor)` is collision-safe across +//! any number of inputs. + +use gpui::{ + App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, Pixels, StyleRefinement, + Window, div, hsla, point, prelude::*, px, white, +}; + +use crate::example_editor::{Editor, standard_actions}; + +enum Source { + Value(Entity), + Editor(Entity), +} + +#[derive(IntoElement)] +pub struct Input { + source: Source, + width: Option, + color: Option, +} + +impl Input { + /// Backed by a bare string; the editor is allocated internally. + pub fn new(value: Entity) -> Self { + Self { + source: Source::Value(value), + width: None, + color: None, + } + } + + /// Backed by an editor you own (so you can read/drive its cursor). + pub fn editor(editor: Entity) -> Self { + Self { + source: Source::Editor(editor), + width: None, + color: None, + } + } + + pub fn width(mut self, width: Pixels) -> Self { + self.width = Some(width); + self + } + + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +impl gpui::View for Input { + fn entity_id(&self) -> Option { + Some(match &self.source { + Source::Value(value) => value.entity_id(), + Source::Editor(editor) => editor.entity_id(), + }) + } + + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + // Get the editor: use the one we were handed, or allocate it under our + // own (string-derived) identity so it persists and never collides. + let editor = match self.source { + Source::Value(value) => { + window.use_state(cx, move |window, cx| Editor::over(value, window, cx)) + } + Source::Editor(editor) => editor, + }; + + let focus_handle = editor.read(cx).focus_handle.clone(); + let is_focused = focus_handle.is_focused(window); + let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.)); + let box_width = self.width.unwrap_or(px(300.)); + + let border = if is_focused { + hsla(220. / 360., 0.8, 0.5, 1.) + } else { + hsla(0., 0., 0.75, 1.) + }; + + div() + .id("input") + .key_context("TextInput") + .track_focus(&focus_handle) + .cursor(CursorStyle::IBeam) + .map(standard_actions(editor.clone())) + .w(box_width) + .h(px(36.)) + .px(px(8.)) + .bg(white()) + .border_1() + .border_color(border) + .when(is_focused, |this| { + this.shadow(vec![BoxShadow { + color: hsla(220. / 360., 0.8, 0.5, 0.3), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(1.), + inset: false, + }]) + }) + .rounded(px(4.)) + .overflow_hidden() + .flex() + .items_center() + .line_height(px(20.)) + .text_size(px(14.)) + .text_color(text_color) + .child(editor.cached(StyleRefinement::default().size_full())) + } +} diff --git a/crates/gpui/examples/view_example/example_tests.rs b/crates/gpui/examples/view_example/example_tests.rs new file mode 100644 index 00000000000..a3edae8cda1 --- /dev/null +++ b/crates/gpui/examples/view_example/example_tests.rs @@ -0,0 +1,131 @@ +//! Tests for the input composition. Require the `test-support` feature: +//! +//! ```sh +//! cargo test -p gpui --example view_example --features test-support +//! ``` + +#[cfg(test)] +mod tests { + use gpui::{Context, Entity, KeyBinding, TestAppContext, Window, prelude::*}; + + use crate::example_editor::Editor; + use crate::example_input::Input; + use crate::{Backspace, Delete, End, Home, Left, Right}; + + /// Two inputs, each backed by an editor we own (so the test can focus and + /// read them). Proves data flows through the shared `String` and that + /// sibling inputs stay isolated. + struct Harness { + a: Entity, + b: Entity, + } + + impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div() + .child(Input::editor(self.a.clone())) + .child(Input::editor(self.b.clone())) + } + } + + fn bind_keys(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, None), + KeyBinding::new("delete", Delete, None), + KeyBinding::new("left", Left, None), + KeyBinding::new("right", Right, None), + KeyBinding::new("home", Home, None), + KeyBinding::new("end", End, None), + ]); + }); + } + + fn setup( + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + Entity, + &mut gpui::VisualTestContext, + ) { + bind_keys(cx); + + let (harness, cx) = cx.add_window_view(|window, cx| { + let a_value = cx.new(|_| String::new()); + let b_value = cx.new(|_| String::new()); + let a = cx.new(|cx| Editor::over(a_value, window, cx)); + let b = cx.new(|cx| Editor::over(b_value, window, cx)); + Harness { a, b } + }); + + let a = cx.read_entity(&harness, |h, _| h.a.clone()); + let b = cx.read_entity(&harness, |h, _| h.b.clone()); + let a_value = cx.read_entity(&a, |e, _| e.value.clone()); + let b_value = cx.read_entity(&b, |e, _| e.value.clone()); + + // Focus the first input's editor. + cx.update(|window, cx| { + let focus_handle = a.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + }); + + (a, a_value, b_value, cx) + } + + #[gpui::test] + fn typing_updates_the_shared_string(cx: &mut TestAppContext) { + let (editor, a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("hello"); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "hello")); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5)); + } + + #[gpui::test] + fn sibling_inputs_are_isolated(cx: &mut TestAppContext) { + let (_editor, a_value, b_value, cx) = setup(cx); + + cx.simulate_input("x"); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "x")); + cx.read_entity(&b_value, |value, _| { + assert_eq!(value, "", "typing in input A must not touch input B") + }); + } + + #[gpui::test] + fn external_writes_clamp_the_cursor(cx: &mut TestAppContext) { + let (editor, a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("hello"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5)); + + // Write the shared value from outside the editor. The old cursor (5) + // now points into the middle of a multi-byte character; the editor's + // observation must clamp it back onto a boundary. + cx.update(|_, cx| { + a_value.update(cx, |value, cx| { + *value = "日本".to_string(); + cx.notify(); + }) + }); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "日本")); + cx.read_entity(&editor, |editor, _| { + assert_eq!(editor.cursor, 3, "cursor must clamp to a char boundary"); + }); + } + + #[gpui::test] + fn arrows_move_the_cursor(cx: &mut TestAppContext) { + let (editor, _a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("abc"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 3)); + + cx.simulate_keystrokes("left left"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1)); + } +} diff --git a/crates/gpui/examples/view_example/example_text_area.rs b/crates/gpui/examples/view_example/example_text_area.rs new file mode 100644 index 00000000000..07640b93294 --- /dev/null +++ b/crates/gpui/examples/view_example/example_text_area.rs @@ -0,0 +1,118 @@ +//! `TextArea` — a multi-line text box. Same `Editor` workhorse, taller chrome, +//! and `Enter` inserts a newline instead of being ignored. Constructible from a +//! string or an editor, exactly like [`Input`](crate::example_input::Input). + +use gpui::{ + App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, StyleRefinement, Window, div, + hsla, point, prelude::*, px, white, +}; + +use crate::Enter; +use crate::example_editor::{Editor, standard_actions}; + +enum Source { + Value(Entity), + Editor(Entity), +} + +#[derive(IntoElement)] +pub struct TextArea { + source: Source, + rows: usize, + color: Option, +} + +impl TextArea { + pub fn new(value: Entity, rows: usize) -> Self { + Self { + source: Source::Value(value), + rows, + color: None, + } + } + + pub fn editor(editor: Entity, rows: usize) -> Self { + Self { + source: Source::Editor(editor), + rows, + color: None, + } + } + + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +impl gpui::View for TextArea { + fn entity_id(&self) -> Option { + Some(match &self.source { + Source::Value(value) => value.entity_id(), + Source::Editor(editor) => editor.entity_id(), + }) + } + + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let editor = match self.source { + Source::Value(value) => { + window.use_state(cx, move |window, cx| Editor::over(value, window, cx)) + } + Source::Editor(editor) => editor, + }; + + let focus_handle = editor.read(cx).focus_handle.clone(); + let is_focused = focus_handle.is_focused(window); + let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.)); + let row_height = px(20.); + let box_height = row_height * self.rows as f32 + px(16.); + + let border = if is_focused { + hsla(220. / 360., 0.8, 0.5, 1.) + } else { + hsla(0., 0., 0.75, 1.) + }; + + div() + .id("text-area") + .key_context("TextInput") + .track_focus(&focus_handle) + .cursor(CursorStyle::IBeam) + .map(standard_actions(editor.clone())) + // Enter is the one binding that differs from a single-line input. + .on_action({ + let editor = editor.clone(); + move |_: &Enter, _window, cx| editor.update(cx, |e, cx| e.insert_newline(cx)) + }) + .w(px(400.)) + .h(box_height) + .p(px(8.)) + .bg(white()) + .border_1() + .border_color(border) + .when(is_focused, |this| { + this.shadow(vec![BoxShadow { + color: hsla(220. / 360., 0.8, 0.5, 0.3), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(1.), + inset: false, + }]) + }) + .rounded(px(4.)) + .overflow_hidden() + .line_height(row_height) + .text_size(px(14.)) + .text_color(text_color) + // The cache style is computed from the `rows` prop: change `rows` and + // the editor's cached bounds change, busting its cache and re-laying + // out the text. (`Input` just uses `size_full()` — nothing to vary.) + .child( + editor.cached( + StyleRefinement::default() + .w_full() + .h(row_height * self.rows as f32), + ), + ) + } +} diff --git a/crates/gpui/examples/view_example/view_example_main.rs b/crates/gpui/examples/view_example/view_example_main.rs new file mode 100644 index 00000000000..0eac8494ffc --- /dev/null +++ b/crates/gpui/examples/view_example/view_example_main.rs @@ -0,0 +1,173 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +//! View example — composing a text input from the `View` primitives. +//! +//! The whole point: a text input is deceptively complicated, and `View` makes it +//! easy to compose one. Three pieces, each shown in its own section: +//! +//! * `Editor` — the workhorse entity: cursor, blink, focus, keyboard, and a +//! specialized text renderer. All the hard parts live here. +//! * `String` — the data plane. `editor.text(cx)` / `value.read(cx)` get it out. +//! * `Input` / `TextArea` — the shaping layer. Each takes a `String` (and grows +//! the editor internally) OR an `Editor` (so you can read the cursor). +//! +//! Run: `cargo run -p gpui --example view_example` + +mod example_editor; +mod example_input; +mod example_text_area; + +#[cfg(test)] +mod example_tests; + +use example_editor::Editor; +use example_input::Input; +use example_text_area::TextArea; + +use gpui::{ + App, Bounds, Context, Div, Entity, IntoElement, KeyBinding, Render, SharedString, Window, + WindowBounds, WindowOptions, actions, div, hsla, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +actions!( + view_example, + [Backspace, Delete, Left, Right, Home, End, Enter, Quit] +); + +/// A tiny stateless view that reads an editor's cursor and is composed *beside* +/// the thing editing it — two views over one entity, zero wiring. +#[derive(IntoElement)] +struct CursorReadout { + editor: Entity, +} + +impl CursorReadout { + fn new(editor: Entity) -> Self { + Self { editor } + } +} + +impl gpui::RenderOnce for CursorReadout { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let cursor = self.editor.read(cx).cursor; + div() + .text_sm() + .text_color(hsla(0., 0., 0.45, 1.)) + .child(SharedString::from(format!("cursor @ {cursor}"))) + } +} + +struct ViewExample; + +impl ViewExample { + fn new() -> Self { + Self + } +} + +impl Render for ViewExample { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // The data plane: plain strings, allocated at the top by the hook. + let name = window.use_state(cx, |_, _| String::new()); + let email = window.use_state(cx, |_, _| String::from("me@example.com")); + let bio = window.use_state(cx, |_, _| String::new()); + // Editors that own their own string internally — no extra wiring up top. + let notes = window.use_state(cx, |window, cx| Editor::new("multi\nline", window, cx)); + let owned = window.use_state(cx, |window, cx| Editor::new("editable", window, cx)); + + div() + .flex() + .flex_col() + .size_full() + .bg(rgb(0xf0f0f0)) + .p(px(24.)) + .gap(px(24.)) + .child( + section("Inputs — from a String (cursor stays internal)") + .child(Input::new(name).width(px(320.))) + .child( + Input::new(email) + .width(px(320.)) + .color(hsla(0., 0., 0.3, 1.)), + ), + ) + .child( + section("Input — from an Editor (read its cursor beside it)").child( + div() + .flex() + .items_center() + .gap(px(12.)) + .child(Input::editor(owned.clone()).width(px(320.))) + .child(CursorReadout::new(owned)), + ), + ) + .child( + section("Text areas — from a String, or from an Editor") + .child(TextArea::new(bio, 3)) + .child( + div() + .flex() + .items_start() + .gap(px(12.)) + .child(TextArea::editor(notes.clone(), 3).color(hsla( + 250. / 360., + 0.7, + 0.4, + 1., + ))) + .child(CursorReadout::new(notes)), + ), + ) + } +} + +/// A labeled vertical section. +fn section(title: &str) -> Div { + div().flex().flex_col().gap(px(8.)).child( + div() + .text_sm() + .text_color(hsla(0., 0., 0.3, 1.)) + .child(SharedString::from(title.to_string())), + ) +} + +fn run_example() { + application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(560.0), px(480.0)), cx); + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, None), + KeyBinding::new("delete", Delete, None), + KeyBinding::new("left", Left, None), + KeyBinding::new("right", Right, None), + KeyBinding::new("home", Home, None), + KeyBinding::new("end", End, None), + KeyBinding::new("enter", Enter, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|_| ViewExample::new()), + ) + .unwrap(); + + cx.on_action(|_: &Quit, cx| cx.quit()); + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index e6ca25ecae0..0467c3e4fd8 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -143,6 +143,31 @@ impl Drop for AppRefMut<'_> { /// You won't interact with this type much outside of initial configuration and startup. pub struct Application(Rc); +/// A strong handle to an [`Application`] started with [`Application::run_embedded`]. +/// +/// Dropping this handle releases the app, so an embedder must hold it for as long as the +/// app should run. While held, it is the embedder's entry point back into GPUI each time +/// the external run loop gives it control. +pub struct ApplicationHandle { + app: Rc, +} + +impl ApplicationHandle { + /// Invoke `f` with the app context. Must not be called re-entrantly from code that + /// is already inside an update; the app state is a `RefCell` and will panic on a + /// double borrow. + pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> R { + let cx = &mut *self.app.borrow_mut(); + f(cx) + } + + /// An [`AsyncApp`] for use across await points. It holds the app weakly; keeping the + /// app alive remains this handle's job. + pub fn to_async(&self) -> AsyncApp { + self.update(|cx| cx.to_async()) + } +} + /// Represents an application before it is fully launched. Once your app is /// configured, you'll start the app with `App::run`. impl Application { @@ -209,6 +234,28 @@ impl Application { })); } + /// Start the application for an embedder that drives the run loop itself. + /// + /// On ordinary platforms `Platform::run` blocks for the lifetime of the app, and the + /// app state is kept alive by [`Application::run`]'s stack frame. Embedded platforms — + /// where the run loop belongs to someone else, e.g. GPUI compiled into a Wasm guest, + /// or a GPUI view hosted inside a foreign native application — implement + /// `Platform::run` to invoke the launch callback and return immediately. This method + /// supports that shape: it returns an [`ApplicationHandle`] that keeps the app alive + /// and lets the embedder re-enter it whenever the external run loop yields control. + pub fn run_embedded(self, on_finish_launching: F) -> ApplicationHandle + where + F: 'static + FnOnce(&mut App), + { + let this = self.0.clone(); + let platform = self.0.borrow().platform.clone(); + platform.run(Box::new(move || { + let cx = &mut *this.borrow_mut(); + on_finish_launching(cx); + })); + ApplicationHandle { app: self.0 } + } + /// Register a handler to be invoked when the platform instructs the application /// to open one or more URLs. pub fn on_open_urls(&self, mut callback: F) -> &Self diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index f212f246caa..dbd82b1c2ef 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -33,12 +33,12 @@ use crate::{ A11ySubtreeBuilder, App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId, - FocusHandle, InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window, + FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window, util::FluentBuilder, window::with_element_arena, }; use derive_more::{Deref, DerefMut}; use std::{ - any::{Any, type_name}, + any::Any, fmt::{self, Debug, Display}, mem, panic, sync::Arc, @@ -208,116 +208,6 @@ pub trait ParentElement { } } -/// An element for rendering components. An implementation detail of the [`IntoElement`] derive macro -/// for [`RenderOnce`] -#[doc(hidden)] -pub struct Component { - component: Option, - #[cfg(debug_assertions)] - source: &'static core::panic::Location<'static>, -} - -impl Component { - /// Create a new component from the given RenderOnce type. - #[track_caller] - pub fn new(component: C) -> Self { - Component { - component: Some(component), - #[cfg(debug_assertions)] - source: core::panic::Location::caller(), - } - } -} - -fn prepaint_component( - (element, name): &mut (AnyElement, &'static str), - window: &mut Window, - cx: &mut App, -) { - window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { - element.prepaint(window, cx); - }) -} - -fn paint_component( - (element, name): &mut (AnyElement, &'static str), - window: &mut Window, - cx: &mut App, -) { - window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { - element.paint(window, cx); - }) -} -impl Element for Component { - type RequestLayoutState = (AnyElement, &'static str); - type PrepaintState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - #[cfg(debug_assertions)] - return Some(self.source); - - #[cfg(not(debug_assertions))] - return None; - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - window.with_id(ElementId::Name(type_name::().into()), |window| { - let mut element = self - .component - .take() - .unwrap() - .render(window, cx) - .into_any_element(); - - let layout_id = element.request_layout(window, cx); - (layout_id, (element, type_name::())) - }) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - state: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) { - prepaint_component(state, window, cx); - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - state: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - paint_component(state, window, cx); - } -} - -impl IntoElement for Component { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - /// A globally unique identifier for an element, used to track state across frames. #[derive(Deref, DerefMut, Clone, Default, Debug, Eq, PartialEq, Hash)] pub struct GlobalElementId(pub(crate) Arc<[ElementId]>); diff --git a/crates/gpui/src/elements/container_query.rs b/crates/gpui/src/elements/container_query.rs new file mode 100644 index 00000000000..0363ce69008 --- /dev/null +++ b/crates/gpui/src/elements/container_query.rs @@ -0,0 +1,126 @@ +//! A container query element, in the spirit of CSS container queries. +//! The element's own size is determined solely by its style and the space +//! offered by its parent. + +use refineable::Refineable as _; + +use crate::{ + AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, + InspectorElementId, IntoElement, LayoutId, Pixels, Size, Style, StyleRefinement, Styled, + Window, relative, +}; + +/// Construct a container query element with the given render callback. +/// The callback receives the size the element was assigned during layout and +/// returns the contents to display within it. +/// +/// By default the element fills its parent (equivalent to `.size_full()`); +/// use the [`Styled`] methods to size it differently. Because the contents +/// don't exist until after layout, they cannot influence the element's size. +/// +/// # Example +/// +/// ``` +/// # use gpui::{container_query, div, px, IntoElement, ParentElement}; +/// container_query(|size, _window, _cx| { +/// if size.width < px(240.) { +/// div().child("Narrow layout") +/// } else { +/// div().child("Wide layout") +/// } +/// }); +/// ``` +pub fn container_query( + render: impl 'static + FnOnce(Size, &mut Window, &mut App) -> E, +) -> ContainerQuery +where + E: IntoElement, +{ + let mut base_style = StyleRefinement::default(); + base_style.size.width = Some(relative(1.).into()); + base_style.size.height = Some(relative(1.).into()); + + ContainerQuery { + render: Some(Box::new(|size, window, cx| { + render(size, window, cx).into_any_element() + })), + style: base_style, + } +} + +/// A container query element, created with [`container_query`]. +pub struct ContainerQuery { + render: Option, &mut Window, &mut App) -> AnyElement>>, + style: StyleRefinement, +} + +impl Element for ContainerQuery { + type RequestLayoutState = (); + type PrepaintState = Option; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.refine(&self.style); + let layout_id = window.request_layout(style, [], cx); + (layout_id, ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Option { + let render = self.render.take()?; + let mut child = render(bounds.size, window, cx); + child.layout_as_root(bounds.size.map(AvailableSpace::Definite), window, cx); + child.prepaint_at(bounds.origin, window, cx); + Some(child) + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(child) = prepaint { + child.paint(window, cx); + } + } +} + +impl IntoElement for ContainerQuery { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Styled for ContainerQuery { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index ebbce8f84c9..04692c86a55 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2860,7 +2860,6 @@ impl Interactivity { } if let Some(hover_listener) = self.hover_listener.take() { - let hitbox = hitbox.clone(); let was_hovered = element_state .hover_listener_state .get_or_insert_with(Default::default) @@ -2869,22 +2868,35 @@ impl Interactivity { .pending_mouse_down .get_or_insert_with(Default::default) .clone(); - - window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - let is_hovered = has_mouse_down.borrow().is_none() - && !cx.has_active_drag() - && hitbox.is_hovered(window); + let hover_listener = Rc::new(hover_listener); + let update_hover = move |is_hovered: bool, window: &mut Window, cx: &mut App| { let mut was_hovered = was_hovered.borrow_mut(); - if is_hovered != *was_hovered { *was_hovered = is_hovered; drop(was_hovered); - hover_listener(&is_hovered, window, cx); } + }; + + window.on_mouse_event({ + let update_hover = update_hover.clone(); + let hitbox = hitbox.clone(); + move |_: &MouseMoveEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + let is_hovered = has_mouse_down.borrow().is_none() + && !cx.has_active_drag() + && hitbox.is_hovered(window); + update_hover(is_hovered, window, cx); + } + } + }); + + // The pointer can leave the window without a final MouseMove, so also + // clear hover on MouseExited. + window.on_mouse_event(move |_: &MouseExitEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + update_hover(false, window, cx); + } }); } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 85b38d5234e..62b481bc530 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -338,6 +338,17 @@ impl ListState { self } + /// Pre-populate every unmeasured item with a uniform height hint so the scrollbar thumb + /// is correctly sized from the first frame, without measuring all items up front. + /// + /// As items are actually rendered their real heights replace the hint, so the scrollbar + /// converges to the exact size over time. This is a cheaper alternative to [`Self::measure_all`] + /// for lists where items have roughly uniform heights (e.g. table rows). + pub fn with_uniform_item_height(self, height: Pixels) -> Self { + self.apply_uniform_item_height(height); + self + } + /// Reset this instantiation of the list state. /// /// Note that this will cause scroll events to be dropped until the next paint. @@ -355,6 +366,33 @@ impl ListState { self.splice(0..old_count, element_count); } + /// Reset the list to `element_count` items, pre-populating every item with a + /// uniform height hint so the scrollbar thumb is correctly sized from the first + /// frame even for off-screen items. + pub fn reset_with_uniform_height(&self, element_count: usize, height: Pixels) { + self.reset(element_count); + self.apply_uniform_item_height(height); + } + + fn apply_uniform_item_height(&self, height: Pixels) { + let size_hint = Size { + width: px(0.), + height, + }; + let mut state = self.0.borrow_mut(); + let new_items = state + .items + .iter() + .map(|item| ListItem::Unmeasured { + size_hint: Some(item.size_hint().unwrap_or(size_hint)), + focus_handle: item.focus_handle(), + }) + .collect::>(); + let mut tree = SumTree::default(); + tree.extend(new_items, ()); + state.items = tree; + } + /// Remeasure all items while preserving proportional scroll position. /// /// Use this when item heights may have changed (e.g., font size changes) diff --git a/crates/gpui/src/elements/mod.rs b/crates/gpui/src/elements/mod.rs index bfbc08b3f49..8a2a1b70a7b 100644 --- a/crates/gpui/src/elements/mod.rs +++ b/crates/gpui/src/elements/mod.rs @@ -1,6 +1,7 @@ mod anchored; mod animation; mod canvas; +mod container_query; mod deferred; mod div; mod image_cache; @@ -14,6 +15,7 @@ mod uniform_list; pub use anchored::*; pub use animation::*; pub use canvas::*; +pub use container_query::*; pub use deferred::*; pub use div::*; pub use image_cache::*; diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs new file mode 100644 index 00000000000..5e14d4249bc --- /dev/null +++ b/crates/gpui/src/gestures.rs @@ -0,0 +1,123 @@ +//! Touch gesture recognition vocabulary. +//! +//! GPUI recognizes gestures from raw [`TouchEvent`](crate::TouchEvent)s in a +//! single, portable arena in gpui core: recognizers compete for in-flight +//! touches, winners claim them, and losers are cancelled. Recognized gestures +//! are surfaced through *existing* semantic events wherever possible, a tap +//! becomes [`ClickEvent::Touch`](crate::ClickEvent), a pan becomes +//! [`ScrollWheelEvent`](crate::ScrollWheelEvent)s carrying a +//! [`TouchPhase`](crate::TouchPhase), and a pinch becomes +//! [`PinchEvent`](crate::PinchEvent)s — so components written against +//! `on_click` and scroll containers work untouched on mobile. + +use std::time::Duration; + +use crate::{Pixels, Point, px}; + +/// Feel constants consumed by gesture recognizers. Provided on a best-effort +/// basis, depending on each platform's support, defaulting to GPUI's own +/// (iOS flavored) values +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GestureTuning { + /// Distance a touch may travel before it stops being a potential tap and + /// becomes a pan/drag. + pub touch_slop: Pixels, + /// Maximum interval between taps for them to accumulate a tap count. + pub multi_tap_interval: Duration, + /// Maximum distance between taps for them to accumulate a tap count. + pub multi_tap_slop: Pixels, + /// How long a touch must remain within [`Self::touch_slop`] to be + /// recognized as a long press. + pub long_press_duration: Duration, + /// Per-millisecond decay factor applied to scroll momentum after a fling. + /// (`UIScrollView` uses `0.998` per millisecond for its normal + /// deceleration rate.) + pub momentum_decay_per_ms: f32, + /// Minimum release velocity, in pixels per second, required to start + /// scroll momentum. + pub min_fling_velocity: f32, +} + +impl Default for GestureTuning { + fn default() -> Self { + Self { + touch_slop: px(8.), + multi_tap_interval: Duration::from_millis(400), + multi_tap_slop: px(16.), + long_press_duration: Duration::from_millis(500), + momentum_decay_per_ms: 0.998, + min_fling_velocity: 50., + } + } +} + +/// The set of gesture kinds that participate in recognition. +/// +/// Used by [`PlatformGestures::native_recognizers`] to declare which gestures +/// the platform recognizes natively rather than leaving to gpui core's +/// portable recognizers. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GestureKinds { + /// Tap (and multi-tap), surfaced as [`ClickEvent::Touch`](crate::ClickEvent). + pub tap: bool, + /// Long press, surfaced as [`LongPressEvent`]. + pub long_press: bool, + /// Pan/scroll (including fling momentum), surfaced as + /// [`ScrollWheelEvent`](crate::ScrollWheelEvent)s. + pub pan: bool, + /// Pinch to zoom, surfaced as [`PinchEvent`](crate::PinchEvent)s. + pub pinch: bool, +} + +impl GestureKinds { + /// No gestures; gpui core's portable recognizers handle everything. + pub const NONE: Self = Self { + tap: false, + long_press: false, + pan: false, + pinch: false, + }; + + /// All gesture kinds. + pub const ALL: Self = Self { + tap: true, + long_press: true, + pan: true, + pinch: true, + }; +} + +/// A long-press gesture, mobile's context-menu trigger. +/// +/// A bare long press is surfaced as a [`ClickEvent`](crate::ClickEvent) with +/// `long_press: true`, delivered to aux-click listeners alongside right +/// clicks. This event is the raw hook for elements that need the gesture +/// itself (e.g. long-press to start a drag); the registration API ships +/// together with the gesture arena. +#[derive(Clone, Debug, Default)] +pub struct LongPressEvent { + /// The position of the touch that was recognized as a long press. + pub position: Point, +} + +/// Platform gesture recognition services. +/// +/// If your mobile platform supports native gesture recognition, use this +/// to share it with GPUI. +pub trait PlatformGestures { + /// Feel constants for the portable recognizers on this platform. + fn tuning(&self) -> GestureTuning { + GestureTuning::default() + } + + /// The gesture kinds this platform recognizes natively. + fn native_recognizers(&self) -> GestureKinds { + GestureKinds::NONE + } +} + +/// A no-op [`PlatformGestures`] implementation: no native recognizers and +/// default tuning. Suitable for desktop platforms and tests. +pub struct NullPlatformGestures; + +impl PlatformGestures for NullPlatformGestures {} diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index a81ff265c3e..4b1679cbe44 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -24,6 +24,7 @@ mod executor; mod platform_scheduler; pub(crate) use platform_scheduler::PlatformScheduler; mod geometry; +mod gestures; mod global; mod input; mod inspector; @@ -98,6 +99,7 @@ pub use element::*; pub use elements::*; pub use executor::*; pub use geometry::*; +pub use gestures::*; pub use global::*; pub use gpui_macros::{ AppContext, IntoElement, Render, VisualContext, bench, property_test, register_action, test, diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index 10ca46501d8..4e204e97880 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -71,6 +71,24 @@ pub trait EntityInputHandler: 'static + Sized { cx: &mut Context, ) -> Option; + /// See [`InputHandler::set_selected_text_range`] for details + fn set_selected_text_range( + &mut self, + _range_utf16: Range, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + /// See [`InputHandler::text_length_utf16`] for details + fn text_length_utf16( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + /// See [`InputHandler::accepts_text_input`] for details fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { true @@ -183,6 +201,26 @@ impl InputHandler for ElementInputHandler { }) } + fn set_selected_text_range( + &mut self, + range_utf16: Range, + window: &mut Window, + cx: &mut App, + ) { + self.view.update(cx, |view, cx| { + view.set_selected_text_range(range_utf16, window, cx) + }) + } + + fn element_bounds(&mut self, _window: &mut Window, _cx: &mut App) -> Option> { + Some(self.element_bounds) + } + + fn text_length_utf16(&mut self, window: &mut Window, cx: &mut App) -> Option { + self.view + .update(cx, |view, cx| view.text_length_utf16(window, cx)) + } + fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool { self.view .update(cx, |view, cx| view.accepts_text_input(window, cx)) diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 0c7f2f9c97c..1feee49edb0 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -84,7 +84,7 @@ impl Deref for ModifiersChangedEvent { /// The phase of a touch motion event. /// Based on the winit enum of the same name. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum TouchPhase { /// The touch started. Started, @@ -93,6 +93,45 @@ pub enum TouchPhase { Moved, /// The touch phase has ended Ended, + /// The touch was cancelled: the system took it and it will not end + /// normally. Consumers must fully unwind any in-progress interaction, + /// treating the touch as if it never committed. + Cancelled, +} + +/// Identifies one touch (finger or stylus contact) for its lifetime, from +/// [`TouchPhase::Started`] through [`TouchPhase::Ended`] or +/// [`TouchPhase::Cancelled`]. +/// +/// The value is opaque and platform-defined; it is only guaranteed to be +/// stable for the duration of the touch and unique among concurrent touches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct TouchId(pub u64); + +/// A raw touch event from the platform. +/// +/// +/// Dispatch contract (core implementation pending): a touch is hit-tested +/// once, at [`TouchPhase::Started`], occlusion-aware; all subsequent events +/// for the same [`TouchId`] are delivered to the elements under the starting +/// position, even after the touch moves outside them. +#[derive(Clone, Debug, Default)] +pub struct TouchEvent { + /// Which touch this event belongs to. + pub id: TouchId, + /// The phase of the touch. + pub phase: TouchPhase, + /// The position of the touch in window coordinates. + pub position: Point, + /// Normalized touch force in `0.0..=1.0`, if the hardware reports it. + pub force: Option, +} + +impl Sealed for TouchEvent {} +impl InputEvent for TouchEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::Touch(self) + } } /// A mouse down event from the platform @@ -221,13 +260,31 @@ pub struct KeyboardClickEvent { pub bounds: Bounds, } -/// A click event, generated when a mouse button or keyboard button is pressed and released. +/// A click event that was generated by a recognized tap gesture on a touch +/// screen. +#[derive(Clone, Debug, Default)] +pub struct TouchClickEvent { + /// The position of the tap in window coordinates. + pub position: Point, + /// The number of consecutive taps at this location (double tap = 2), + /// analogous to the mouse `click_count`. + pub tap_count: usize, + /// Whether this was a long press rather than a tap. Long presses are + /// touch's secondary activation: they are delivered to aux-click + /// listeners alongside right clicks, not to primary click listeners. + pub long_press: bool, +} + +/// A click event, generated when a mouse button or keyboard button is pressed and released, +/// or when a tap gesture is recognized on a touch screen. #[derive(Clone, Debug)] pub enum ClickEvent { /// A click event trigger by a mouse button being pressed and released. Mouse(MouseClickEvent), /// A click event trigger by a keyboard button being pressed and released. Keyboard(KeyboardClickEvent), + /// A click event triggered by a recognized tap gesture on a touch screen. + Touch(TouchClickEvent), } impl Default for ClickEvent { @@ -249,6 +306,8 @@ impl ClickEvent { // tested via observing the behavior of the `ClickEvent.shiftKey` field in Chrome 138 // under various combinations of modifiers and keyUp / keyDown events. ClickEvent::Mouse(event) => event.up.modifiers, + // Touch screens have no modifier keys. + ClickEvent::Touch(_) => Modifiers::default(), } } @@ -256,10 +315,12 @@ impl ClickEvent { /// /// `Keyboard`: The bottom left corner of the clicked hitbox /// `Mouse`: The position of the mouse when the button was released. + /// `Touch`: The position of the tap. pub fn position(&self) -> Point { match self { ClickEvent::Keyboard(event) => event.bounds.bottom_left(), ClickEvent::Mouse(event) => event.up.position, + ClickEvent::Touch(event) => event.position, } } @@ -267,10 +328,12 @@ impl ClickEvent { /// /// `Keyboard`: None /// `Mouse`: The position of the mouse when the button was released. + /// `Touch`: None, touches are not mouse input and there is no cursor. pub fn mouse_position(&self) -> Option> { match self { ClickEvent::Keyboard(_) => None, ClickEvent::Mouse(event) => Some(event.up.position), + ClickEvent::Touch(_) => None, } } @@ -284,6 +347,7 @@ impl ClickEvent { ClickEvent::Mouse(event) => { event.down.button == MouseButton::Right && event.up.button == MouseButton::Right } + ClickEvent::Touch(_) => false, } } @@ -297,6 +361,21 @@ impl ClickEvent { ClickEvent::Mouse(event) => { event.down.button == MouseButton::Middle && event.up.button == MouseButton::Middle } + ClickEvent::Touch(_) => false, + } + } + + /// Returns whether the click is a secondary activation, i.e. a context + /// menu trigger: a right click from a mouse (macOS ctrl-clicks arrive + /// already converted to right clicks by the platform layer), or a long + /// press on a touch screen. + pub fn is_secondary(&self) -> bool { + match self { + ClickEvent::Keyboard(_) => false, + ClickEvent::Mouse(event) => { + event.down.button == MouseButton::Right && event.up.button == MouseButton::Right + } + ClickEvent::Touch(event) => event.long_press, } } @@ -304,12 +383,14 @@ impl ClickEvent { /// /// `Keyboard`: Always true /// `Mouse`: Left button pressed and released + /// `Touch`: A tap, but not a long press pub fn standard_click(&self) -> bool { match self { ClickEvent::Keyboard(_) => true, ClickEvent::Mouse(event) => { event.down.button == MouseButton::Left && event.up.button == MouseButton::Left } + ClickEvent::Touch(event) => !event.long_press, } } @@ -317,10 +398,12 @@ impl ClickEvent { /// /// `Keyboard`: false, keyboard clicks only work if an element is already focused /// `Mouse`: Whether this was the first focusing click + /// `Touch`: false, mobile windows are already active when tappable pub fn first_focus(&self) -> bool { match self { ClickEvent::Keyboard(_) => false, ClickEvent::Mouse(event) => event.down.first_mouse, + ClickEvent::Touch(_) => false, } } @@ -328,17 +411,19 @@ impl ClickEvent { /// /// `Keyboard`: Always 1 /// `Mouse`: Count of clicks from MouseUpEvent + /// `Touch`: Count of consecutive taps pub fn click_count(&self) -> usize { match self { ClickEvent::Keyboard(_) => 1, ClickEvent::Mouse(event) => event.up.click_count, + ClickEvent::Touch(event) => event.tap_count, } } /// Returns whether the click event is generated by a keyboard event pub fn is_keyboard(&self) -> bool { match self { - ClickEvent::Mouse(_) => false, + ClickEvent::Mouse(_) | ClickEvent::Touch(_) => false, ClickEvent::Keyboard(_) => true, } } @@ -670,6 +755,8 @@ pub enum PlatformInput { Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), + /// A raw touch event on a touch screen. + Touch(TouchEvent), } impl PlatformInput { @@ -686,6 +773,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), + PlatformInput::Touch(_) => None, } } @@ -702,6 +790,15 @@ impl PlatformInput { PlatformInput::ScrollWheel(_) => None, PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, + PlatformInput::Touch(_) => None, + } + } + + /// Returns the touch event contained in this input, if any. + pub fn touch_event(&self) -> Option<&TouchEvent> { + match self { + PlatformInput::Touch(event) => Some(event), + _ => None, } } } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 13fcbcee7a7..52b2f0066a1 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -6,6 +6,9 @@ mod keystroke; #[expect(missing_docs)] pub mod layer_shell; +/// Types for configuring parent-anchored popup windows such as menus, dropdowns and tooltips. +pub mod popup; + #[cfg(any(test, feature = "bench"))] mod bench_dispatcher; @@ -33,11 +36,11 @@ pub(crate) type PlatformScreenCaptureFrame = core_video::image_buffer::CVImageBu use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, - DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, - ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, LineLayout, Pixels, - PlatformInput, Point, Priority, RenderGlyphParams, RenderImage, RenderImageParams, - RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, - SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, + DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Edges, Font, FontId, FontMetrics, + FontRun, ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, LineLayout, Pixels, + PlatformGestures, PlatformInput, Point, Priority, RenderGlyphParams, RenderImage, + RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, + SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, }; use anyhow::Result; #[cfg(any(target_os = "linux", target_os = "freebsd"))] @@ -190,6 +193,30 @@ pub trait Platform: 'static { fn on_reopen(&self, callback: Box); fn on_system_wake(&self, callback: Box); + // Mobile platform methods. On mobile the OS owns the application + // lifecycle: apps are backgrounded, foregrounded, and killed at the + // system's discretion, and must react rather than decide. + + /// Registers a callback invoked whenever the application's lifecycle + /// phase changes. See [`AppLifecyclePhase`] for the phase vocabulary and + /// its mapping onto iOS and Android. + /// + /// Desktop platforms never invoke this. + fn on_app_lifecycle(&self, _callback: Box) {} + + /// Registers a callback invoked when the OS signals memory pressure + /// (iOS `didReceiveMemoryWarning`, Android `onTrimMemory`). + /// + /// Desktop platforms never invoke this. + fn on_memory_warning(&self, _callback: Box) {} + + /// The platform's gesture recognition services, if it provides any + /// beyond gpui's portable recognizers. See + /// [`PlatformGestures`](crate::PlatformGestures). + fn gestures(&self) -> Option> { + None + } + fn set_menus(&self, menus: Vec, keymap: &Keymap); fn get_menus(&self) -> Option> { None @@ -617,6 +644,75 @@ pub struct RequestFrameOptions { pub force_render: bool, } +/// The application's lifecycle phase, as owned and reported by a mobile OS. +/// +/// `Inactive` means visible but not receiving input (a system dialog on +/// top), while `Background` means not visible at all, with process death +/// possible at any time thereafter. +/// +/// | Phase | iOS | Android | +/// |--------------|------------------------------|--------------| +/// | `Active` | `didBecomeActive` | `onResume` | +/// | `Inactive` | `willResignActive` | `onPause` | +/// | `Background` | `didEnterBackground` | `onStop` | +/// | `Foreground` | `willEnterForeground` | `onStart` | +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum AppLifecyclePhase { + /// Foreground and receiving input. + Active, + /// Foreground (visible) but not receiving input. + Inactive, + /// Not visible. The GPU surface may be destroyed while backgrounded and + /// the process may be killed without further notice. + Background, + /// Becoming visible again, before input is restored. + Foreground, +} + +/// Regions of a window that are obscured or reserved by the system. +/// +/// Mobile applications often share space in their window with system-specific +/// geometry, from keyboards to camera notches. In GPUI, all this is abstracted +/// into a single "inset" which should be overlaid on the window's bounds. +/// It is up to the application develop to determine how to handle these cases. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct WindowInsets { + /// Regions covered by system UI or hardware: status bar, display + /// cutouts/notch, home indicator, navigation bars. + /// (iOS: `safeAreaInsets`. Android: `WindowInsets` of types + /// `systemBars() | displayCutout()`.) + pub safe_area: Edges, + /// The region covered by the keyboard, when present. + /// (iOS: derived from `keyboardWillShow`/frame-change notifications. + /// Android: `WindowInsets.Type.ime()`.) + pub ime: Edges, +} + +impl WindowInsets { + /// The combined inset content should avoid. + pub fn effective(&self) -> Edges { + Edges { + top: self.safe_area.top.max(self.ime.top), + right: self.safe_area.right.max(self.ime.right), + bottom: self.safe_area.bottom.max(self.ime.bottom), + left: self.safe_area.left.max(self.ime.left), + } + } +} + +/// A change in the state of the focused text input. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum TextInputStateChange { + /// An editable element gained focus. + FocusGained, + /// The focused editable element lost focus. + FocusLost, + /// The selection or caret moved + SelectionChanged, + /// The document content changed outside of platform-initiated edits. + ContentChanged, +} + #[expect(missing_docs)] pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn bounds(&self) -> Bounds; @@ -718,6 +814,38 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn update_ime_position(&self, _bounds: Bounds); + // Mobile platform methods. + + /// The regions of this window currently obscured or reserved by the + /// system. Zero on platforms without such regions. + fn insets(&self) -> WindowInsets { + WindowInsets::default() + } + + /// Registers a callback invoked whenever [`Self::insets`] change. + /// + /// Contract: fires continuously during animated transitions (Android + /// `WindowInsetsAnimation` progress; on iOS the platform interpolates + /// the keyboard animation curve on frame ticks) and is exact at rest. + fn on_insets_changed(&self, _callback: Box) {} + + /// Sets the handler for the system back action (Android back + /// button/gesture; no source on iOS or desktop). + fn set_back_handler(&self, _callback: Box) {} + + /// Declares whether the application would currently handle the system + /// back action (e.g. navigation depth > 0). + fn set_back_enabled(&self, _enabled: bool) {} + + /// Requests that the soft keyboard be shown. + fn show_soft_keyboard(&self) {} + + /// Requests that the soft keyboard be hidden. + fn hide_soft_keyboard(&self) {} + + /// Inform the operating system that the text input state has changed + fn text_input_state_changed(&self, _change: TextInputStateChange) {} + fn play_system_bell(&self) {} /// Initialize the accessibility adapter with callbacks. @@ -1331,6 +1459,32 @@ impl PlatformInputHandler { .flatten() } + /// See [`InputHandler::set_selected_text_range`]. + pub fn set_selected_text_range(&mut self, range_utf16: Range) { + self.cx + .update(|window, cx| { + self.handler + .set_selected_text_range(range_utf16, window, cx) + }) + .ok(); + } + + /// See [`InputHandler::element_bounds`]. + pub fn element_bounds(&mut self) -> Option> { + self.cx + .update(|window, cx| self.handler.element_bounds(window, cx)) + .ok() + .flatten() + } + + /// See [`InputHandler::text_length_utf16`]. + pub fn text_length_utf16(&mut self) -> Option { + self.cx + .update(|window, cx| self.handler.text_length_utf16(window, cx)) + .ok() + .flatten() + } + #[allow(dead_code)] pub fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool { self.handler.accepts_text_input(window, cx) @@ -1449,6 +1603,38 @@ pub trait InputHandler: 'static { cx: &mut App, ) -> Option; + /// Set the range of the user's currently selected text. + /// + /// This is the reverse data-flow direction from [`Self::selected_text_range`]: + /// platforms call it when the system text machinery moves the selection on the + /// application's behalf — e.g. the user drags a system selection handle or + /// invokes Select All from system UI (iOS `UITextInput setSelectedTextRange:`, + /// Android `InputConnection.setSelection`). + /// + /// range_utf16 is in terms of UTF-16 characters, from 0 to the length of the document + fn set_selected_text_range( + &mut self, + _range_utf16: Range, + _window: &mut Window, + _cx: &mut App, + ) { + } + + /// Get the bounds of the focused text element in window coordinates, if known. + /// + /// This is the pull counterpart to the [`PlatformWindow::update_ime_position`] + /// push: mobile platforms ask for the focused element's geometry when they + /// need it (e.g. to frame system text-interaction UI overlaid on the focused + /// element). + fn element_bounds(&mut self, _window: &mut Window, _cx: &mut App) -> Option> { + None + } + + /// Get the length of the document in UTF-16 characters, if known. + fn text_length_utf16(&mut self, _window: &mut Window, _cx: &mut App) -> Option { + None + } + /// Allows a given input context to opt into getting raw key repeats instead of /// sending these to the platform. /// TODO: Ideally we should be able to set ApplePressAndHoldEnabled in NSUserDefaults @@ -1677,6 +1863,14 @@ pub enum WindowKind { /// use sparingly! PopUp, + /// A parent-anchored, platform-native popup window for menus, comboboxes, context menus and + /// tooltips. Unlike [`WindowKind::PopUp`], it is positioned relative to a parent window. + /// + /// The popup's size comes from [`WindowOptions::window_bounds`], whose origin is ignored. + /// See [`popup::PopupOptions`] for the placement options. Platforms without a native + /// implementation reject it with [`popup::PopupNotSupportedError`]. + AnchoredPopup(popup::PopupOptions), + /// A floating window that appears on top of its parent window Floating, diff --git a/crates/gpui/src/platform/popup.rs b/crates/gpui/src/platform/popup.rs new file mode 100644 index 00000000000..a1f8d7ea0d5 --- /dev/null +++ b/crates/gpui/src/platform/popup.rs @@ -0,0 +1,134 @@ +use bitflags::bitflags; +use thiserror::Error; + +use crate::{AnyWindowHandle, Bounds, Pixels, Point}; + +/// Options for a parent-anchored popup window such as a menu, dropdown, context menu or tooltip. +/// +/// A popup is placed relative to an anchor rectangle on its parent window rather than at an +/// absolute screen position. The platform resolves the final position, so this works both on +/// systems where the compositor owns window placement (Wayland) and on platforms with absolute +/// coordinates. +/// +/// The popup's size comes from [`WindowOptions::window_bounds`](crate::WindowOptions), whose +/// origin is ignored. All coordinates are in logical pixels. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PopupOptions { + /// The window the popup is anchored to. + pub parent: AnyWindowHandle, + + /// The rectangle the popup is positioned relative to, in the parent window's coordinate + /// space (the same space element bounds are in). For example, a dropdown menu uses the + /// bounds of the button that opened it. + pub anchor_rect: Bounds, + + /// Which point of [`Self::anchor_rect`] the popup is anchored to. + pub anchor: PopupAnchor, + + /// The direction in which the popup extends away from the anchor point. A dropdown that + /// drops below its button anchors to [`PopupAnchor::BottomLeft`] with a gravity of + /// [`PopupGravity::BottomRight`] so it grows down and to the right. + pub gravity: PopupGravity, + + /// How the platform may adjust the popup if the requested placement would put it off-screen. + pub constraint_adjustment: PopupConstraintAdjustment, + + /// An additional offset applied to the popup after anchoring. + pub offset: Point, + + /// Whether the popup should take an explicit input grab. + /// + /// Grabbing popups behave like menus: they take keyboard focus and are dismissed when the + /// user clicks outside of them or presses a dismissing key. Use it for menus and comboboxes, + /// not for tooltips or other passive popups. + /// + /// A grab must be requested while the triggering input is still active, in practice the + /// press of the mouse button that opens the popup. Open grabbing popups from a mouse-down + /// handler rather than a click handler, otherwise the grab is refused. + /// + /// Automatic dismissal only covers input aimed at other applications. A click elsewhere in + /// your own application still reaches it as usual, so closing the popup in that case is up + /// to you. Nested grabbing popups must be closed in the reverse order they were opened. + pub grab: bool, +} + +/// The point of the anchor rectangle that a popup is anchored to. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum PopupAnchor { + /// Anchor to the center of the anchor rectangle. + #[default] + Center, + /// Anchor to the center of the top edge. + Top, + /// Anchor to the center of the bottom edge. + Bottom, + /// Anchor to the center of the left edge. + Left, + /// Anchor to the center of the right edge. + Right, + /// Anchor to the top-left corner. + TopLeft, + /// Anchor to the bottom-left corner. + BottomLeft, + /// Anchor to the top-right corner. + TopRight, + /// Anchor to the bottom-right corner. + BottomRight, +} + +/// The direction in which a popup extends away from its anchor point. +/// +/// For instance, a gravity of [`PopupGravity::BottomRight`] places the popup below and to the +/// right of the anchor point. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum PopupGravity { + /// The popup is centered over the anchor point. + #[default] + Center, + /// The popup extends upwards from the anchor point. + Top, + /// The popup extends downwards from the anchor point. + Bottom, + /// The popup extends to the left of the anchor point. + Left, + /// The popup extends to the right of the anchor point. + Right, + /// The popup extends up and to the left of the anchor point. + TopLeft, + /// The popup extends down and to the left of the anchor point. + BottomLeft, + /// The popup extends up and to the right of the anchor point. + TopRight, + /// The popup extends down and to the right of the anchor point. + BottomRight, +} + +bitflags! { + /// How a popup may be adjusted by the platform if the requested placement would put it + /// off-screen. If no flags are set, the popup is placed exactly as requested and may be + /// clipped. + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub struct PopupConstraintAdjustment: u32 { + /// The popup may be slid horizontally to stay on-screen. + const SLIDE_X = 1; + /// The popup may be slid vertically to stay on-screen. + const SLIDE_Y = 2; + /// The popup's anchor and gravity may be flipped horizontally to stay on-screen. + const FLIP_X = 4; + /// The popup's anchor and gravity may be flipped vertically to stay on-screen. + const FLIP_Y = 8; + /// The popup may be shrunk horizontally to stay on-screen. + const RESIZE_X = 16; + /// The popup may be shrunk vertically to stay on-screen. + const RESIZE_Y = 32; + } +} + +/// Returned when the current platform has no native popup implementation yet. +/// +/// Native popups are separate from gpui's in-window popovers, which are drawn as elements inside +/// an existing window. A caller that wants a popup on every platform should treat this error as +/// a cue to fall back to that in-window rendering. +#[derive(Debug, Error)] +#[error("popups are not supported on this platform")] +pub struct PopupNotSupportedError; diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index bc7f5d79eac..ea2ad7b2b90 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -22,6 +22,20 @@ pub type PathVertex_ScaledPixels = PathVertex; #[expect(missing_docs)] pub type DrawOrder = u32; +/// A boolean stored as a `u32` so that GPU-facing structs contain no +/// compiler-inserted padding bytes, which would be undefined behavior to +/// reinterpret as `&[u8]` when writing instance buffers. Guaranteed to be +/// `0` or `1` by construction; shaders read it as a `u32`/`uint`. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +#[repr(transparent)] +pub struct PaddedBool32(u32); + +impl From for PaddedBool32 { + fn from(value: bool) -> Self { + PaddedBool32(value as u32) + } +} + #[derive(Default)] #[expect(missing_docs)] pub struct Scene { @@ -511,7 +525,7 @@ pub struct Underline { pub content_mask: ContentMask, pub color: Hsla, pub thickness: ScaledPixels, - pub wavy: u32, + pub wavy: PaddedBool32, } impl From for Primitive { @@ -701,7 +715,7 @@ impl From for Primitive { pub struct PolychromeSprite { pub order: DrawOrder, pub pad: u32, - pub grayscale: bool, + pub grayscale: PaddedBool32, pub opacity: f32, pub bounds: Bounds, pub content_mask: ContentMask, diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 4844748d6c7..eb3c391dc5e 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -111,6 +111,36 @@ impl TaffyLayoutEngine { .into() } + /// Treats any `auto` dimension of the given node's style as filling `size`. + /// + /// This is applied to window roots before layout so they behave like the + /// root element on the web, which stretches to fill the initial containing + /// block (the viewport) unless given an explicit size. Explicitly styled + /// dimensions are preserved. + pub fn stretch_auto_size_to_fill( + &mut self, + id: LayoutId, + size: Size, + scale_factor: f32, + ) { + let style = self.taffy.style(id.0).expect(EXPECT_MESSAGE); + let stretch_width = style.size.width.is_auto(); + let stretch_height = style.size.height.is_auto(); + if !stretch_width && !stretch_height { + return; + } + let mut style = style.clone(); + if stretch_width { + style.size.width = + taffy::style::Dimension::length(round_to_device_pixel(size.width.0, scale_factor)); + } + if stretch_height { + style.size.height = + taffy::style::Dimension::length(round_to_device_pixel(size.height.0, scale_factor)); + } + self.taffy.set_style(id.0, style).expect(EXPECT_MESSAGE); + } + // Used to understand performance #[allow(dead_code)] fn count_all_children(&self, parent: LayoutId) -> anyhow::Result { diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 39b87dbb803..394f00c1082 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -1,36 +1,24 @@ use crate::{ AnyElement, AnyEntity, AnyWeakEntity, App, Bounds, ContentMask, Context, Element, ElementId, Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, PaintIndex, - Pixels, PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, WeakEntity, + Pixels, PrepaintStateIndex, Render, RenderOnce, Style, StyleRefinement, TextStyle, WeakEntity, }; use crate::{Empty, Window}; use anyhow::Result; use collections::FxHashSet; use refineable::Refineable; use std::mem; -use std::rc::Rc; use std::{any::TypeId, fmt, ops::Range}; -struct AnyViewState { - prepaint_range: Range, - paint_range: Range, - cache_key: ViewCacheKey, - accessed_entities: FxHashSet, -} - -#[derive(Default)] -struct ViewCacheKey { - bounds: Bounds, - content_mask: ContentMask, - text_style: TextStyle, -} - -/// A dynamically-typed handle to a view, which can be downcast to a [Entity] for a specific type. +/// A dynamically-typed view handle that can be downcast to a specific `Entity`. +/// +/// This is the type-erased counterpart to [`ViewElement`]: it holds an entity plus +/// a function pointer to its render, and is itself a [`View`], so embedding it as an +/// element goes through the same [`ViewElement`] machinery as any other view. #[derive(Clone, Debug)] pub struct AnyView { entity: AnyEntity, render: fn(&AnyView, &mut Window, &mut App) -> AnyElement, - cached_style: Option>, } impl From> for AnyView { @@ -38,18 +26,18 @@ impl From> for AnyView { AnyView { entity: value.into_any(), render: any_view::render::, - cached_style: None, } } } impl AnyView { - /// Indicate that this view should be cached when using it as an element. - /// When using this method, the view's previous layout and paint will be recycled from the previous frame if [Context::notify] has not been called since it was rendered. - /// The one exception is when [Window::refresh] is called, in which case caching is ignored. - pub fn cached(mut self, style: StyleRefinement) -> Self { - self.cached_style = Some(style.into()); - self + /// Embed this view as a cached [`ViewElement`] laid out at `style`. + /// + /// The rendered subtree is recycled from the previous frame unless + /// [Context::notify] was called on the backing entity since it was rendered + /// (or [Window::refresh] is called, which ignores caching). + pub fn cached(self, style: StyleRefinement) -> ViewElement { + ViewElement::new(self).cached(style) } /// Convert this to a weak handle. @@ -68,7 +56,6 @@ impl AnyView { Err(entity) => Err(Self { entity, render: self.render, - cached_style: self.cached_style, }), } } @@ -78,7 +65,7 @@ impl AnyView { self.entity.entity_type } - /// Gets the entity id of this handle. + /// The [`EntityId`] of this view. pub fn entity_id(&self) -> EntityId { self.entity.entity_id() } @@ -92,184 +79,48 @@ impl PartialEq for AnyView { impl Eq for AnyView {} -impl Element for AnyView { - type RequestLayoutState = Option; - type PrepaintState = Option; - - fn id(&self) -> Option { - Some(ElementId::View(self.entity_id())) +/// `AnyView` is the type-erased [`View`]: its `render` is a function pointer rather +/// than a concrete type, but it participates in the reactive graph exactly like any +/// other view via [`ViewElement`]. +impl View for AnyView { + fn entity_id(&self) -> Option { + Some(self.entity.entity_id()) } - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - window.with_rendered_view(self.entity_id(), |window| { - // Disable caching when inspecting so that mouse_hit_test has all hitboxes. - let caching_disabled = window.is_inspector_picking(cx); - match self.cached_style.as_ref() { - Some(style) if !caching_disabled => { - let mut root_style = Style::default(); - root_style.refine(style); - let layout_id = window.request_layout(root_style, None, cx); - (layout_id, None) - } - _ => { - let mut element = (self.render)(self, window, cx); - let layout_id = element.request_layout(window, cx); - (layout_id, Some(element)) - } - } - }) - } - - fn prepaint( - &mut self, - global_id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Option { - window.set_view_id(self.entity_id()); - window.with_rendered_view(self.entity_id(), |window| { - if let Some(mut element) = element.take() { - element.prepaint(window, cx); - return Some(element); - } - - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let content_mask = window.content_mask(); - let text_style = window.text_style(); - - if let Some(mut element_state) = element_state - && element_state.cache_key.bounds == bounds - && element_state.cache_key.content_mask == content_mask - && element_state.cache_key.text_style == text_style - && !window.dirty_views.contains(&self.entity_id()) - && !window.refreshing - { - let prepaint_start = window.prepaint_index(); - window.reuse_prepaint(element_state.prepaint_range.clone()); - cx.entities - .extend_accessed(&element_state.accessed_entities); - let prepaint_end = window.prepaint_index(); - element_state.prepaint_range = prepaint_start..prepaint_end; - - return (None, element_state); - } - - let refreshing = mem::replace(&mut window.refreshing, true); - let prepaint_start = window.prepaint_index(); - let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| { - let mut element = (self.render)(self, window, cx); - element.layout_as_root(bounds.size.into(), window, cx); - element.prepaint_at(bounds.origin, window, cx); - element - }); - - let prepaint_end = window.prepaint_index(); - window.refreshing = refreshing; - - ( - Some(element), - AnyViewState { - accessed_entities, - prepaint_range: prepaint_start..prepaint_end, - paint_range: PaintIndex::default()..PaintIndex::default(), - cache_key: ViewCacheKey { - bounds, - content_mask, - text_style, - }, - }, - ) - }, - ) - }) - } - - fn paint( - &mut self, - global_id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _bounds: Bounds, - _: &mut Self::RequestLayoutState, - element: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - window.with_rendered_view(self.entity_id(), |window| { - let caching_disabled = window.is_inspector_picking(cx); - if self.cached_style.is_some() && !caching_disabled { - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let mut element_state = element_state.unwrap(); - - let paint_start = window.paint_index(); - - if let Some(element) = element { - let refreshing = mem::replace(&mut window.refreshing, true); - element.paint(window, cx); - window.refreshing = refreshing; - } else { - window.reuse_paint(element_state.paint_range.clone()); - } - - let paint_end = window.paint_index(); - element_state.paint_range = paint_start..paint_end; - - ((), element_state) - }, - ) - } else { - element.as_mut().unwrap().paint(window, cx); - } - }); + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + (self.render)(&self, window, cx) } } impl IntoElement for Entity { - type Element = AnyView; + type Element = ViewElement>; fn into_element(self) -> Self::Element { - self.into() + ViewElement::new(self) } } impl IntoElement for AnyView { - type Element = Self; + type Element = ViewElement; fn into_element(self) -> Self::Element { - self + ViewElement::new(self) } } -/// A weak, dynamically-typed view handle that does not prevent the view from being released. +/// A weak, dynamically-typed view handle. pub struct AnyWeakView { entity: AnyWeakEntity, render: fn(&AnyView, &mut Window, &mut App) -> AnyElement, } impl AnyWeakView { - /// Convert to a strongly-typed handle if the referenced view has not yet been released. + /// Upgrade to a strong `AnyView` handle, if the view is still alive. pub fn upgrade(&self) -> Option { let entity = self.entity.upgrade()?; Some(AnyView { entity, render: self.render, - cached_style: None, }) } } @@ -310,6 +161,335 @@ mod any_view { } } +/// A renderable that participates in GPUI's reactive graph — the unifying model +/// behind [`Render`] and [`RenderOnce`]. +/// +/// When `entity_id()` returns `Some`, that id becomes the view's identity: it gets +/// a unique element-id space (so internal `use_state` / `.id(..)` never collide +/// across siblings) and `cx.notify()` on that entity re-renders only this view's +/// subtree. `None` behaves like a stateless component. +/// +/// You rarely implement `View` directly. `Entity` and any `T: RenderOnce` +/// get a blanket impl below; implement it by hand only when a component needs both +/// parent-supplied props *and* a backing entity for identity. +pub trait View: 'static + Sized { + /// This view's identity, if it has one. A view typically holds the backing + /// entity as a field and returns its [`EntityId`] here. + /// + /// The id becomes this view's [`ElementId`], so two views keyed on the same + /// entity must not be rendered at the same position in the element tree + /// (e.g. as siblings under the same parent): their internal element state + /// (`use_state`, scroll offsets, etc.) would silently collide. Nesting is + /// fine — the id is scoped by the parent path. + fn entity_id(&self) -> Option; + + /// Render this view into an element tree, consuming `self`. + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; +} + +/// A stateless component (`RenderOnce`) is a `View` with no identity. +impl View for T { + fn entity_id(&self) -> Option { + None + } + + #[inline] + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + RenderOnce::render(self, window, cx) + } +} + +/// An entity that renders itself (`Render`) is a `View` keyed on its own id. +impl View for Entity { + fn entity_id(&self) -> Option { + Some(Entity::entity_id(self)) + } + + #[inline] + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + self.update(cx, |this, cx| { + Render::render(this, window, cx).into_any_element() + }) + } +} + +impl Entity { + /// Embed this entity as a cached [`ViewElement`] laid out at `style`. + /// + /// The rendered subtree is reused until the entity is notified (or the + /// cached bounds / text style change). Caching requires a definite size: + /// a cached view is laid out from `style` and is *not* measured from its + /// contents. Use [`ViewElement::new`] (or `.child(entity)`) for the + /// uncached case. + #[track_caller] + pub fn cached(self, style: StyleRefinement) -> ViewElement> { + ViewElement::new(self).cached(style) + } +} + +/// The element type for [`View`] implementations. Wraps a `View` and hooks it +/// into layout, prepaint, and paint. Constructed via [`ViewElement::new`]. +#[doc(hidden)] +pub struct ViewElement { + view: Option, + entity_id: Option, + cached_style: Option, + #[cfg(debug_assertions)] + source: &'static core::panic::Location<'static>, +} + +impl ViewElement { + /// Wrap a [`View`] as an element. + #[track_caller] + pub fn new(view: V) -> Self { + let entity_id = view.entity_id(); + ViewElement { + entity_id, + cached_style: None, + view: Some(view), + #[cfg(debug_assertions)] + source: core::panic::Location::caller(), + } + } + + /// Enable caching of this view's rendered subtree, laid out at `style`. + /// The composer supplies the layout style because caching skips rendering + /// the contents to measure them. + /// + /// Crate-private on purpose: caching is only sound for entity-backed views, + /// where [`Context::notify`] is the contract that busts the cache. A stateless + /// view has no such contract, so a frozen subtree could never be invalidated. + /// Reach this through [`Entity::cached`] or [`AnyView::cached`], which are + /// entity-backed by construction. + pub(crate) fn cached(mut self, style: StyleRefinement) -> Self { + self.cached_style = Some(style); + self + } +} + +impl IntoElement for ViewElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +struct ViewElementState { + prepaint_range: Range, + paint_range: Range, + cache_key: ViewElementCacheKey, + accessed_entities: FxHashSet, +} + +struct ViewElementCacheKey { + bounds: Bounds, + content_mask: ContentMask, + text_style: TextStyle, +} + +impl Element for ViewElement { + type RequestLayoutState = Option; + type PrepaintState = Option; + + fn id(&self) -> Option { + self.entity_id.map(ElementId::View) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + #[cfg(debug_assertions)] + return Some(self.source); + + #[cfg(not(debug_assertions))] + return None; + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + if let Some(entity_id) = self.entity_id { + // Stateful path: create a reactive boundary. + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + match self.cached_style.as_ref() { + Some(style) if !caching_disabled => { + let mut root_style = Style::default(); + root_style.refine(style); + let layout_id = window.request_layout(root_style, None, cx); + (layout_id, None) + } + _ => { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + } + } + }) + } else { + // Stateless path: isolate subtree via type name (no entity identity). + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + }, + ) + } + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + element: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Option { + if let Some(entity_id) = self.entity_id { + // Stateful path. + window.set_view_id(entity_id); + window.with_rendered_view(entity_id, |window| { + if let Some(mut element) = element.take() { + element.prepaint(window, cx); + return Some(element); + } + + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let content_mask = window.content_mask(); + let text_style = window.text_style(); + + if let Some(mut element_state) = element_state + && element_state.cache_key.bounds == bounds + && element_state.cache_key.content_mask == content_mask + && element_state.cache_key.text_style == text_style + && !window.dirty_views.contains(&entity_id) + && !window.refreshing + { + let prepaint_start = window.prepaint_index(); + window.reuse_prepaint(element_state.prepaint_range.clone()); + cx.entities + .extend_accessed(&element_state.accessed_entities); + let prepaint_end = window.prepaint_index(); + element_state.prepaint_range = prepaint_start..prepaint_end; + + return (None, element_state); + } + + let refreshing = mem::replace(&mut window.refreshing, true); + let prepaint_start = window.prepaint_index(); + let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + element.layout_as_root(bounds.size.into(), window, cx); + element.prepaint_at(bounds.origin, window, cx); + element + }); + + let prepaint_end = window.prepaint_index(); + window.refreshing = refreshing; + + ( + Some(element), + ViewElementState { + accessed_entities, + prepaint_range: prepaint_start..prepaint_end, + paint_range: PaintIndex::default()..PaintIndex::default(), + cache_key: ViewElementCacheKey { + bounds, + content_mask, + text_style, + }, + }, + ) + }, + ) + }) + } else { + // Stateless path: just prepaint the element. + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + element.as_mut().unwrap().prepaint(window, cx); + }, + ); + Some(element.take().unwrap()) + } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + element: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(entity_id) = self.entity_id { + // Stateful path. + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + if self.cached_style.is_some() && !caching_disabled { + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let mut element_state = element_state.unwrap(); + + let paint_start = window.paint_index(); + + if let Some(element) = element { + let refreshing = mem::replace(&mut window.refreshing, true); + element.paint(window, cx); + window.refreshing = refreshing; + } else { + window.reuse_paint(element_state.paint_range.clone()); + } + + let paint_end = window.paint_index(); + element_state.paint_range = paint_start..paint_end; + + ((), element_state) + }, + ) + } else { + element.as_mut().unwrap().paint(window, cx); + } + }); + } else { + // Stateless path: just paint the element. + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + element.as_mut().unwrap().paint(window, cx); + }, + ); + } + } +} + /// A view that renders nothing pub struct EmptyView; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f4bd64ecba5..35269a09c84 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -982,6 +982,7 @@ impl Frame { enum InputModality { Mouse, Keyboard, + Touch, } /// Holds the state for a specific window. @@ -2823,8 +2824,16 @@ impl Window { } }; - // Layout all root elements. - let mut root_element = self.root.as_ref().unwrap().clone().into_any(); + // Layout all root elements. Like the root element on the web, which + // stretches to fill the viewport unless explicitly sized, window roots + // fill the window when their size is `auto`. + let scale_factor = self.scale_factor(); + let mut root_element = self.root.as_ref().unwrap().clone().into_any_element(); + let root_layout_id = root_element.request_layout(self, cx); + self.layout_engine + .as_mut() + .unwrap() + .stretch_auto_size_to_fill(root_layout_id, root_size, scale_factor); root_element.prepaint_as_root(Point::default(), root_size.into(), self, cx); #[cfg(any(feature = "inspector", debug_assertions))] @@ -2836,12 +2845,17 @@ impl Window { let mut active_drag_element = None; let mut tooltip_element = None; if let Some(prompt) = self.prompt.take() { - let mut element = prompt.view.any_view().into_any(); + let mut element = prompt.view.any_view().into_any_element(); + let prompt_layout_id = element.request_layout(self, cx); + self.layout_engine + .as_mut() + .unwrap() + .stretch_auto_size_to_fill(prompt_layout_id, root_size, scale_factor); element.prepaint_as_root(Point::default(), root_size.into(), self, cx); prompt_element = Some(element); self.prompt = Some(prompt); } else if let Some(active_drag) = cx.active_drag.take() { - let mut element = active_drag.view.clone().into_any(); + let mut element = active_drag.view.clone().into_any_element(); let offset = self.mouse_position() - active_drag.cursor_offset; element.prepaint_as_root(offset, AvailableSpace::min_size(), self, cx); active_drag_element = Some(element); @@ -2905,7 +2919,7 @@ impl Window { log::error!("Unexpectedly absent TooltipRequest"); continue; }; - let mut element = tooltip_request.tooltip.view.clone().into_any(); + let mut element = tooltip_request.tooltip.view.clone().into_any_element(); let mouse_position = tooltip_request.tooltip.mouse_position; let tooltip_size = element.layout_as_root(AvailableSpace::min_size(), self, cx); @@ -3812,7 +3826,7 @@ impl Window { content_mask: self.snapped_content_mask(), color: style.color.unwrap_or_default().opacity(element_opacity), thickness, - wavy: if style.wavy { 1 } else { 0 }, + wavy: style.wavy.into(), }); } @@ -3842,7 +3856,7 @@ impl Window { content_mask: self.snapped_content_mask(), thickness: self.snap_stroke(style.thickness), color: style.color.unwrap_or_default().opacity(opacity), - wavy: 0, + wavy: false.into(), }); } @@ -4002,7 +4016,7 @@ impl Window { self.next_frame.scene.insert_primitive(PolychromeSprite { order: 0, pad: 0, - grayscale: false, + grayscale: false.into(), bounds, corner_radii: Default::default(), content_mask, @@ -4117,7 +4131,7 @@ impl Window { self.next_frame.scene.insert_primitive(PolychromeSprite { order: 0, pad: 0, - grayscale, + grayscale: grayscale.into(), bounds, content_mask, corner_radii, @@ -4541,6 +4555,7 @@ impl Window { self.last_input_modality = match &event { PlatformInput::KeyDown(_) => InputModality::Keyboard, PlatformInput::MouseMove(_) | PlatformInput::MouseDown(_) => InputModality::Mouse, + PlatformInput::Touch(_) => InputModality::Touch, _ => self.last_input_modality, }; if self.last_input_modality != old_modality { @@ -4634,6 +4649,7 @@ impl Window { PlatformInput::FileDrop(FileDropEvent::Exited) } }, + PlatformInput::Touch(touch) => PlatformInput::Touch(touch), PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event, }; @@ -6312,3 +6328,77 @@ pub fn outline( border_style, } } + +#[cfg(test)] +mod tests { + use crate::{ + AppContext as _, Bounds, Context, IntoElement, ParentElement as _, Pixels, Render, + Styled as _, TestAppContext, Window, canvas, div, px, size, + }; + use std::{cell::Cell, rc::Rc}; + + struct RootView { + explicit_size: bool, + child_bounds: Rc>>, + } + + impl Render for RootView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let child_bounds = self.child_bounds.clone(); + let root = div().flex().flex_col().child( + canvas( + move |bounds, _, _| child_bounds.set(bounds), + |_, _, _, _| {}, + ) + .size_full(), + ); + if self.explicit_size { + root.w(px(300.)).h(px(200.)) + } else { + root + } + } + } + + #[test] + fn auto_sized_window_root_fills_the_window() { + let mut cx = TestAppContext::single(); + let child_bounds = Rc::new(Cell::new(Bounds::default())); + let window = cx.add_window({ + let child_bounds = child_bounds.clone(); + move |_, _| RootView { + explicit_size: false, + child_bounds, + } + }); + + let viewport_size = cx + .update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(); + window.viewport_size() + }) + .unwrap(); + + assert_eq!(child_bounds.get().size, viewport_size); + } + + #[test] + fn explicitly_sized_window_root_keeps_its_size() { + let mut cx = TestAppContext::single(); + let child_bounds = Rc::new(Cell::new(Bounds::default())); + let window = cx.add_window({ + let child_bounds = child_bounds.clone(); + move |_, _| RootView { + explicit_size: true, + child_bounds, + } + }); + + cx.update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + + assert_eq!(child_bounds.get().size, size(px(300.), px(200.))); + } +} diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index a1e0531f3d5..262da8d4dff 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -96,7 +96,7 @@ scap = { workspace = true, optional = true } # Wayland calloop-wayland-source = { version = "0.4.1", optional = true } -wayland-backend = { version = "0.3.3", features = [ +wayland-backend = { version = "0.3.15", features = [ "client_system", "dlopen", ], optional = true } diff --git a/crates/gpui_linux/src/linux/dispatcher.rs b/crates/gpui_linux/src/linux/dispatcher.rs index 2a178521edc..fd9caf89fc1 100644 --- a/crates/gpui_linux/src/linux/dispatcher.rs +++ b/crates/gpui_linux/src/linux/dispatcher.rs @@ -127,9 +127,12 @@ impl PlatformDispatcher for LinuxDispatcher { } fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { - self.timer_sender - .send(TimerAfter { duration, runnable }) - .ok(); + if let Err(err) = self.timer_sender.send(TimerAfter { duration, runnable }) { + // The timer thread has shut down. Dropping a scheduled runnable cancels its task + // and makes the next poll of any awaiter panic. Leaking leaves the task pending, + // which is acceptable during shutdown. + std::mem::forget(err); + } } fn spawn_realtime(&self, f: Box) { diff --git a/crates/gpui_linux/src/linux/wayland.rs b/crates/gpui_linux/src/linux/wayland.rs index 3e90688d1bd..cbc962bcffe 100644 --- a/crates/gpui_linux/src/linux/wayland.rs +++ b/crates/gpui_linux/src/linux/wayland.rs @@ -2,6 +2,7 @@ mod client; mod clipboard; mod cursor; mod display; +mod popup; mod serial; mod window; diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 256875bed1e..a4cc9fde9f1 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -57,7 +57,9 @@ use wayland_protocols::xdg::activation::v1::client::{xdg_activation_token_v1, xd use wayland_protocols::xdg::decoration::zv1::client::{ zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, }; -use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; +use wayland_protocols::xdg::shell::client::{ + xdg_popup, xdg_positioner, xdg_surface, xdg_toplevel, xdg_wm_base, +}; use wayland_protocols::xdg::system_bell::v1::client::xdg_system_bell_v1; use wayland_protocols::{ wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1}, @@ -96,7 +98,7 @@ use gpui::{ ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, - ScrollDelta, ScrollWheelEvent, SharedString, Size, TouchPhase, WindowButtonLayout, + ScrollDelta, ScrollWheelEvent, SharedString, Size, TouchPhase, WindowButtonLayout, WindowKind, WindowParams, point, profiler, px, size, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; @@ -846,7 +848,29 @@ impl LinuxClient for WaylandClient { ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); - let parent = state.keyboard_focused_window.clone(); + // Popups name their parent explicitly. Other kinds are parented to the focused window. + let (parent, popup_grab) = match ¶ms.kind { + WindowKind::AnchoredPopup(options) => { + let parent = state + .windows + .values() + .find(|window| window.handle() == options.parent) + .cloned() + .ok_or_else(|| anyhow::anyhow!("popup parent window not found"))?; + // A popup grab must reference a press event or the compositor declines it and + // immediately dismisses the popup, so use the most recent press serial, or no + // grab before any press. + let popup_grab = options.grab.then(|| { + let serial = state + .serial_tracker + .get(SerialKind::MousePress) + .max(state.serial_tracker.get(SerialKind::KeyPress)); + (serial != 0).then(|| (serial, state.wl_seat.clone())) + }); + (Some(parent), popup_grab.flatten()) + } + _ => (state.keyboard_focused_window.clone(), None), + }; let target_output = params.display_id.and_then(|display_id| { let target_protocol_id: u64 = display_id.into(); @@ -859,6 +883,7 @@ impl LinuxClient for WaylandClient { let appearance = state.common.appearance; let compositor_gpu = state.compositor_gpu.take(); + let (window, surface_id) = WaylandWindow::new( handle, state.globals.clone(), @@ -868,8 +893,10 @@ impl LinuxClient for WaylandClient { params, appearance, parent, + popup_grab, target_output, )?; + if window.0.toplevel().is_some() { state.consume_startup_activation_token(&window.0.surface()); } @@ -1196,6 +1223,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion); delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1); +delegate_noop!(WaylandClientStatePtr: ignore xdg_positioner::XdgPositioner); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager); delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur); @@ -1359,6 +1387,31 @@ impl Dispatch for WaylandCl drop(state); let should_close = window.handle_layersurface_event(event); + if should_close { + // Close logic will be handled in drop_window() + window.close(); + } + } +} + +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + _: &xdg_popup::XdgPopup, + event: ::Event, + surface_id: &ObjectId, + _: &Connection, + _: &QueueHandle, + ) { + let client = this.get_client(); + let mut state = client.borrow_mut(); + let Some(window) = get_window(&mut state, surface_id) else { + return; + }; + + drop(state); + let should_close = window.handle_popup_event(event); + if should_close { // The close logic will be handled in drop_window() window.close(); @@ -1831,8 +1884,9 @@ impl Dispatch for WaylandClientStatePtr { surface_y, .. } => { + let position = point(px(surface_x as f32), px(surface_y as f32)); state.serial_tracker.update(SerialKind::MouseEnter, serial); - state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); + state.mouse_location = Some(position); state.button_pressed = None; if let Some(window) = get_window(&mut state, &surface.id()) { @@ -1855,8 +1909,16 @@ impl Dispatch for WaylandClientStatePtr { ); } } + let modifiers = state.modifiers; drop(state); window.set_hovered(true); + // No Motion follows Enter unless the pointer keeps moving, so synthesize + // a MouseMove to establish hover at the entry position. + window.handle_input(PlatformInput::MouseMove(MouseMoveEvent { + position, + pressed_button: None, + modifiers, + })); } } wl_pointer::Event::Leave { .. } => { @@ -1934,7 +1996,11 @@ impl Dispatch for WaylandClientStatePtr { state: WEnum::Value(button_state), .. } => { - state.serial_tracker.update(SerialKind::MousePress, serial); + // Record presses only. Requests referencing this serial (popup grabs, + // interactive moves) are declined when given a release serial. + if button_state == wl_pointer::ButtonState::Pressed { + state.serial_tracker.update(SerialKind::MousePress, serial); + } let button = linux_button_to_gpui(button); let Some(button) = button else { return }; if state.mouse_focused_window.is_none() { diff --git a/crates/gpui_linux/src/linux/wayland/popup.rs b/crates/gpui_linux/src/linux/wayland/popup.rs new file mode 100644 index 00000000000..4a15e78211b --- /dev/null +++ b/crates/gpui_linux/src/linux/wayland/popup.rs @@ -0,0 +1,38 @@ +pub use gpui::popup::*; + +use wayland_protocols::xdg::shell::client::xdg_positioner; + +pub(crate) fn wayland_anchor(anchor: PopupAnchor) -> xdg_positioner::Anchor { + match anchor { + PopupAnchor::Center => xdg_positioner::Anchor::None, + PopupAnchor::Top => xdg_positioner::Anchor::Top, + PopupAnchor::Bottom => xdg_positioner::Anchor::Bottom, + PopupAnchor::Left => xdg_positioner::Anchor::Left, + PopupAnchor::Right => xdg_positioner::Anchor::Right, + PopupAnchor::TopLeft => xdg_positioner::Anchor::TopLeft, + PopupAnchor::BottomLeft => xdg_positioner::Anchor::BottomLeft, + PopupAnchor::TopRight => xdg_positioner::Anchor::TopRight, + PopupAnchor::BottomRight => xdg_positioner::Anchor::BottomRight, + } +} + +pub(crate) fn wayland_gravity(gravity: PopupGravity) -> xdg_positioner::Gravity { + match gravity { + PopupGravity::Center => xdg_positioner::Gravity::None, + PopupGravity::Top => xdg_positioner::Gravity::Top, + PopupGravity::Bottom => xdg_positioner::Gravity::Bottom, + PopupGravity::Left => xdg_positioner::Gravity::Left, + PopupGravity::Right => xdg_positioner::Gravity::Right, + PopupGravity::TopLeft => xdg_positioner::Gravity::TopLeft, + PopupGravity::BottomLeft => xdg_positioner::Gravity::BottomLeft, + PopupGravity::TopRight => xdg_positioner::Gravity::TopRight, + PopupGravity::BottomRight => xdg_positioner::Gravity::BottomRight, + } +} + +pub(crate) fn wayland_constraint_adjustment( + adjustment: PopupConstraintAdjustment, +) -> xdg_positioner::ConstraintAdjustment { + // The flag values match the protocol bitfield, so the bits map across directly. + xdg_positioner::ConstraintAdjustment::from_bits_truncate(adjustment.bits()) +} diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 731569a025f..675602082bb 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1,12 +1,12 @@ use std::{ - cell::{Ref, RefCell, RefMut}, + cell::{Cell, Ref, RefCell, RefMut}, ffi::c_void, ptr::NonNull, rc::Rc, sync::Arc, }; -use collections::{FxHashSet, HashMap}; +use collections::{FxHashMap, HashMap}; use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; @@ -14,10 +14,12 @@ use wayland_backend::client::ObjectId; use wayland_client::WEnum; use wayland_client::{ Proxy, - protocol::{wl_output, wl_surface}, + protocol::{wl_output, wl_seat, wl_surface}, }; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; +use wayland_protocols::xdg::shell::client::xdg_popup; +use wayland_protocols::xdg::shell::client::xdg_positioner; use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; use wayland_protocols::{ @@ -34,8 +36,8 @@ use gpui::{ PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, - WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px, - size, + WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, + popup::PopupOptions, px, size, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig, wgpu}; @@ -93,7 +95,9 @@ pub struct WaylandWindowState { surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, parent: Option, - children: FxHashSet, + /// Child surfaces mapped to whether they block this window's input (dialogs + /// block, popups don't). Children are closed before this window closes. + children: FxHashMap, pub surface: wl_surface::WlSurface, app_id: Option, appearance: WindowAppearance, @@ -129,6 +133,7 @@ pub struct WaylandWindowState { pub enum WaylandSurfaceState { Xdg(WaylandXdgSurfaceState), LayerShell(WaylandLayerSurfaceState), + Popup(WaylandPopupSurfaceState), } impl WaylandSurfaceState { @@ -137,6 +142,7 @@ impl WaylandSurfaceState { globals: &Globals, params: &WindowParams, parent: Option, + popup_grab: Option<(u32, wl_seat::WlSeat)>, target_output: Option, ) -> anyhow::Result { // For layer_shell windows, create a layer surface instead of an xdg surface @@ -186,6 +192,54 @@ impl WaylandSurfaceState { })); } + if let WindowKind::AnchoredPopup(options) = ¶ms.kind { + let Some(parent) = parent.as_ref() else { + return Err(anyhow::anyhow!("popup parent window not found")); + }; + + let positioner = build_popup_positioner( + globals, + options, + params.bounds.size, + parent.window_geometry(), + ); + + let xdg_surface = globals + .wm_base + .get_xdg_surface(&surface, &globals.qh, surface.id()); + + // A layer-shell parent takes a null xdg parent and is attached via the layer + // surface. Every other surface kind has an xdg_surface to parent to directly. + let xdg_popup = if let Some(parent_layer_surface) = parent.layer_surface() { + let xdg_popup = xdg_surface.get_popup(None, &positioner, &globals.qh, surface.id()); + parent_layer_surface.get_popup(&xdg_popup); + xdg_popup + } else { + xdg_surface.get_popup( + parent.xdg_surface().as_ref(), + &positioner, + &globals.qh, + surface.id(), + ) + }; + positioner.destroy(); + + if let Some((serial, seat)) = popup_grab { + xdg_popup.grab(&seat, serial); + } + + // Non-blocking: the parent keeps its input so it can dismiss the popup on + // clicks in its own window. + parent.add_child(surface.id(), false); + + return Ok(WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_surface, + xdg_popup, + options: options.clone(), + next_reposition_token: Cell::new(0), + })); + } + // All other WindowKinds result in a regular xdg surface let xdg_surface = globals .wm_base @@ -206,7 +260,7 @@ impl WaylandSurfaceState { }); if let Some(parent) = parent.as_ref() { - parent.add_child(surface.id()); + parent.add_child(surface.id(), true); } dialog @@ -246,6 +300,65 @@ pub struct WaylandLayerSurfaceState { layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, } +pub struct WaylandPopupSurfaceState { + xdg_surface: xdg_surface::XdgSurface, + xdg_popup: xdg_popup::XdgPopup, + // Kept so the popup can be re-anchored via `xdg_popup.reposition` when resized. + options: PopupOptions, + next_reposition_token: Cell, +} + +fn build_popup_positioner( + globals: &Globals, + options: &PopupOptions, + size: Size, + parent_geometry: Bounds, +) -> xdg_positioner::XdgPositioner { + let positioner = globals.wm_base.create_positioner(&globals.qh, ()); + // A zero or negative size is a protocol error. + positioner.set_size( + f32::from(size.width).max(1.0) as i32, + f32::from(size.height).max(1.0) as i32, + ); + + // The protocol wants the anchor rect relative to the parent's window geometry, while + // `options.anchor_rect` is in gpui window coordinates (surface-local). A rect extending + // outside the geometry or with a zero size is a protocol error, so translate, then clamp + // to at least one pixel inside the geometry, pulling the origin inward at the edges. + let anchor_rect = Bounds { + origin: options.anchor_rect.origin - parent_geometry.origin, + size: options.anchor_rect.size, + }; + let one = Point::new(px(1.0), px(1.0)); + let geometry_bottom_right: Point = parent_geometry.size.into(); + let top_left = anchor_rect + .origin + .min(&(geometry_bottom_right - one)) + .max(&Point::default()); + let bottom_right = anchor_rect + .bottom_right() + .min(&geometry_bottom_right) + .max(&(top_left + one)); + let anchor_rect = Bounds::from_corners(top_left, bottom_right); + positioner.set_anchor_rect( + f32::from(anchor_rect.origin.x) as i32, + f32::from(anchor_rect.origin.y) as i32, + f32::from(anchor_rect.size.width) as i32, + f32::from(anchor_rect.size.height) as i32, + ); + + positioner.set_anchor(super::popup::wayland_anchor(options.anchor)); + positioner.set_gravity(super::popup::wayland_gravity(options.gravity)); + positioner.set_constraint_adjustment(super::popup::wayland_constraint_adjustment( + options.constraint_adjustment, + )); + positioner.set_offset( + f32::from(options.offset.x) as i32, + f32::from(options.offset.y) as i32, + ); + positioner +} + impl WaylandSurfaceState { fn ack_configure(&self, serial: u32) { match self { @@ -255,6 +368,9 @@ impl WaylandSurfaceState { WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { layer_surface.ack_configure(serial); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + xdg_surface.ack_configure(serial); + } } } @@ -274,6 +390,28 @@ impl WaylandSurfaceState { } } + fn xdg_surface(&self) -> Option<&xdg_surface::XdgSurface> { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { + Some(xdg_surface) + } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + Some(xdg_surface) + } + WaylandSurfaceState::LayerShell(_) => None, + } + } + + fn layer_surface(&self) -> Option<&zwlr_layer_surface_v1::ZwlrLayerSurfaceV1> { + if let WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) = + self + { + Some(layer_surface) + } else { + None + } + } + fn set_geometry(&self, x: i32, y: i32, width: i32, height: i32) { match self { WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { @@ -283,6 +421,34 @@ impl WaylandSurfaceState { // cannot set window position of a layer surface layer_surface.set_size(width as u32, height as u32); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + xdg_surface.set_window_geometry(x, y, width, height); + } + } + } + + // Re-anchors a mapped popup at a new size via `xdg_popup.reposition`. Repositioning an + // unmapped popup (before the first configure) is a protocol error. + fn reposition_popup( + &self, + globals: &Globals, + size: Size, + parent_geometry: Bounds, + ) { + if let WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_popup, + options, + next_reposition_token, + .. + }) = self + && xdg_popup.version() >= xdg_popup::REQ_REPOSITION_SINCE + { + let token = next_reposition_token.get(); + next_reposition_token.set(token.wrapping_add(1)); + + let positioner = build_popup_positioner(globals, options, size, parent_geometry); + xdg_popup.reposition(&positioner, token); + positioner.destroy(); } } @@ -307,6 +473,15 @@ impl WaylandSurfaceState { WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => { layer_surface.destroy(); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_surface, + xdg_popup, + .. + }) => { + // Role object before its xdg_surface, as with the toplevel above. + xdg_popup.destroy(); + xdg_surface.destroy(); + } } } } @@ -374,7 +549,7 @@ impl WaylandWindowState { surface_state, acknowledged_first_configure: false, parent, - children: FxHashSet::default(), + children: FxHashMap::default(), surface, app_id: options.app_id, blur: None, @@ -523,11 +698,18 @@ impl WaylandWindow { params: WindowParams, appearance: WindowAppearance, parent: Option, + popup_grab: Option<(u32, wl_seat::WlSeat)>, target_output: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let surface_state = - WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone(), target_output)?; + let surface_state = WaylandSurfaceState::new( + &surface, + &globals, + ¶ms, + parent.clone(), + popup_grab, + target_output, + )?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); @@ -575,18 +757,39 @@ impl WaylandWindowStatePtr { self.state.borrow().surface_state.toplevel().cloned() } + /// The `xdg_surface` backing this window, if it has one. Used to anchor child popups. + pub fn xdg_surface(&self) -> Option { + self.state.borrow().surface_state.xdg_surface().cloned() + } + + /// The layer-shell surface backing this window, if it is one. Used to anchor child popups. + pub fn layer_surface(&self) -> Option { + self.state.borrow().surface_state.layer_surface().cloned() + } + + /// This window's xdg window geometry in surface-local coordinates. Child popup anchor + /// rectangles are relative to it, while gpui coordinates are surface-local. + pub fn window_geometry(&self) -> Bounds { + let state = self.state.borrow(); + inset_by_tiling( + state.bounds.map_origin(|_| px(0.0)), + state.inset(), + state.tiling, + ) + } + pub fn ptr_eq(&self, other: &Self) -> bool { Rc::ptr_eq(&self.state, &other.state) } - pub fn add_child(&self, child: ObjectId) { + pub fn add_child(&self, child: ObjectId, blocking: bool) { let mut state = self.state.borrow_mut(); - state.children.insert(child); + state.children.insert(child, blocking); } pub fn is_blocked(&self) -> bool { let state = self.state.borrow(); - !state.children.is_empty() + state.children.values().any(|&blocking| blocking) } pub fn frame(&self) { @@ -889,6 +1092,35 @@ impl WaylandWindowStatePtr { } } + // Returns `true` if the popup should be closed. + pub fn handle_popup_event(&self, event: xdg_popup::Event) -> bool { + match event { + // Only the size is needed, the position is the compositor's. The following + // xdg_surface.configure applies the change. + xdg_popup::Event::Configure { width, height, .. } => { + let size = if width <= 0 || height <= 0 { + None + } else { + Some(size(px(width as f32), px(height as f32))) + }; + + self.state.borrow_mut().in_progress_configure = Some(InProgressConfigure { + size, + fullscreen: false, + maximized: false, + resizing: false, + tiling: Tiling::default(), + }); + + false + } + xdg_popup::Event::PopupDone => true, + // Precedes the reposition's Configure, which does the work. The token is not needed. + xdg_popup::Event::Repositioned { .. } => false, + _ => false, + } + } + #[allow(clippy::mutable_key_type)] pub fn handle_surface_event( &self, @@ -1025,8 +1257,7 @@ impl WaylandWindowStatePtr { pub fn close(&self) { let state = self.state.borrow(); let client = state.client.get_client(); - #[allow(clippy::mutable_key_type)] - let children = state.children.clone(); + let children = state.children.keys().cloned().collect::>(); drop(state); for child in children { @@ -1192,6 +1423,23 @@ impl PlatformWindow for WaylandWindow { let state = self.borrow(); let state_ptr = self.0.clone(); + // A popup's placement is the compositor's, so a resize re-runs the positioner and the + // configure reply drives the buffer resize. Before the first configure the popup is + // unmapped and cannot reposition, but the initial positioner already carries the size. + if matches!(state.surface_state, WaylandSurfaceState::Popup(_)) { + if state.acknowledged_first_configure { + let parent_geometry = state + .parent + .as_ref() + .map(|parent| parent.window_geometry()) + .unwrap_or_default(); + state + .surface_state + .reposition_popup(&state.globals, size, parent_geometry); + } + return; + } + // Keep window geometry consistent with configure handling. On Wayland, window geometry is // surface-local: resizing should not attempt to translate the window; the compositor // controls placement. We also account for client-side decoration insets and tiling. diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index d2edb72408b..a459ed03b5d 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -7,7 +7,7 @@ use gpui::{ Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowDecorations, WindowKind, WindowParams, px, + WindowDecorations, WindowKind, WindowParams, popup::PopupNotSupportedError, px, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; @@ -428,6 +428,12 @@ impl X11WindowState { supports_xinput_gestures: bool, is_bgr: bool, ) -> anyhow::Result { + // Native popups are not implemented on X11 yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(PopupNotSupportedError.into()); + } + let x_screen_index = params .display_id .map_or(x_main_screen_index, |did| u64::from(did) as usize); diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 8266cade4d2..eed9e90bcd3 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -31,7 +31,8 @@ use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowParams, + PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowKind, + WindowParams, popup::PopupNotSupportedError, }; use gpui_util::{ResultExt, new_std_command}; use itertools::Itertools; @@ -640,6 +641,12 @@ impl Platform for MacPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> Result> { + // Native popups are not implemented on macOS yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = options.kind { + return Err(PopupNotSupportedError.into()); + } + let (cursor_visible, foreground_executor, background_executor, renderer_context) = { let guard = self.0.lock(); ( diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index d3d87608253..6221b141610 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -795,7 +795,9 @@ impl MacWindow { WindowKind::Normal => { msg_send![WINDOW_CLASS, alloc] } - WindowKind::PopUp => { + // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only + // for exhaustiveness. + WindowKind::PopUp | WindowKind::AnchoredPopup(_) => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] } @@ -988,7 +990,9 @@ impl MacWindow { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; } } - WindowKind::PopUp => { + // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only + // for exhaustiveness. + WindowKind::PopUp | WindowKind::AnchoredPopup(_) => { // Use a tracking area to allow receiving MouseMoved events even when // the window or application aren't active, which is often the case // e.g. for notification windows. diff --git a/crates/gpui_macros/src/derive_into_element.rs b/crates/gpui_macros/src/derive_into_element.rs index 89d609ae65d..51d2a8ab3f7 100644 --- a/crates/gpui_macros/src/derive_into_element.rs +++ b/crates/gpui_macros/src/derive_into_element.rs @@ -11,11 +11,11 @@ pub fn derive_into_element(input: TokenStream) -> TokenStream { impl #impl_generics gpui::IntoElement for #type_name #type_generics #where_clause { - type Element = gpui::Component; + type Element = gpui::ViewElement; #[track_caller] fn into_element(self) -> Self::Element { - gpui::Component::new(self) + gpui::ViewElement::new(self) } } }; diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index f3958bca568..50305af752c 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -29,8 +29,8 @@ pub fn register_action(ident: TokenStream) -> TokenStream { register_action::register_action(ident) } -/// #[derive(IntoElement)] is used to create a Component out of anything that implements -/// the `RenderOnce` trait. +/// #[derive(IntoElement)] generates an `IntoElement` impl for any `RenderOnce` +/// type, wrapping it in a `ViewElement` so it can be used as a child. #[proc_macro_derive(IntoElement)] pub fn derive_into_element(input: TokenStream) -> TokenStream { derive_into_element::derive_into_element(input) diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index fecc39f368d..c39723ea5b2 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -8,7 +8,7 @@ use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, - ThermalState, WindowAppearance, WindowParams, + ThermalState, WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError, }; use gpui_wgpu::WgpuContext; use std::{ @@ -166,6 +166,12 @@ impl Platform for WebPlatform { handle: AnyWindowHandle, params: WindowParams, ) -> anyhow::Result> { + // Native popups are not implemented on the web yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(PopupNotSupportedError.into()); + } + 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?") diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index 933b88e84d7..747a34d81e0 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -1191,7 +1191,7 @@ fn fs_underline(input: UnderlineVarying) -> @location(0) vec4 { } let underline = b_underlines[input.underline_id]; - if ((underline.wavy & 0xFFu) == 0u) + if (underline.wavy == 0u) { return blend_color(input.color, input.color.a); } @@ -1305,7 +1305,7 @@ fn fs_poly_sprite(input: PolySpriteVarying) -> @location(0) vec4 { let distance = quad_sdf(input.position.xy, sprite.bounds, sprite.corner_radii); var color = sample; - if ((sprite.grayscale & 0xFFu) != 0u) { + if (sprite.grayscale != 0u) { let grayscale = dot(color.rgb, GRAYSCALE_FACTORS); color = vec4(vec3(grayscale), sample.a); } diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index d1dd5685b07..ba492515edb 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -1,4 +1,6 @@ use std::{ + ffi::c_void, + ptr::NonNull, sync::atomic::{AtomicBool, Ordering}, thread::{ThreadId, current}, time::Duration, @@ -6,16 +8,17 @@ use std::{ use anyhow::Context; use gpui_util::ResultExt; -use windows::{ +use windows::Win32::{ + Foundation::{FILETIME, LPARAM, WPARAM}, + Media::{timeBeginPeriod, timeEndPeriod}, System::Threading::{ - ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority, - }, - Win32::{ - Foundation::{LPARAM, WPARAM}, - Media::{timeBeginPeriod, timeEndPeriod}, - System::Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}, - UI::WindowsAndMessaging::PostMessageW, + CloseThreadpoolTimer, CloseThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWork, + GetCurrentThread, PTP_CALLBACK_INSTANCE, PTP_TIMER, PTP_WORK, SetThreadPriority, + SetThreadpoolTimer, SubmitThreadpoolWork, THREAD_PRIORITY_TIME_CRITICAL, + TP_CALLBACK_ENVIRON_V3, TP_CALLBACK_PRIORITY, TP_CALLBACK_PRIORITY_HIGH, + TP_CALLBACK_PRIORITY_LOW, TP_CALLBACK_PRIORITY_NORMAL, }, + UI::WindowsAndMessaging::PostMessageW, }; use crate::{HWND, SafeHwnd, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD}; @@ -49,29 +52,44 @@ impl WindowsDispatcher { } } - fn dispatch_on_threadpool(&self, priority: WorkItemPriority, runnable: RunnableVariant) { - let handler = { - let mut task_wrapper = Some(runnable); - WorkItemHandler::new(move |_| { - let runnable = task_wrapper.take().unwrap(); - Self::execute_runnable(runnable); - Ok(()) - }) + fn dispatch_on_threadpool(&self, priority: TP_CALLBACK_PRIORITY, runnable: RunnableVariant) { + let environ = TP_CALLBACK_ENVIRON_V3 { + Version: 3, + CallbackPriority: priority, + Size: size_of::() as u32, + ..Default::default() }; - ThreadPool::RunWithPriorityAsync(&handler, priority).log_err(); + // If the thread pool never runs our callback, the matching `from_raw` is never called, which leaks the runnable. + // Dropping the scheduled runnable would cancel its task and make the next poll of any awaiter panic. Since we expect + // the scenario to usually happen during shutdown, this leak is acceptable. + let context = runnable.into_raw().as_ptr() as *mut c_void; + + unsafe { + if let Ok(work) = + CreateThreadpoolWork(Some(run_work_callback), Some(context), Some(&environ)) + { + SubmitThreadpoolWork(work); + } + } } fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) { - let handler = { - let mut task_wrapper = Some(runnable); - TimerElapsedHandler::new(move |_| { - let runnable = task_wrapper.take().unwrap(); - Self::execute_runnable(runnable); - Ok(()) - }) - }; - ThreadPoolTimer::CreateTimer(&handler, duration.into()).log_err(); + let context = runnable.into_raw().as_ptr() as *mut c_void; + + unsafe { + if let Ok(timer) = CreateThreadpoolTimer(Some(run_timer_callback), Some(context), None) + { + // Negative FILETIME expresses a relative delay in 100ns ticks + let ticks = (duration.as_nanos() / 100).min(i64::MAX as u128) as i64; + let due = (-ticks) as u64; + let due_time = FILETIME { + dwLowDateTime: due as u32, + dwHighDateTime: (due >> 32) as u32, + }; + SetThreadpoolTimer(timer, Some(&due_time), 0, None); + } + } } #[inline(always)] @@ -94,9 +112,9 @@ impl PlatformDispatcher for WindowsDispatcher { Priority::RealtimeAudio => { panic!("RealtimeAudio priority should use spawn_realtime, not dispatch") } - Priority::High => WorkItemPriority::High, - Priority::Medium => WorkItemPriority::Normal, - Priority::Low => WorkItemPriority::Low, + Priority::High => TP_CALLBACK_PRIORITY_HIGH, + Priority::Medium => TP_CALLBACK_PRIORITY_NORMAL, + Priority::Low => TP_CALLBACK_PRIORITY_LOW, }; self.dispatch_on_threadpool(priority, runnable); } @@ -157,3 +175,23 @@ impl PlatformDispatcher for WindowsDispatcher { })) } } + +unsafe extern "system" fn run_work_callback( + _instance: PTP_CALLBACK_INSTANCE, + context: *mut c_void, + work: PTP_WORK, +) { + let runnable = unsafe { RunnableVariant::from_raw(NonNull::new_unchecked(context as *mut ())) }; + WindowsDispatcher::execute_runnable(runnable); + unsafe { CloseThreadpoolWork(work) }; +} + +unsafe extern "system" fn run_timer_callback( + _instance: PTP_CALLBACK_INSTANCE, + context: *mut c_void, + timer: PTP_TIMER, +) { + let runnable = unsafe { RunnableVariant::from_raw(NonNull::new_unchecked(context as *mut ())) }; + WindowsDispatcher::execute_runnable(runnable); + unsafe { CloseThreadpoolTimer(timer) }; +} diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index 89c12489b24..483f0399385 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -1249,7 +1249,7 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe float distance = quad_sdf(input.position.xy, sprite.bounds, sprite.corner_radii); float4 color = sample; - if ((sprite.grayscale & 0xFFu) != 0u) { + if (sprite.grayscale != 0u) { float3 grayscale = dot(color.rgb, GRAYSCALE_FACTORS); color = float4(grayscale, sample.a); } diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 547ee7a9dde..5a4129b493c 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -411,6 +411,12 @@ impl WindowsWindow { params: WindowParams, creation_info: WindowCreationInfo, ) -> Result { + // Native popups are not implemented on Windows yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(popup::PopupNotSupportedError.into()); + } + let WindowCreationInfo { icon, executor, @@ -1539,8 +1545,12 @@ fn set_window_composition_attribute(hwnd: HWND, color: Option, state: u32 .log_err() { let func_name = PCSTR::from_raw(c"SetWindowCompositionAttribute".as_ptr() as *const u8); + let Some(raw_set_window_composition_attribute) = GetProcAddress(user32, func_name) + else { + return; + }; let set_window_composition_attribute: SetWindowCompositionAttributeType = - std::mem::transmute(GetProcAddress(user32, func_name)); + std::mem::transmute(raw_set_window_composition_attribute); let mut color = color.unwrap_or_default(); let is_acrylic = state == 4; if is_acrylic && color.3 == 0 { diff --git a/crates/grammars/src/javascript/injections.scm b/crates/grammars/src/javascript/injections.scm index 8ccfc5028de..9d07511f952 100644 --- a/crates/grammars/src/javascript/injections.scm +++ b/crates/grammars/src/javascript/injections.scm @@ -142,3 +142,25 @@ ]) (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") (#set! injection.language "css")) + +; '/* glsl */' or '/*glsl*/' +(((comment) @_ecma_comment + [ + (string + (string_fragment) @injection.content) + (template_string + (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*glsl\\s*\\*\\/") + (#set! injection.language "glsl")) + +; '/* wgsl */' or '/*wgsl*/' +(((comment) @_ecma_comment + [ + (string + (string_fragment) @injection.content) + (template_string + (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*wgsl\\s*\\*\\/") + (#set! injection.language "WGSL/WESL")) diff --git a/crates/grammars/src/typescript/injections.scm b/crates/grammars/src/typescript/injections.scm index a8cf9a41b5f..19ffe697918 100644 --- a/crates/grammars/src/typescript/injections.scm +++ b/crates/grammars/src/typescript/injections.scm @@ -197,3 +197,25 @@ ]) (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") (#set! injection.language "css")) + +; '/* glsl */' or '/*glsl*/' +(((comment) @_ecma_comment + [ + (string + (string_fragment) @injection.content) + (template_string + (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*glsl\\s*\\*\\/") + (#set! injection.language "glsl")) + +; '/* wgsl */' or '/*wgsl*/' +(((comment) @_ecma_comment + [ + (string + (string_fragment) @injection.content) + (template_string + (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*wgsl\\s*\\*\\/") + (#set! injection.language "WGSL/WESL")) diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index 26ef0867db5..991046eab08 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -5,7 +5,7 @@ mod role; pub mod tool_schema; pub mod util; -use anyhow::{Result, anyhow}; +use anyhow::{Context as _, Result, anyhow}; use cloud_llm_client::CompletionRequestStatus; use http_client::{StatusCode, http}; use schemars::JsonSchema; @@ -351,13 +351,101 @@ pub struct LanguageModelToolUse { pub id: LanguageModelToolUseId, pub name: Arc, pub raw_input: String, - pub input: serde_json::Value, + pub input: LanguageModelToolUseInput, pub is_input_complete: bool, /// Thought signature the model sent us. Some models require that this /// signature be preserved and sent back in conversation history for validation. pub thought_signature: Option, } +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub enum LanguageModelToolUseInput { + Json(serde_json::Value), + Text(String), +} + +impl Serialize for LanguageModelToolUseInput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let mut state = serializer.serialize_struct("LanguageModelToolUseInput", 2)?; + match self { + Self::Json(input) => { + state.serialize_field("type", "json")?; + state.serialize_field("value", input)?; + } + Self::Text(input) => { + state.serialize_field("type", "text")?; + state.serialize_field("value", input)?; + } + } + state.end() + } +} + +impl<'de> Deserialize<'de> for LanguageModelToolUseInput { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + if let Some(object) = value.as_object() + && object.len() == 2 + && let Some(input_type) = object.get("type").and_then(|value| value.as_str()) + && let Some(input) = object.get("value") + { + return match input_type { + "json" => Ok(Self::Json(input.clone())), + "text" => input + .as_str() + .map(|input| Self::Text(input.to_string())) + .ok_or_else(|| serde::de::Error::custom("text tool input must be a string")), + _ => Ok(Self::Json(value)), + }; + } + + Ok(Self::Json(value)) + } +} + +impl LanguageModelToolUseInput { + pub fn as_json(&self) -> Option<&serde_json::Value> { + match self { + Self::Json(input) => Some(input), + Self::Text(_) => None, + } + } + + /// Typed parsing for JSON tool inputs; freeform (Text) inputs always error. + /// + /// Callers wanting the raw value should use [`Self::as_json`] or [`Self::into_json`]. + pub fn parse(&self) -> Result { + match self { + Self::Json(input) => { + serde_json::from_value(input.clone()).context("failed to parse JSON tool input") + } + Self::Text(_) => Err(anyhow!("custom tool text input cannot be parsed as JSON")), + } + } + + pub fn into_json(self) -> Result { + match self { + Self::Json(input) => Ok(input), + Self::Text(_) => Err(anyhow!("custom tool text input cannot be used as JSON")), + } + } + + pub fn to_display_json(&self) -> serde_json::Value { + match self { + Self::Json(input) => input.clone(), + Self::Text(input) => serde_json::Value::String(input.clone()), + } + } +} + #[derive(Debug, Clone)] pub struct LanguageModelEffortLevel { pub name: SharedString, @@ -624,7 +712,7 @@ mod tests { id: LanguageModelToolUseId::from("test_id"), name: "test_tool".into(), raw_input: json!({"arg": "value"}).to_string(), - input: json!({"arg": "value"}), + input: LanguageModelToolUseInput::Json(json!({"arg": "value"})), is_input_complete: true, thought_signature: Some("test_signature".to_string()), }; @@ -652,9 +740,97 @@ mod tests { assert_eq!(tool_use.id, LanguageModelToolUseId::from("test_id")); assert_eq!(tool_use.name.as_ref(), "test_tool"); + assert_eq!( + tool_use.input, + LanguageModelToolUseInput::Json(json!({"arg": "value"})) + ); assert_eq!(tool_use.thought_signature, None); } + #[test] + fn test_language_model_tool_use_input_round_trips_json() { + use serde_json::json; + + let input = LanguageModelToolUseInput::Json(json!({"arg": "value"})); + let serialized = serde_json::to_value(&input).unwrap(); + assert_eq!( + serialized, + json!({ + "type": "json", + "value": {"arg": "value"} + }) + ); + + let deserialized: LanguageModelToolUseInput = serde_json::from_value(serialized).unwrap(); + assert_eq!(deserialized, input); + } + + #[test] + fn test_language_model_tool_use_input_round_trips_text() { + use serde_json::json; + + let input = LanguageModelToolUseInput::Text("raw custom input".to_string()); + let serialized = serde_json::to_value(&input).unwrap(); + assert_eq!( + serialized, + json!({ + "type": "text", + "value": "raw custom input" + }) + ); + + let deserialized: LanguageModelToolUseInput = serde_json::from_value(serialized).unwrap(); + assert_eq!(deserialized, input); + } + + #[test] + fn test_language_model_tool_use_input_parse() { + use serde_json::json; + + #[derive(Debug, Deserialize, PartialEq)] + struct TestInput { + arg: String, + } + + let parsed: TestInput = LanguageModelToolUseInput::Json(json!({"arg": "value"})) + .parse() + .unwrap(); + assert_eq!( + parsed, + TestInput { + arg: "value".to_string() + } + ); + + let error = LanguageModelToolUseInput::Text("raw custom input".to_string()) + .parse::() + .unwrap_err(); + assert!( + error + .to_string() + .contains("custom tool text input cannot be parsed as JSON") + ); + } + + #[test] + fn test_language_model_tool_use_input_deserializes_legacy_plain_json_as_json() { + use serde_json::json; + + let deserialized: LanguageModelToolUseInput = + serde_json::from_value(json!({"arg": "value"})).unwrap(); + assert_eq!( + deserialized, + LanguageModelToolUseInput::Json(json!({"arg": "value"})) + ); + + let deserialized: LanguageModelToolUseInput = + serde_json::from_value(json!("legacy string argument")).unwrap(); + assert_eq!( + deserialized, + LanguageModelToolUseInput::Json(json!("legacy string argument")) + ); + } + #[test] fn test_language_model_tool_use_round_trip_with_signature() { use serde_json::json; @@ -663,7 +839,7 @@ mod tests { id: LanguageModelToolUseId::from("round_trip_id"), name: "round_trip_tool".into(), raw_input: json!({"key": "value"}).to_string(), - input: json!({"key": "value"}), + input: LanguageModelToolUseInput::Json(json!({"key": "value"})), is_input_complete: true, thought_signature: Some("round_trip_sig".to_string()), }; @@ -684,7 +860,7 @@ mod tests { id: LanguageModelToolUseId::from("no_sig_id"), name: "no_sig_tool".into(), raw_input: json!({"arg": "value"}).to_string(), - input: json!({"arg": "value"}), + input: LanguageModelToolUseInput::Json(json!({"arg": "value"})), is_input_complete: true, thought_signature: None, }; diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index 61696f4158a..a661874b0eb 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use crate::role::Role; -use crate::{LanguageModelToolUse, LanguageModelToolUseId, SharedString}; +use crate::{ + LanguageModelToolUse, LanguageModelToolUseId, LanguageModelToolUseInput, SharedString, +}; /// Dimensions of a `LanguageModelImage` #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -346,8 +348,52 @@ impl LanguageModelRequestMessage { pub struct LanguageModelRequestTool { pub name: String, pub description: String, - pub input_schema: serde_json::Value, - pub use_input_streaming: bool, + pub input: LanguageModelRequestToolInput, +} + +impl LanguageModelRequestTool { + pub fn function( + name: String, + description: String, + input_schema: serde_json::Value, + use_input_streaming: bool, + ) -> Self { + Self { + name, + description, + input: LanguageModelRequestToolInput::Function { + input_schema, + use_input_streaming, + }, + } + } +} + +#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)] +pub enum LanguageModelRequestToolInput { + Function { + input_schema: serde_json::Value, + use_input_streaming: bool, + }, + Custom { + format: Option, + }, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +pub enum LanguageModelCustomToolFormat { + Text, + Grammar { + syntax: LanguageModelCustomToolGrammarSyntax, + definition: String, + }, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LanguageModelCustomToolGrammarSyntax { + Lark, + Regex, } #[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)] @@ -389,6 +435,25 @@ pub struct LanguageModelRequest { pub compact_at_tokens: Option, } +impl LanguageModelRequest { + pub fn contains_custom_tool_input(&self) -> bool { + self.tools + .iter() + .any(|tool| matches!(tool.input, LanguageModelRequestToolInput::Custom { .. })) + || self.messages.iter().any(|message| { + message.content.iter().any(|content| { + matches!( + content, + MessageContent::ToolUse(LanguageModelToolUse { + input: LanguageModelToolUseInput::Text(_), + .. + }) + ) + }) + }) + } +} + #[derive( Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema, )] diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 19ba2b45678..8a055257706 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -333,6 +333,17 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic:: AnthropicModelMode::Thinking { .. } | AnthropicModelMode::AdaptiveThinking ); let supports_adaptive_thinking = matches!(mode, AnthropicModelMode::AdaptiveThinking); + let supports_speed = available + .supports_fast_mode + .unwrap_or_else(|| anthropic::supports_fast_mode(&available.name)); + let mut extra_beta_headers = available.extra_beta_headers.clone(); + if supports_speed + && !extra_beta_headers + .iter() + .any(|header| header.trim() == anthropic::FAST_MODE_BETA_HEADER) + { + extra_beta_headers.push(anthropic::FAST_MODE_BETA_HEADER.to_string()); + } anthropic::Model { display_name: available @@ -347,7 +358,7 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic:: supports_thinking, supports_adaptive_thinking, supports_images: true, - supports_speed: false, + supports_speed, supports_compaction: false, supported_effort_levels: if supports_adaptive_thinking { vec![ @@ -361,7 +372,7 @@ fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic:: vec![] }, tool_override: available.tool_override.clone(), - extra_beta_headers: available.extra_beta_headers.clone(), + extra_beta_headers, } } @@ -528,14 +539,17 @@ impl LanguageModel for AnthropicModel { > { let has_tools = !request.tools.is_empty(); let request_id = self.model.request_id(has_tools).to_string(); - let mut request = into_anthropic( + let mut request = match into_anthropic( request, request_id, self.model.default_temperature, self.model.max_output_tokens, self.model.mode.clone(), AnthropicPromptCacheMode::Automatic, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; if !self.model.supports_speed { request.speed = None; } diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index 8a3ddd85da4..633494e3a78 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -333,14 +333,17 @@ impl LanguageModel for AnthropicCompatibleLanguageModel { > { let has_tools = !request.tools.is_empty(); let request_id = self.model.request_id(has_tools).to_string(); - let mut request = into_anthropic( + let mut request = match into_anthropic( request, request_id, self.model.default_temperature, self.model.max_output_tokens, self.model.mode.clone(), self.cache_mode, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; if !self.model.supports_speed { request.speed = None; } diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index fe12a5eb3a1..f0902dd9cbc 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -937,6 +937,13 @@ impl LanguageModel for BedrockModel { LanguageModelCompletionError, >, > { + if request.contains_custom_tool_input() { + return async move { + Err(anyhow::anyhow!("Bedrock does not support custom tools").into()) + } + .boxed(); + } + let (region, allow_global, guardrail_identifier, guardrail_version) = cx.read_entity(&self.state, |state, _cx| { let (gid, gv) = state.get_guardrail_config(); @@ -1477,7 +1484,7 @@ impl LanguageModel for BedrockMantleModel { } MantleProtocol::ChatCompletions => { let reasoning_effort = mantle_selected_reasoning_effort(&request, &self.model); - let request = into_open_ai( + let request = match into_open_ai( request, &model_id, self.model.supports_tools(), @@ -1486,7 +1493,10 @@ impl LanguageModel for BedrockMantleModel { ChatCompletionMaxTokensParameter::MaxCompletionTokens, reasoning_effort, false, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = OpenAiEventMapper::new(); @@ -1526,6 +1536,10 @@ pub fn into_bedrock( guardrail_identifier: Option, guardrail_version: Option, ) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("Bedrock does not support custom tools"); + } + let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); @@ -1588,12 +1602,19 @@ pub fn into_bedrock( } MessageContent::ToolUse(tool_use) => { messages_contain_tool_content = true; - let input = if tool_use.input.is_null() { - // Bedrock API requires valid JsonValue, not null, for tool use input - value_to_aws_document(&serde_json::json!({})) - } else { - value_to_aws_document(&tool_use.input) - }; + let input = + if let language_model::LanguageModelToolUseInput::Json(input) = + &tool_use.input + { + if input.is_null() { + // Bedrock API requires valid JsonValue, not null, for tool use input + value_to_aws_document(&serde_json::json!({})) + } else { + value_to_aws_document(input) + } + } else { + value_to_aws_document(&serde_json::json!({})) + }; BedrockToolUseBlock::builder() .name(tool_use.name.to_string()) .tool_use_id(tool_use.id.to_string()) @@ -1727,19 +1748,25 @@ pub fn into_bedrock( request .tools .iter() - .filter_map(|tool| { - Some(BedrockTool::ToolSpec( + .map(|tool| { + let language_model::LanguageModelRequestToolInput::Function { + input_schema, .. + } = &tool.input + else { + anyhow::bail!("Bedrock does not support custom tools"); + }; + Ok(BedrockTool::ToolSpec( BedrockToolSpec::builder() .name(tool.name.clone()) .description(tool.description.clone()) .input_schema(BedrockToolInputSchema::Json(value_to_aws_document( - &tool.input_schema, + input_schema, ))) .build() - .log_err()?, + .context("failed to build Bedrock tool spec")?, )) }) - .collect() + .collect::>()? } else { Vec::new() }; @@ -1894,7 +1921,10 @@ pub fn map_to_language_model_completion_events( name: tool_use.name.clone().into(), is_input_complete: false, raw_input: tool_use.input_json.clone(), - input, + input: + language_model::LanguageModelToolUseInput::Json( + input, + ), thought_signature: None, }, ))) @@ -1959,7 +1989,9 @@ pub fn map_to_language_model_completion_events( name: tool_use.name.into(), is_input_complete: true, raw_input: tool_use.input_json, - input, + input: language_model::LanguageModelToolUseInput::Json( + input, + ), thought_signature: None, }, )) diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index db845e85796..4074fab35c2 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -368,7 +368,7 @@ impl LanguageModel for CopilotChatLanguageModel { AnthropicModelMode::Default }, AnthropicPromptCacheMode::Legacy, - ); + )?; anthropic_request.temperature = None; @@ -423,7 +423,10 @@ impl LanguageModel for CopilotChatLanguageModel { if self.model.supports_response() { let location = intent_to_chat_location(request.intent); - let responses_request = into_copilot_responses(&self.model, request); + let responses_request = match into_copilot_responses(&self.model, request) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { let request = CopilotChat::stream_response( @@ -567,7 +570,9 @@ pub fn map_to_language_model_completion_events( id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: language_model::LanguageModelToolUseInput::Json( + input, + ), raw_input: entry.arguments.clone(), thought_signature: entry.thought_signature.clone(), }, @@ -629,7 +634,10 @@ pub fn map_to_language_model_completion_events( id: tool_call.id.into(), name: tool_call.name.as_str().into(), is_input_complete: true, - input, + input: + language_model::LanguageModelToolUseInput::Json( + input, + ), raw_input: tool_call.arguments, thought_signature: tool_call.thought_signature, }, @@ -734,7 +742,7 @@ impl CopilotResponsesEventMapper { id: call_id.into(), name: name.as_str().into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: arguments.clone(), thought_signature, }, @@ -1054,12 +1062,15 @@ fn into_copilot_chat( let mut tool_calls = Vec::new(); for content in &message.content { if let MessageContent::ToolUse(tool_use) = content { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow!("Copilot Chat does not support custom tool calls") + })?; tool_calls.push(ToolCall { id: tool_use.id.to_string(), content: ToolCallContent::Function { function: FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input)?, + arguments: serde_json::to_string(input)?, thought_signature: tool_use.thought_signature.clone(), }, }, @@ -1120,14 +1131,21 @@ fn into_copilot_chat( let tools = request .tools .iter() - .map(|tool| Tool::Function { - function: Function { - name: tool.name.clone(), - description: tool.description.clone(), - parameters: tool.input_schema.clone(), - }, + .map(|tool| match &tool.input { + language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => { + Ok(Tool::Function { + function: Function { + name: tool.name.clone(), + description: tool.description.clone(), + parameters: input_schema.clone(), + }, + }) + } + language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!( + "Copilot Chat does not support custom tools" + )), }) - .collect::>(); + .collect::>>()?; Ok(CopilotChatRequest { n: 1, @@ -1188,7 +1206,7 @@ fn intent_to_chat_location(intent: Option) -> ChatLocation { fn into_copilot_responses( model: &CopilotChatModel, request: LanguageModelRequest, -) -> copilot_responses::Request { +) -> Result { use copilot_responses as responses; let LanguageModelRequest { @@ -1356,13 +1374,20 @@ fn into_copilot_responses( let converted_tools: Vec = tools .into_iter() - .map(|tool| responses::ToolDefinition::Function { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - strict: None, + .map(|tool| match tool.input { + language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => { + Ok(responses::ToolDefinition::Function { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + strict: None, + }) + } + language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!( + "Copilot Chat does not support custom tools" + )), }) - .collect(); + .collect::>()?; let mapped_tool_choice = tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => responses::ToolChoice::Auto, @@ -1370,7 +1395,7 @@ fn into_copilot_responses( LanguageModelToolChoice::None => responses::ToolChoice::None, }); - responses::Request { + Ok(responses::Request { model: model.id().to_string(), input: input_items, stream: model.uses_streaming(), @@ -1393,7 +1418,7 @@ fn into_copilot_responses( copilot_responses::ResponseIncludable::ReasoningEncryptedContent, ]), store: false, - } + }) } #[cfg(test)] @@ -1671,7 +1696,7 @@ mod tests { ..Default::default() }; - let serialized = serde_json::to_value(into_copilot_responses(&model, request)) + let serialized = serde_json::to_value(into_copilot_responses(&model, request).unwrap()) .expect("serialized request"); let input = serialized["input"].as_array().expect("input items"); diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index f448ae2be68..129ea1bd451 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -338,7 +338,10 @@ impl LanguageModel for DeepSeekLanguageModel { LanguageModelCompletionError, >, > { - let request = into_deepseek(request, &self.model, self.max_output_tokens()); + let request = match into_deepseek(request, &self.model, self.max_output_tokens()) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_completion(request, cx); async move { @@ -353,7 +356,11 @@ pub fn into_deepseek( request: LanguageModelRequest, model: &deepseek::Model, max_output_tokens: Option, -) -> deepseek::Request { +) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("DeepSeek does not support custom tools"); + } + let thinking = deepseek_thinking(model, request.thinking_allowed); let thinking_enabled = thinking .as_ref() @@ -392,13 +399,16 @@ pub fn into_deepseek( MessageContent::Image(_) => {} MessageContent::Compaction(_) => {} MessageContent::ToolUse(tool_use) => { + let input = tool_use + .input + .as_json() + .ok_or_else(|| anyhow!("DeepSeek does not support custom tool calls"))?; let tool_call = deepseek::ToolCall { id: tool_use.id.to_string(), content: deepseek::ToolCallContent::Function { function: deepseek::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -441,7 +451,7 @@ pub fn into_deepseek( } } - deepseek::Request { + Ok(deepseek::Request { model: model.id().to_string(), messages, stream: true, @@ -466,15 +476,26 @@ pub fn into_deepseek( tools: request .tools .into_iter() - .map(|tool| deepseek::ToolDefinition::Function { - function: deepseek::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("DeepSeek does not support custom tools")); + } + }; + Ok(deepseek::ToolDefinition::Function { + function: deepseek::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect(), - } + .collect::>()?, + }) } fn deepseek_thinking( @@ -578,7 +599,7 @@ impl DeepSeekEventMapper { id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: None, }, @@ -609,7 +630,7 @@ impl DeepSeekEventMapper { id: tool_call.id.clone().into(), name: tool_call.name.as_str().into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments.clone(), thought_signature: None, }, diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 7906e8f301e..e930405c118 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -358,11 +358,14 @@ impl LanguageModel for GoogleLanguageModel { LanguageModelCompletionError, >, > { - let request = into_google( + let request = match into_google( request, self.model.request_id().to_string(), self.model.mode(), - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let request = self.stream_completion(request, cx); let future = self.request_limiter.stream(async move { let response = request.await.map_err(LanguageModelCompletionError::from)?; diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs index 19f0e9f2962..297c1e4284c 100644 --- a/crates/language_models/src/provider/llama_cpp.rs +++ b/crates/language_models/src/provider/llama_cpp.rs @@ -650,7 +650,7 @@ impl LlamaCppLanguageModel { fn to_llama_cpp_request( &self, request: LanguageModelRequest, - ) -> llama_cpp::ChatCompletionRequest { + ) -> Result { build_llama_cpp_request( &self.name, self.supports_images, @@ -697,7 +697,11 @@ fn build_llama_cpp_request( supports_images: bool, capabilities: LiveCapabilities, request: LanguageModelRequest, -) -> llama_cpp::ChatCompletionRequest { +) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("llama.cpp does not support custom tools"); + } + let supports_tools = capabilities.supports_tools; let supports_thinking = capabilities.supports_thinking; let mut messages = Vec::new(); @@ -743,13 +747,15 @@ fn build_llama_cpp_request( } } MessageContent::ToolUse(tool_use) => { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow::anyhow!("llama.cpp does not support custom tool calls") + })?; let tool_call = llama_cpp::ToolCall { id: tool_use.id.to_string(), content: llama_cpp::ToolCallContent::Function { function: llama_cpp::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -811,14 +817,25 @@ fn build_llama_cpp_request( request .tools .into_iter() - .map(|tool| llama_cpp::ToolDefinition::Function { - function: llama_cpp::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("llama.cpp does not support custom tools")); + } + }; + Ok(llama_cpp::ToolDefinition::Function { + function: llama_cpp::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect() + .collect::>()? } else { Vec::new() }; @@ -834,7 +851,7 @@ fn build_llama_cpp_request( }) }; - llama_cpp::ChatCompletionRequest { + Ok(llama_cpp::ChatCompletionRequest { model: model_name.to_string(), messages, stream: true, @@ -853,7 +870,7 @@ fn build_llama_cpp_request( stream_options: Some(llama_cpp::StreamOptions { include_usage: true, }), - } + }) } impl LanguageModel for LlamaCppLanguageModel { @@ -919,7 +936,10 @@ impl LanguageModel for LlamaCppLanguageModel { LanguageModelCompletionError, >, > { - let request = self.to_llama_cpp_request(request); + let request = match self.to_llama_cpp_request(request) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = LlamaCppEventMapper::new(); @@ -1019,7 +1039,9 @@ impl LlamaCppEventMapper { id: tool_call.id.into(), name: tool_call.name.into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json( + input, + ), raw_input: tool_call.arguments, thought_signature: None, }, @@ -1829,7 +1851,8 @@ mod tests { }], ..Default::default() }, - ); + ) + .unwrap(); assert_eq!(request.messages.len(), 1); match &request.messages[0] { @@ -1872,7 +1895,8 @@ mod tests { }], ..Default::default() }, - ); + ) + .unwrap(); assert_eq!(request.messages.len(), 1); match &request.messages[0] { @@ -1882,7 +1906,7 @@ mod tests { tool_calls, } => { assert_eq!(content, "answer"); - assert_eq!(reasoning_content, &None); + assert!(reasoning_content.is_none()); assert!(tool_calls.is_empty()); } message => panic!("unexpected message: {message:?}"), @@ -1911,7 +1935,9 @@ mod tests { id: "call_1".into(), name: "weather".into(), raw_input: r#"{"city":"Oslo"}"#.to_string(), - input: serde_json::json!({ "city": "Oslo" }), + input: language_model::LanguageModelToolUseInput::Json( + serde_json::json!({ "city": "Oslo" }), + ), is_input_complete: true, thought_signature: None, }), @@ -1921,7 +1947,8 @@ mod tests { }], ..Default::default() }, - ); + ) + .unwrap(); assert_eq!(request.messages.len(), 1); match &request.messages[0] { diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 3f6e9fb46e2..518b4a26155 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -338,7 +338,11 @@ impl LmStudioLanguageModel { fn to_lmstudio_request( &self, request: LanguageModelRequest, - ) -> lmstudio::ChatCompletionRequest { + ) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("LM Studio does not support custom tools"); + } + let mut messages = Vec::new(); for message in request.messages { @@ -365,13 +369,15 @@ impl LmStudioLanguageModel { ); } MessageContent::ToolUse(tool_use) => { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow!("LM Studio does not support custom tool calls") + })?; let tool_call = lmstudio::ToolCall { id: tool_use.id.to_string(), content: lmstudio::ToolCallContent::Function { function: lmstudio::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -417,10 +423,13 @@ impl LmStudioLanguageModel { } } - lmstudio::ChatCompletionRequest { + Ok(lmstudio::ChatCompletionRequest { model: self.model.name.clone(), messages, stream: true, + stream_options: Some(lmstudio::StreamOptions { + include_usage: true, + }), max_tokens: Some(-1), stop: Some(request.stop), // In LM Studio you can configure specific settings you'd like to use for your model. @@ -430,20 +439,31 @@ impl LmStudioLanguageModel { tools: request .tools .into_iter() - .map(|tool| lmstudio::ToolDefinition::Function { - function: lmstudio::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("LM Studio does not support custom tools")); + } + }; + Ok(lmstudio::ToolDefinition::Function { + function: lmstudio::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect(), + .collect::>()?, tool_choice: request.tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => lmstudio::ToolChoice::Auto, LanguageModelToolChoice::Any => lmstudio::ToolChoice::Required, LanguageModelToolChoice::None => lmstudio::ToolChoice::None, }), - } + }) } fn stream_completion( @@ -533,7 +553,10 @@ impl LanguageModel for LmStudioLanguageModel { LanguageModelCompletionError, >, > { - let request = self.to_lmstudio_request(request); + let request = match self.to_lmstudio_request(request) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = LmStudioEventMapper::new(); @@ -571,13 +594,23 @@ impl LmStudioEventMapper { &mut self, event: lmstudio::ResponseStreamEvent, ) -> Vec> { + let mut events = Vec::new(); + + if let Some(usage) = event.usage { + events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: usage.prompt_tokens, + output_tokens: usage.completion_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }))); + } + + // The final usage summary chunk from OpenAI-compatible servers has an empty choices array. + // Return accumulated events instead of treating it as an error. let Some(choice) = event.choices.into_iter().next() else { - return vec![Err(LanguageModelCompletionError::from(anyhow!( - "Response contained no choices" - )))]; + return events; }; - let mut events = Vec::new(); if let Some(content) = choice.delta.content { events.push(Ok(LanguageModelCompletionEvent::Text(content))); } @@ -616,15 +649,6 @@ impl LmStudioEventMapper { } } - if let Some(usage) = event.usage { - events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: usage.prompt_tokens, - output_tokens: usage.completion_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }))); - } - match choice.finish_reason.as_deref() { Some("stop") => { events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); @@ -637,7 +661,7 @@ impl LmStudioEventMapper { id: tool_call.id.into(), name: tool_call.name.into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments, thought_signature: None, }, @@ -671,6 +695,142 @@ struct RawToolCall { arguments: String, } +#[cfg(test)] +mod tests { + use super::*; + use lmstudio::{ChoiceDelta, ResponseMessageDelta, ResponseStreamEvent, Usage}; + + fn make_event(choices: Vec, usage: Option) -> ResponseStreamEvent { + ResponseStreamEvent { + created: 0, + model: "test-model".to_string(), + object: "chat.completion.chunk".to_string(), + choices, + usage, + } + } + + fn make_content_choice(content: &str) -> ChoiceDelta { + ChoiceDelta { + index: 0, + delta: ResponseMessageDelta { + role: None, + content: Some(content.to_string()), + reasoning_content: None, + tool_calls: None, + }, + finish_reason: None, + } + } + + fn make_stop_choice() -> ChoiceDelta { + ChoiceDelta { + index: 0, + delta: ResponseMessageDelta { + role: None, + content: None, + reasoning_content: None, + tool_calls: None, + }, + finish_reason: Some("stop".to_string()), + } + } + + // OpenAI-compatible servers send a final chunk with usage data and an empty + // choices array. Before this fix, the mapper returned an error for empty + // choices, discarding usage entirely. + #[test] + fn test_usage_in_final_empty_choices_chunk() { + let mut mapper = LmStudioEventMapper::new(); + let event = make_event( + vec![], + Some(Usage { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }), + ); + + let results: Vec<_> = mapper + .map_event(event) + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + assert_eq!( + results, + vec![LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + })] + ); + } + + #[test] + fn test_empty_choices_without_usage_returns_empty() { + let mut mapper = LmStudioEventMapper::new(); + let event = make_event(vec![], None); + + let results = mapper.map_event(event); + + assert!(results.is_empty()); + } + + // Usage data can also arrive in a regular chunk that also contains content. + // Both events must be emitted, with UsageUpdate first. + #[test] + fn test_usage_emitted_alongside_content() { + let mut mapper = LmStudioEventMapper::new(); + let event = make_event( + vec![make_content_choice("Hello!")], + Some(Usage { + prompt_tokens: 5, + completion_tokens: 3, + total_tokens: 8, + }), + ); + + let results: Vec<_> = mapper + .map_event(event) + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + assert_eq!( + results[0], + LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 5, + output_tokens: 3, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }) + ); + assert_eq!( + results[1], + LanguageModelCompletionEvent::Text("Hello!".to_string()) + ); + } + + #[test] + fn test_stop_event_emitted_on_finish_reason() { + let mut mapper = LmStudioEventMapper::new(); + let event = make_event(vec![make_stop_choice()], None); + + let results: Vec<_> = mapper + .map_event(event) + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + assert_eq!( + results, + vec![LanguageModelCompletionEvent::Stop(StopReason::EndTurn)] + ); + } +} + fn add_message_content_part( new_part: lmstudio::MessagePart, role: Role, diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index f12f33a6f35..a45564b191b 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -343,7 +343,10 @@ impl LanguageModel for MistralLanguageModel { >, > { let (request, affinity) = - into_mistral(request, self.model.clone(), self.max_output_tokens()); + match into_mistral(request, self.model.clone(), self.max_output_tokens()) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_completion(request, affinity, cx); async move { @@ -359,7 +362,11 @@ pub fn into_mistral( request: LanguageModelRequest, model: mistral::Model, max_output_tokens: Option, -) -> (mistral::Request, Option) { +) -> Result<(mistral::Request, Option)> { + if request.contains_custom_tool_input() { + anyhow::bail!("Mistral does not support custom tools"); + } + let stream = true; let mut messages = Vec::new(); @@ -452,13 +459,15 @@ pub fn into_mistral( MessageContent::Image(_) => {} MessageContent::Compaction(_) => {} MessageContent::ToolUse(tool_use) => { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow!("Mistral does not support custom tool calls") + })?; let tool_call = mistral::ToolCall { id: tool_use.id.to_string(), content: mistral::ToolCallContent::Function { function: mistral::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -516,7 +525,7 @@ pub fn into_mistral( } } - ( + Ok(( mistral::Request { model: model.id().to_string(), messages, @@ -550,17 +559,28 @@ pub fn into_mistral( tools: request .tools .into_iter() - .map(|tool| mistral::ToolDefinition::Function { - function: mistral::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("Mistral does not support custom tools")); + } + }; + Ok(mistral::ToolDefinition::Function { + function: mistral::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect(), + .collect::>()?, }, request.thread_id, - ) + )) } pub struct MistralEventMapper { @@ -664,7 +684,7 @@ impl MistralEventMapper { id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: None, }, @@ -721,7 +741,7 @@ impl MistralEventMapper { id: tool_call.id.into(), name: tool_call.name.into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments, thought_signature: None, }, @@ -813,7 +833,10 @@ mod tests { assert_eq!(tool_use.id.to_string(), "real_id_123"); assert_eq!(tool_use.name.as_ref(), "read_file"); - assert_eq!(tool_use.input, serde_json::json!({"path": "a.txt"})); + assert_eq!( + tool_use.input, + language_model::LanguageModelToolUseInput::Json(serde_json::json!({"path": "a.txt"})) + ); } #[test] @@ -854,7 +877,7 @@ mod tests { }; let (mistral_request, affinity) = - into_mistral(request, mistral::Model::MistralSmallLatest, None); + into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap(); assert_eq!(mistral_request.model, "mistral-small-latest"); assert_eq!(mistral_request.temperature, Some(0.5)); @@ -890,7 +913,8 @@ mod tests { compact_at_tokens: None, }; - let (mistral_request, _) = into_mistral(request, mistral::Model::MistralSmallLatest, None); + let (mistral_request, _) = + into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap(); assert_eq!(mistral_request.messages.len(), 1); assert!(matches!( diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index f0166c6afb1..9ef157f9655 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -360,7 +360,11 @@ pub struct OllamaLanguageModel { } impl OllamaLanguageModel { - fn to_ollama_request(&self, request: LanguageModelRequest) -> ChatRequest { + fn to_ollama_request(&self, request: LanguageModelRequest) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("Ollama does not support custom tools"); + } + let supports_vision = self.model.supports_vision.unwrap_or(false); let mut messages = Vec::with_capacity(request.messages.len()); @@ -422,7 +426,16 @@ impl OllamaLanguageModel { id: tool_use.id.to_string(), function: OllamaFunctionCall { name: tool_use.name.to_string(), - arguments: tool_use.input, + arguments: match tool_use.input { + language_model::LanguageModelToolUseInput::Json( + input, + ) => input, + language_model::LanguageModelToolUseInput::Text(_) => { + return Err(anyhow::anyhow!( + "Ollama does not support custom tool calls" + )); + } + }, }, }); } @@ -445,7 +458,7 @@ impl OllamaLanguageModel { }), } } - ChatRequest { + Ok(ChatRequest { model: self.model.name.clone(), messages, keep_alive: self.model.keep_alive.clone().unwrap_or_default(), @@ -468,11 +481,15 @@ impl OllamaLanguageModel { .supports_thinking .map(|supports_thinking| supports_thinking && request.thinking_allowed), tools: if self.model.supports_tools.unwrap_or(false) { - request.tools.into_iter().map(tool_into_ollama).collect() + request + .tools + .into_iter() + .map(tool_into_ollama) + .collect::>()? } else { vec![] }, - } + }) } } @@ -536,7 +553,10 @@ impl LanguageModel for OllamaLanguageModel { LanguageModelCompletionError, >, > { - let request = self.to_ollama_request(request); + let request = match self.to_ollama_request(request) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let http_client = self.http_client.clone(); let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, cx| { @@ -621,7 +641,9 @@ fn map_to_language_model_completion_events( id: LanguageModelToolUseId::from(id), name: Arc::from(function.name), raw_input: function.arguments.to_string(), - input: function.arguments, + input: language_model::LanguageModelToolUseInput::Json( + function.arguments, + ), is_input_complete: true, thought_signature: None, }); @@ -1130,14 +1152,22 @@ fn merge_settings_into_models( } } -fn tool_into_ollama(tool: LanguageModelRequestTool) -> ollama::OllamaTool { - ollama::OllamaTool::Function { +fn tool_into_ollama(tool: LanguageModelRequestTool) -> Result { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => { + input_schema + } + language_model::LanguageModelRequestToolInput::Custom { .. } => { + anyhow::bail!("Ollama does not support custom tools"); + } + }; + Ok(ollama::OllamaTool::Function { function: OllamaFunctionTool { name: tool.name, description: Some(tool.description), - parameters: Some(tool.input_schema), + parameters: Some(input_schema), }, - } + }) } #[cfg(test)] diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index c2c74a8553b..eabb0ed5032 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -464,6 +464,9 @@ impl LanguageModel for OpenAiLanguageModel { | Model::FivePointFourPro | Model::FivePointFive | Model::FivePointFivePro + | Model::FivePointSixSol + | Model::FivePointSixTerra + | Model::FivePointSixLuna | Model::O3 => true, Model::Four => false, Model::Custom { @@ -553,7 +556,7 @@ impl LanguageModel for OpenAiLanguageModel { } .boxed() } else { - let request = into_open_ai( + let request = match into_open_ai( request, self.model.id(), self.model.supports_parallel_tool_calls(), @@ -562,7 +565,10 @@ impl LanguageModel for OpenAiLanguageModel { ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = OpenAiEventMapper::new(); diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index e69e847eab9..ff67344ac5a 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -424,7 +424,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { if self.model.capabilities.chat_completions { let reasoning_effort = chat_completion_reasoning_effort(&request, &self.model); - let request = into_open_ai( + let request = match into_open_ai( request, &self.model.name, self.model.capabilities.parallel_tool_calls, @@ -433,7 +433,10 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { chat_completion_max_tokens_parameter(&self.model), reasoning_effort, self.model.capabilities.interleaved_reasoning, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = OpenAiEventMapper::new(); @@ -688,7 +691,8 @@ mod tests { chat_completion_max_tokens_parameter(&model), reasoning_effort, model.capabilities.interleaved_reasoning, - ); + ) + .unwrap(); let serialized = serde_json::to_value(request).unwrap(); assert_eq!(serialized["reasoning_effort"], json!("high")); diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index fd5221c9b7c..59d4aac3fb7 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -400,7 +400,11 @@ impl LanguageModel for OpenRouterLanguageModel { LanguageModelCompletionError, >, > { - let openrouter_request = into_open_router(request, &self.model, self.max_output_tokens()); + let openrouter_request = + match into_open_router(request, &self.model, self.max_output_tokens()) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let request = self.stream_completion(openrouter_request, cx); let future = self.request_limiter.stream(async move { let response = request.await?; @@ -414,7 +418,11 @@ pub fn into_open_router( request: LanguageModelRequest, model: &Model, max_output_tokens: Option, -) -> open_router::Request { +) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("OpenRouter does not support custom tools"); + } + // Anthropic models via OpenRouter don't accept reasoning_details being echoed back // in requests - it's an output-only field for them. However, Gemini models require // the thought signatures to be echoed back for proper reasoning chain continuity. @@ -472,13 +480,15 @@ pub fn into_open_router( message_added_content = true; } MessageContent::ToolUse(tool_use) => { + let input = tool_use.input.as_json().ok_or_else(|| { + anyhow::anyhow!("OpenRouter does not support custom tool calls") + })?; let tool_call = open_router::ToolCall { id: tool_use.id.to_string(), content: open_router::ToolCallContent::Function { function: open_router::FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), thought_signature: tool_use.thought_signature.clone(), }, }, @@ -551,7 +561,7 @@ pub fn into_open_router( } } - open_router::Request { + Ok(open_router::Request { model: model.id().into(), messages, stream: true, @@ -580,21 +590,32 @@ pub fn into_open_router( tools: request .tools .into_iter() - .map(|tool| open_router::ToolDefinition::Function { - function: open_router::FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| { + let input_schema = match tool.input { + language_model::LanguageModelRequestToolInput::Function { + input_schema, + .. + } => input_schema, + language_model::LanguageModelRequestToolInput::Custom { .. } => { + return Err(anyhow::anyhow!("OpenRouter does not support custom tools")); + } + }; + Ok(open_router::ToolDefinition::Function { + function: open_router::FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) }) - .collect(), + .collect::>()?, tool_choice: request.tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => open_router::ToolChoice::Auto, LanguageModelToolChoice::Any => open_router::ToolChoice::Required, LanguageModelToolChoice::None => open_router::ToolChoice::None, }), provider: model.provider.clone(), - } + }) } fn open_router_session_id(thread_id: Option) -> Option { @@ -809,7 +830,7 @@ impl OpenRouterEventMapper { id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: entry.thought_signature.clone(), }, @@ -832,7 +853,7 @@ impl OpenRouterEventMapper { id: tool_call.id.clone().into(), name: tool_call.name.as_str().into(), is_input_complete: true, - input, + input: language_model::LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments.clone(), thought_signature: tool_call.thought_signature.clone(), }, @@ -1114,7 +1135,7 @@ mod tests { ..Default::default() }; - let result = into_open_router(request, &model, None); + let result = into_open_router(request, &model, None).unwrap(); assert_eq!( result.session_id.as_deref(), @@ -1216,7 +1237,7 @@ mod tests { compact_at_tokens: None, }; - let result = into_open_router(request, &model, None); + let result = into_open_router(request, &model, None).unwrap(); let system_cache = result.messages.iter().find_map(|m| { if let open_router::RequestMessage::System { content } = m { @@ -1355,7 +1376,7 @@ mod tests { compact_at_tokens: None, }; - let result = into_open_router(request, &model, None); + let result = into_open_router(request, &model, None).unwrap(); for message in &result.messages { let content = match message { @@ -1418,7 +1439,7 @@ mod tests { compact_at_tokens: None, }; - let result = into_open_router(request, &model, None); + let result = into_open_router(request, &model, None).unwrap(); for message in &result.messages { let content = match message { diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index e3ec95859a2..fca352d8689 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -311,6 +311,8 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { // approximation; the entries below mirror that file's picker-visible models. #[derive(Clone, Debug, PartialEq)] enum ChatGptModel { + Gpt56Sol, + Gpt56Terra, Gpt55, Gpt54, Gpt54Mini, @@ -318,11 +320,19 @@ enum ChatGptModel { impl ChatGptModel { fn all() -> Vec { - vec![Self::Gpt55, Self::Gpt54, Self::Gpt54Mini] + vec![ + Self::Gpt56Sol, + Self::Gpt56Terra, + Self::Gpt55, + Self::Gpt54, + Self::Gpt54Mini, + ] } fn id(&self) -> &str { match self { + Self::Gpt56Sol => "gpt-5.6-sol", + Self::Gpt56Terra => "gpt-5.6-terra", Self::Gpt55 => "gpt-5.5", Self::Gpt54 => "gpt-5.4", Self::Gpt54Mini => "gpt-5.4-mini", @@ -331,6 +341,8 @@ impl ChatGptModel { fn display_name(&self) -> &str { match self { + Self::Gpt56Sol => "GPT-5.6 Sol", + Self::Gpt56Terra => "GPT-5.6 Terra", Self::Gpt55 => "GPT-5.5", Self::Gpt54 => "GPT-5.4", Self::Gpt54Mini => "GPT-5.4 Mini", @@ -338,11 +350,10 @@ impl ChatGptModel { } fn max_token_count(&self) -> u64 { - // All Codex-supported models use a 272K context window in the Codex - // backend, even when the raw model exposes a larger context window via the - // public API (e.g. gpt-5.4 has max_context_window 1M, but Codex uses - // context_window 272K). Source: openai/codex models-manager/models.json. - 272_000 + match self { + Self::Gpt56Sol | Self::Gpt56Terra => 372_000, + Self::Gpt55 | Self::Gpt54 | Self::Gpt54Mini => 272_000, + } } fn max_output_tokens(&self) -> Option { @@ -356,18 +367,30 @@ impl ChatGptModel { } fn default_reasoning_effort(&self) -> Option { - // Codex bundled models all default to Medium reasoning effort. - Some(ReasoningEffort::Medium) + match self { + Self::Gpt56Sol => Some(ReasoningEffort::Low), + Self::Gpt56Terra | Self::Gpt55 | Self::Gpt54 | Self::Gpt54Mini => { + Some(ReasoningEffort::Medium) + } + } } fn supported_reasoning_efforts(&self) -> &'static [ReasoningEffort] { - // The Codex backend's supported_reasoning_levels for every model in this list is low/medium/high/xhigh - &[ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ReasoningEffort::XHigh, - ] + match self { + Self::Gpt56Sol | Self::Gpt56Terra => &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ReasoningEffort::Max, + ], + Self::Gpt55 | Self::Gpt54 | Self::Gpt54Mini => &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ], + } } fn supports_parallel_tool_calls(&self) -> bool { @@ -380,7 +403,7 @@ impl ChatGptModel { fn supports_priority(&self) -> bool { match self { - Self::Gpt55 | Self::Gpt54 => true, + Self::Gpt56Sol | Self::Gpt56Terra | Self::Gpt55 | Self::Gpt54 => true, Self::Gpt54Mini => false, } } @@ -449,7 +472,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { ReasoningEffort::Medium => ("Medium", "medium"), ReasoningEffort::High => ("High", "high"), ReasoningEffort::XHigh => ("Extra High", "xhigh"), - ReasoningEffort::Max => return None, // Not supported by any OpenAI models + ReasoningEffort::Max => ("Max", "max"), }; Some(LanguageModelEffortLevel { diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index c3e5c0f8dab..6604995ffee 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -669,7 +669,7 @@ impl LanguageModel for OpenCodeLanguageModel { } else { anthropic::AnthropicModelMode::Default }; - let anthropic_request = into_anthropic( + let anthropic_request = match into_anthropic( request, self.model.id().to_string(), 1.0, @@ -678,7 +678,10 @@ impl LanguageModel for OpenCodeLanguageModel { .unwrap_or(8192), mode, anthropic::completion::AnthropicPromptCacheMode::Automatic, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_anthropic(anthropic_request, http_client, extra_headers, cx); async move { @@ -696,7 +699,7 @@ impl LanguageModel for OpenCodeLanguageModel { } else { None }; - let openai_request = into_open_ai( + let openai_request = match into_open_ai( request, self.model.id(), true, @@ -705,7 +708,10 @@ impl LanguageModel for OpenCodeLanguageModel { ChatCompletionMaxTokensParameter::MaxCompletionTokens, reasoning_effort, self.model.interleaved_reasoning(), - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_openai_chat(openai_request, http_client, extra_headers, cx); async move { @@ -744,7 +750,10 @@ impl LanguageModel for OpenCodeLanguageModel { } else { google_ai::GoogleModelMode::Default }; - let google_request = into_google(request, self.model.id().to_string(), mode); + let google_request = match into_google(request, self.model.id().to_string(), mode) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let stream = self.stream_google(google_request, http_client, extra_headers, cx); async move { let mapper = GoogleEventMapper::new(); diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 212223eb851..23868b93747 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -451,7 +451,7 @@ impl LanguageModel for VercelAiGatewayLanguageModel { LanguageModelCompletionError, >, > { - let request = crate::provider::open_ai::into_open_ai( + let request = match crate::provider::open_ai::into_open_ai( request, &self.model.name, self.model.capabilities.parallel_tool_calls, @@ -460,7 +460,10 @@ impl LanguageModel for VercelAiGatewayLanguageModel { crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_open_ai(request, cx); async move { let mapper = crate::provider::open_ai::OpenAiEventMapper::new(); diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 4556e439a39..834b97eb8be 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -413,7 +413,7 @@ impl LanguageModel for XAiLanguageModel { >, > { let reasoning_effort = reasoning_effort_for_request(&request, &self.model); - let request = crate::provider::open_ai::into_open_ai( + let request = match crate::provider::open_ai::into_open_ai( request, self.model.id(), self.model.supports_parallel_tool_calls(), @@ -422,7 +422,10 @@ impl LanguageModel for XAiLanguageModel { crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens, reasoning_effort, false, - ); + ) { + Ok(request) => request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let completions = self.stream_completion(request, cx); async move { let mapper = crate::provider::open_ai::OpenAiEventMapper::new(); diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 07be3150c0c..46060e92c7b 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -462,7 +462,7 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; if enable_thinking && effort.is_some() { request.thinking = Some(anthropic::Thinking::Adaptive { @@ -592,7 +595,7 @@ impl LanguageModel for CloudLanguageModel { let http_client = self.http_client.clone(); let token_provider = self.token_provider.clone(); - let request = into_open_ai( + let request = match into_open_ai( request, &self.model.id.0, self.model.supports_parallel_tool_calls, @@ -601,7 +604,10 @@ impl LanguageModel for CloudLanguageModel request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let auth_context = token_provider.auth_context(cx); let future = self.request_limiter.stream(async move { let PerformLlmCompletionResponse { @@ -640,7 +646,11 @@ impl LanguageModel for CloudLanguageModel request, + Err(error) => return async move { Err(error.into()) }.boxed(), + }; let auth_context = token_provider.auth_context(cx); let future = self.request_limiter.stream(async move { let PerformLlmCompletionResponse { diff --git a/crates/lmstudio/src/lmstudio.rs b/crates/lmstudio/src/lmstudio.rs index 57963bbb040..4f5c977f129 100644 --- a/crates/lmstudio/src/lmstudio.rs +++ b/crates/lmstudio/src/lmstudio.rs @@ -207,12 +207,19 @@ pub struct FunctionContent { pub arguments: String, } +#[derive(Serialize, Debug)] +pub struct StreamOptions { + pub include_usage: bool, +} + #[derive(Serialize, Debug)] pub struct ChatCompletionRequest { pub model: String, pub messages: Vec, pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] pub stop: Option>, diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 69f5aa32083..a4651e5d7e5 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -2,6 +2,7 @@ pub mod html; mod mermaid; pub mod parser; mod path_range; +mod selection; use base64::Engine as _; use futures::FutureExt as _; @@ -808,12 +809,15 @@ impl Markdown { output.into() } - pub fn selected_text(&self) -> Option { + pub fn has_selection(&self) -> bool { + self.selection.end > self.selection.start + } + + pub fn selected_source(&self) -> Option<&str> { if self.selection.end <= self.selection.start { - None - } else { - Some(self.source[self.selection.start..self.selection.end].to_string()) + return None; } + self.source.get(self.selection.start..self.selection.end) } pub fn set_search_highlights( @@ -873,7 +877,9 @@ impl Markdown { if self.selection.end <= self.selection.start { return; } - let text = self.source[self.selection.start..self.selection.end].to_string(); + let text = self + .parsed_markdown + .rebalanced_markdown_for_selection(self.selection.start..self.selection.end); cx.write_to_clipboard(ClipboardItem::new_string(text)); } @@ -884,8 +890,10 @@ impl Markdown { ) { let range = self.selection.start..self.selection.end; if range.end > range.start { - self.context_menu_selected_markdown = - Some(SharedString::new(&self.source[range.clone()])); + self.context_menu_selected_markdown = Some(SharedString::new( + self.parsed_markdown + .rebalanced_markdown_for_selection(range.clone()), + )); self.context_menu_selected_text = rendered_text .map(|text| text.text_for_range(range)) .map(SharedString::new) @@ -910,8 +918,9 @@ impl Markdown { self.context_menu_selected_text.as_ref() } - /// Returns the raw markdown source that was selected when the most recent - /// context menu invocation happened. + /// Returns the markdown that was selected when the most recent context + /// menu invocation happened, rebalanced via + /// [`ParsedMarkdown::rebalanced_markdown_for_selection`]. pub fn context_menu_selected_markdown(&self) -> Option<&SharedString> { self.context_menu_selected_markdown.as_ref() } @@ -1207,6 +1216,21 @@ impl ParsedMarkdown { Some(partition.saturating_sub(1)) } + + /// Extracts the markdown source for a selection, rebalancing inline + /// delimiters (`**`, backticks, link syntax, etc.) so partial selections of + /// styled spans stay well-formed. + /// + /// With an exception of a single inline code span, which is returned as plain + /// text, since copying a command or identifier is the dominant use case there. + pub fn rebalanced_markdown_for_selection(&self, selection: Range) -> String { + selection::rebalanced_markdown_for_selection( + &self.source, + &self.events, + &self.root_block_starts, + selection, + ) + } } pub enum AutoscrollBehavior { diff --git a/crates/markdown/src/selection.rs b/crates/markdown/src/selection.rs new file mode 100644 index 00000000000..9e7851b549b --- /dev/null +++ b/crates/markdown/src/selection.rs @@ -0,0 +1,598 @@ +use crate::parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd}; +use std::ops::Range; + +struct InlineSpan { + range: Range, + content: Range, + is_code: bool, +} + +impl InlineSpan { + fn opening<'a>(&self, source: &'a str) -> &'a str { + source + .get(self.range.start..self.content.start) + .unwrap_or("") + } + + fn closing<'a>(&self, source: &'a str) -> &'a str { + source.get(self.content.end..self.range.end).unwrap_or("") + } +} + +fn is_inline_span_tag(tag: &MarkdownTag) -> bool { + matches!( + tag, + MarkdownTag::Emphasis + | MarkdownTag::Strong + | MarkdownTag::Strikethrough + | MarkdownTag::Superscript + | MarkdownTag::Subscript + | MarkdownTag::Link { .. } + ) +} + +fn is_inline_span_tag_end(tag: &MarkdownTagEnd) -> bool { + matches!( + tag, + MarkdownTagEnd::Emphasis + | MarkdownTagEnd::Strong + | MarkdownTagEnd::Strikethrough + | MarkdownTagEnd::Superscript + | MarkdownTagEnd::Subscript + | MarkdownTagEnd::Link + ) +} + +fn inline_code_full_range(source: &str, content: &Range) -> Range { + let opening_ticks = source[..content.start] + .bytes() + .rev() + .take_while(|&byte| byte == b'`') + .count(); + let closing_ticks = source[content.end..] + .bytes() + .take_while(|&byte| byte == b'`') + .count(); + let ticks = opening_ticks.min(closing_ticks); + content.start - ticks..content.end + ticks +} + +fn collect_inline_spans(source: &str, events: &[(Range, MarkdownEvent)]) -> Vec { + fn note_child(stack: &mut [(Range, Option>)], child: &Range) { + for (_, content) in stack.iter_mut() { + match content { + Some(content) => content.end = content.end.max(child.end), + None => *content = Some(child.clone()), + } + } + } + + let mut spans = Vec::new(); + let mut stack: Vec<(Range, Option>)> = Vec::new(); + for (event_range, event) in events { + match event { + MarkdownEvent::Start(tag) if is_inline_span_tag(tag) => { + note_child(&mut stack, event_range); + stack.push((event_range.clone(), None)); + } + MarkdownEvent::End(tag) if is_inline_span_tag_end(tag) => { + if let Some((range, content)) = stack.pop() { + let content = content.unwrap_or(range.clone()); + spans.push(InlineSpan { + range, + content, + is_code: false, + }); + } + note_child(&mut stack, event_range); + } + MarkdownEvent::Code | MarkdownEvent::SubstitutedCode(_) => { + let range = inline_code_full_range(source, event_range); + note_child(&mut stack, &range); + spans.push(InlineSpan { + range, + content: event_range.clone(), + is_code: true, + }); + } + _ => note_child(&mut stack, event_range), + } + } + spans +} + +pub(crate) fn rebalanced_markdown_for_selection( + source: &str, + events: &[(Range, MarkdownEvent)], + root_block_starts: &[usize], + selection: Range, +) -> String { + let Some(selection) = snap_to_char_boundaries(source, selection) else { + return String::new(); + }; + + let (start_events, end_events) = boundary_block_events(events, root_block_starts, &selection); + let mut spans = collect_inline_spans(source, start_events); + spans.extend(collect_inline_spans(source, end_events)); + + let Some(selection) = snap_out_of_delimiters(&spans, selection) else { + return String::new(); + }; + + if selection_is_only_inside_code_spans(&spans, &selection) { + return source[selection].to_string(); + } + + rebalance_delimiters(source, &spans, &selection) +} + +/// Returns the events of the root blocks containing each selection boundary. +/// The second slice is empty when both boundaries share a block. +fn boundary_block_events<'a>( + events: &'a [(Range, MarkdownEvent)], + root_block_starts: &[usize], + selection: &Range, +) -> ( + &'a [(Range, MarkdownEvent)], + &'a [(Range, MarkdownEvent)], +) { + if root_block_starts.is_empty() { + return (events, &[]); + } + let start_block = root_block_index(root_block_starts, selection.start); + let end_block = root_block_index(root_block_starts, selection.end); + let start_events = root_block_events(events, root_block_starts, start_block); + if end_block == start_block { + (start_events, &[]) + } else { + ( + start_events, + root_block_events(events, root_block_starts, end_block), + ) + } +} + +fn root_block_index(root_block_starts: &[usize], offset: usize) -> usize { + root_block_starts + .partition_point(|block_start| *block_start <= offset) + .saturating_sub(1) +} + +fn root_block_events<'a>( + events: &'a [(Range, MarkdownEvent)], + root_block_starts: &[usize], + block: usize, +) -> &'a [(Range, MarkdownEvent)] { + let Some(&block_start) = root_block_starts.get(block) else { + return events; + }; + let start = events.partition_point(|(range, _)| range.start < block_start); + let end = match root_block_starts.get(block + 1) { + Some(&next_block_start) => { + events.partition_point(|(range, _)| range.start < next_block_start) + } + None => events.len(), + }; + events.get(start..end).unwrap_or(events) +} + +fn snap_to_char_boundaries(source: &str, selection: Range) -> Option> { + let mut start = selection.start.min(source.len()); + let mut end = selection.end.min(source.len()); + if start >= end { + return None; + } + while start > 0 && !source.is_char_boundary(start) { + start -= 1; + } + while end < source.len() && !source.is_char_boundary(end) { + end += 1; + } + Some(start..end) +} + +/// Shrinks selection boundaries that fall inside delimiter syntax (`**`, +/// etc.) so no delimiter is left half-selected: +/// +/// - an end in `**bold*|*` snaps back to `**bold|**` +/// - a start in `*|*bold**` snaps forward to `**|bold**` +/// +/// This repeats until stable, since snapping can land inside a nested span's +/// delimiter. Returns `None` if the selection becomes empty. +fn snap_out_of_delimiters(spans: &[InlineSpan], selection: Range) -> Option> { + let mut start = selection.start; + let mut end = selection.end; + loop { + let mut changed = false; + for span in spans { + if end > span.range.start && end <= span.content.start { + end = span.range.start; + changed = true; + } else if end > span.content.end && end <= span.range.end { + end = span.content.end; + changed = true; + } + if start >= span.range.start && start < span.content.start { + start = span.content.start; + changed = true; + } else if start >= span.content.end && start < span.range.end { + start = span.range.end; + changed = true; + } + } + if !changed { + break; + } + } + (start < end).then(|| start..end) +} + +fn selection_is_only_inside_code_spans(spans: &[InlineSpan], selection: &Range) -> bool { + let contains = |span: &InlineSpan| { + span.content.start <= selection.start && selection.end <= span.content.end + }; + spans.iter().any(|span| span.is_code && contains(span)) + && !spans.iter().any(|span| !span.is_code && contains(span)) +} + +/// Re-adds delimiters cut off by the selection so the result is well-formed +/// markdown: +/// +/// - selecting `old te` in `**bold text**` yields `**old te**` +/// - nested spans are reopened outermost first: selecting `alic` in +/// `**bold _italic_**` yields `**_alic_**` +fn rebalance_delimiters(source: &str, spans: &[InlineSpan], selection: &Range) -> String { + let nesting_order = |a: &&InlineSpan, b: &&InlineSpan| { + a.range + .start + .cmp(&b.range.start) + .then(b.range.end.cmp(&a.range.end)) + }; + + let mut open_at_start = spans + .iter() + .filter(|span| span.content.start <= selection.start && selection.start < span.content.end) + .collect::>(); + open_at_start.sort_by(nesting_order); + + let mut open_at_end = spans + .iter() + .filter(|span| span.content.start < selection.end && selection.end <= span.content.end) + .collect::>(); + open_at_end.sort_by(nesting_order); + + let mut result = String::new(); + for span in &open_at_start { + result.push_str(span.opening(source)); + } + result.push_str(&source[selection.clone()]); + for span in open_at_end.iter().rev() { + result.push_str(span.closing(source)); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse_markdown_with_options; + use util::test::marked_text_ranges; + + fn markdown_for(source: &str, selection: Range) -> String { + let parsed = parse_markdown_with_options(source, false, false, false); + rebalanced_markdown_for_selection( + source, + &parsed.events, + &parsed.root_block_starts, + selection, + ) + } + + fn markdown_for_marked(marked_source: &str) -> String { + let (source, selection) = marked_range(marked_source); + markdown_for(&source, selection) + } + + #[track_caller] + fn assert_marked_selection(marked_source: &str, expected: &str) { + assert_eq!( + markdown_for_marked(marked_source), + expected, + "source: {marked_source:?}" + ); + } + + #[track_caller] + fn marked_range(marked_source: &str) -> (String, Range) { + let (source, ranges) = marked_text_ranges(marked_source, false); + match ranges.as_slice() { + [selection] => (source, selection.clone()), + _ => panic!("expected exactly one «» range in marked source"), + } + } + + fn inline_spans(source: &str) -> Vec { + let parsed = parse_markdown_with_options(source, false, false, false); + collect_inline_spans(source, &parsed.events) + } + + #[track_caller] + fn assert_snapped(marked_selection: &str, marked_expected: Option<&str>, message: &str) { + let (source, selection) = marked_range(marked_selection); + let expected = marked_expected.map(|marked_expected| { + let (expected_source, range) = marked_range(marked_expected); + assert_eq!( + expected_source, source, + "expected must be marked on the same source" + ); + range + }); + assert_eq!( + snap_out_of_delimiters(&inline_spans(&source), selection), + expected, + "{message}" + ); + } + + #[test] + fn test_snap_out_of_delimiters() { + assert_snapped( + "**«bold»** rest", + Some("**«bold»** rest"), + "boundaries already in content must be untouched", + ); + assert_snapped( + "*«*bold»** rest", + Some("**«bold»** rest"), + "a start in the opening delimiter must advance into the content", + ); + assert_snapped( + "**«bold*»* rest", + Some("**«bold»** rest"), + "an end in the closing delimiter must retreat to the content end", + ); + assert_snapped( + "**bold*«* rest»", + Some("**bold**« rest»"), + "a start in the closing delimiter must advance past the whole span", + ); + assert_snapped( + "«*»*bold** rest", + None, + "an end in the opening delimiter must retreat to before the span", + ); + assert_snapped( + "**bold«**» rest", + None, + "a selection covering only delimiter text must collapse", + ); + } + + #[test] + fn test_snap_out_of_delimiters_cascades_through_nested_spans() { + assert_snapped( + "**`«code`*»*", + Some("**`«code»`**"), + "an end inside bold's closing `**` first snaps to code's closing \ + backtick, so it must cascade into the code content", + ); + } + + #[test] + fn test_snap_to_char_boundaries() { + // `√` occupies bytes 1..4. + let source = "a√b"; + assert_eq!( + snap_to_char_boundaries(source, 0..5), + Some(0..5), + "boundaries already on char boundaries must be untouched" + ); + assert_eq!( + snap_to_char_boundaries(source, 0..2), + Some(0..4), + "an end mid-character must expand forward to keep the character" + ); + assert_eq!( + snap_to_char_boundaries(source, 2..5), + Some(1..5), + "a start mid-character must expand backward to keep the character" + ); + assert_eq!( + snap_to_char_boundaries(source, 2..3), + Some(1..4), + "a selection entirely inside a character must cover the whole character" + ); + assert_eq!( + snap_to_char_boundaries(source, 2..10), + Some(1..5), + "an end past the source must clamp to its length" + ); + assert_eq!( + snap_to_char_boundaries(source, 2..2), + None, + "an empty selection must collapse, even mid-character" + ); + assert_eq!( + snap_to_char_boundaries(source, 5..10), + None, + "a selection entirely past the source must collapse" + ); + assert_eq!( + snap_to_char_boundaries(source, Range { start: 4, end: 2 }), + None, + "a reversed selection must collapse" + ); + } + + fn selection_is_plain(marked_source: &str) -> bool { + let (source, selection) = marked_range(marked_source); + selection_is_only_inside_code_spans(&inline_spans(&source), &selection) + } + + #[test] + fn test_selection_is_only_inside_code_spans() { + assert!( + selection_is_plain("run `«cargo» test` now"), + "a selection fully inside the code span's content must be plain" + ); + assert!( + !selection_is_plain("«run `cargo» test` now"), + "a selection reaching outside the code span must not be plain" + ); + assert!( + !selection_is_plain("**`«code»`**"), + "code nested in bold must not be plain: the bold span also contains it" + ); + } + + #[test] + fn test_markdown_for_selection_balances_inline_spans() { + assert_marked_selection("This is **«bold»** text in a sentence.", "**bold**"); + assert_marked_selection("This is **«bold**» text in a sentence.", "**bold**"); + assert_marked_selection("Th«is is **bo»ld** text in a sentence.", "is is **bo**"); + + assert_marked_selection("This is *«italic»* text in a sentence.", "*italic*"); + assert_marked_selection("This is *it«al»ic* text in a sentence.", "*al*"); + + assert_marked_selection("T«his is `cod»e` all `in one` sentence.", "his is `cod`"); + assert_marked_selection( + "This is `c«ode` all `in o»ne` sentence.", + "`ode` all `in o`", + ); + assert_marked_selection( + "This is `«code` all `in one»` sentence.", + "`code` all `in one`", + ); + assert_marked_selection( + "This is `«code` all `in one`» sentence.", + "`code` all `in one`", + ); + + // Special case for single inline code blocks + assert_marked_selection("This is `«code»` all `in one` sentence.", "code"); + assert_marked_selection("This is `«code`» all `in one` sentence.", "code"); + assert_marked_selection("This is `c«od»e` all `in one` sentence.", "od"); + } + + #[test] + fn test_markdown_for_selection_nested_spans() { + assert_marked_selection("**bo«ld wi»th `code` inside**", "**ld wi**"); + assert_marked_selection("**bold with `c«od»e` inside**", "**`od`**"); + assert_marked_selection("**bold with `«code»` inside**", "**`code`**"); + assert_marked_selection( + "**«bold with `code` inside»**", + "**bold with `code` inside**", + ); + assert_marked_selection( + "«**bold with `code` inside**»", + "**bold with `code` inside**", + ); + } + + #[test] + fn test_markdown_for_selection_links() { + assert_marked_selection( + "[Visit Rust's we«bsite»](https://rust.org)", + "[bsite](https://rust.org)", + ); + assert_marked_selection( + "[«Visit Rust's website»](https://rust.org)", + "[Visit Rust's website](https://rust.org)", + ); + assert_marked_selection("visit https://«example».com now", "example"); + } + + #[test] + fn test_markdown_for_selection_scopes_to_boundary_blocks() { + assert_marked_selection( + "**bo«ld one**\n\nmiddle `x`\n\n*ita»lic two*", + "**ld one**\n\nmiddle `x`\n\n*ita*", + ); + assert_marked_selection("**bold** first\n\nsecond *ita«li»c* here", "*li*"); + assert_marked_selection("one\n«\ntwo»", "\ntwo"); + assert_marked_selection("**one*«*\n\ntw»o", "\n\ntw"); + } + + #[test] + fn test_root_block_index() { + let starts = [2, 10, 20]; + assert_eq!( + root_block_index(&starts, 0), + 0, + "an offset before the first block start must clamp to the first block" + ); + assert_eq!(root_block_index(&starts, 2), 0); + assert_eq!(root_block_index(&starts, 9), 0); + assert_eq!(root_block_index(&starts, 10), 1); + assert_eq!(root_block_index(&starts, 19), 1); + assert_eq!(root_block_index(&starts, 20), 2); + assert_eq!( + root_block_index(&starts, 100), + 2, + "an offset past the last block start must clamp to the last block" + ); + } + + #[test] + fn test_root_block_events_slices_each_block() { + let source = "first **a**\n\nsecond `b`\n\nthird *c*"; + let parsed = parse_markdown_with_options(source, false, false, false); + let events = parsed.events.as_slice(); + let starts = parsed.root_block_starts.as_slice(); + assert_eq!(starts, &[0, 13, 25]); + + let mut sliced_events = Vec::new(); + for block in 0..starts.len() { + let slice = root_block_events(events, starts, block); + assert!(!slice.is_empty(), "block {block} must have events"); + let block_end = starts.get(block + 1).copied().unwrap_or(source.len()); + for (range, event) in slice { + assert!( + (starts[block]..block_end).contains(&range.start), + "event {event:?} at {range:?} must start within block {block}" + ); + } + sliced_events.extend_from_slice(slice); + } + assert_eq!( + sliced_events, events, + "the per-block slices must partition all events in order" + ); + + assert_eq!( + root_block_events(events, starts, starts.len()), + events, + "an out-of-range block index must fall back to all events" + ); + } + + #[test] + fn test_markdown_for_selection_plain_text_and_blocks() { + assert_eq!( + markdown_for_marked("some «text»"), + "text", + "plain text must be unchanged" + ); + assert_eq!( + markdown_for_marked("«para one\n\n- item **bold**\n- item two»"), + "para one\n\n- item **bold**\n- item two", + "selections spanning multiple blocks must keep interior syntax as-is" + ); + assert_eq!( + markdown_for_marked("```rust\n«let x = 1;»\n```"), + "let x = 1;", + "a selection inside a fenced code block must stay plain" + ); + assert_eq!( + markdown_for("abc", 2..100), + "c", + "out-of-bounds ends must be clamped" + ); + assert_eq!( + markdown_for("abc", Range { start: 3, end: 2 }), + "", + "inverted ranges must yield an empty string" + ); + } +} diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index db6c1a80786..81349a59f3b 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1385,7 +1385,11 @@ impl SearchableItem for MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) -> String { - self.markdown.read(cx).selected_text().unwrap_or_default() + self.markdown + .read(cx) + .selected_source() + .unwrap_or_default() + .to_string() } fn activate_match( diff --git a/crates/migrator/src/migrations/m_2025_10_02/settings.rs b/crates/migrator/src/migrations/m_2025_10_02/settings.rs index 8942008e632..bb2a5bbb299 100644 --- a/crates/migrator/src/migrations/m_2025_10_02/settings.rs +++ b/crates/migrator/src/migrations/m_2025_10_02/settings.rs @@ -14,9 +14,9 @@ fn remove_formatters_on_save_inner(value: &mut Value, path: &[&str]) -> Result<( let Some(format_on_save) = obj.get("format_on_save").cloned() else { return Ok(()); }; - let is_format_on_save_set_to_formatter = format_on_save - .as_str() - .map_or(true, |s| s != "on" && s != "off"); + let is_format_on_save_set_to_formatter = format_on_save.as_str().map_or(true, |s| { + s != "on" && s != "off" && s != "modifications" && s != "modifications_if_available" + }); if !is_format_on_save_set_to_formatter { return Ok(()); } diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index 0638203d769..a8541a4ee1e 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -3,16 +3,19 @@ use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelImage, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, - Role, StopReason, TokenUsage, + LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage, + LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestToolInput, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, + LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason, + TokenUsage, util::{fix_streamed_json, parse_tool_arguments}, }; use std::pin::Pin; use std::sync::Arc; use crate::responses::{ - ContextManagement, Request as ResponseRequest, ResponseCompactionItem, ResponseError, + ContextManagement, Request as ResponseRequest, ResponseCompactionItem, + ResponseCustomToolCallItem, ResponseCustomToolCallOutputItem, ResponseError, ResponseFunctionCallItem, ResponseFunctionCallOutputContent, ResponseFunctionCallOutputItem, ResponseIncludable, ResponseInputContent, ResponseInputItem, ResponseMessageItem, ResponseOutputItem, ResponseOutputMessage, ResponseReasoningInputItem, ResponseReasoningItem, @@ -52,7 +55,17 @@ pub fn into_open_ai( max_tokens_parameter: ChatCompletionMaxTokensParameter, reasoning_effort: Option, interleaved_reasoning: bool, -) -> crate::Request { +) -> Result { + if request + .tools + .iter() + .any(|tool| matches!(tool.input, LanguageModelRequestToolInput::Custom { .. })) + { + return Err(anyhow!( + "OpenAI Chat Completions does not support custom tools; use Responses API instead" + )); + } + let stream = !model_id.starts_with("o1-"); let service_tier = service_tier_for(request.speed); @@ -103,13 +116,18 @@ pub fn into_open_ai( ); } MessageContent::ToolUse(tool_use) => { + let LanguageModelToolUseInput::Json(input) = &tool_use.input else { + return Err(anyhow!( + "OpenAI Chat Completions cannot replay custom tool call `{}`", + tool_use.name + )); + }; let tool_call = ToolCall { id: tool_use.id.to_string(), content: ToolCallContent::Function { function: FunctionContent { name: tool_use.name.to_string(), - arguments: serde_json::to_string(&tool_use.input) - .unwrap_or_default(), + arguments: serde_json::to_string(input).unwrap_or_default(), }, }, }; @@ -152,7 +170,7 @@ pub fn into_open_ai( } } - crate::Request { + Ok(crate::Request { model: model_id.into(), messages, stream, @@ -184,14 +202,21 @@ pub fn into_open_ai( tools: request .tools .into_iter() - .map(|tool| crate::ToolDefinition::Function { - function: FunctionDefinition { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - }, + .map(|tool| match tool.input { + LanguageModelRequestToolInput::Function { input_schema, .. } => { + Ok(crate::ToolDefinition::Function { + function: FunctionDefinition { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + }, + }) + } + LanguageModelRequestToolInput::Custom { .. } => Err(anyhow!( + "OpenAI Chat Completions does not support custom tools; use Responses API instead" + )), }) - .collect(), + .collect::>()?, tool_choice: request.tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => crate::ToolChoice::Auto, LanguageModelToolChoice::Any => crate::ToolChoice::Required, @@ -199,7 +224,7 @@ pub fn into_open_ai( }), reasoning_effort, service_tier, - } + }) } pub fn into_open_ai_response( @@ -232,22 +257,39 @@ pub fn into_open_ai_response( let mut input_items = Vec::new(); let mut replayed_reasoning_item_indexes = HashMap::default(); + let mut tool_use_kinds_by_id = HashMap::default(); for (index, message) in messages.into_iter().enumerate() { append_message_to_response_items( message, index, &mut replayed_reasoning_item_indexes, + &mut tool_use_kinds_by_id, &mut input_items, ); } let tools: Vec<_> = tools .into_iter() - .map(|tool| crate::responses::ToolDefinition::Function { - name: tool.name, - description: Some(tool.description), - parameters: Some(tool.input_schema), - strict: None, + .map(|tool| match tool.input { + LanguageModelRequestToolInput::Function { input_schema, .. } => { + crate::responses::ToolDefinition::Function { + name: tool.name, + description: Some(tool.description), + parameters: Some(input_schema), + strict: None, + } + } + LanguageModelRequestToolInput::Custom { format } => { + crate::responses::ToolDefinition::Custom { + name: tool.name, + description: if tool.description.is_empty() { + None + } else { + Some(tool.description) + }, + format: format.map(custom_tool_format_into_open_ai), + } + } }) .collect(); @@ -323,6 +365,7 @@ fn append_message_to_response_items( message: LanguageModelRequestMessage, index: usize, replayed_reasoning_item_indexes: &mut HashMap, + tool_use_kinds_by_id: &mut HashMap, input_items: &mut Vec, ) { let mut content_parts: Vec = Vec::new(); @@ -386,11 +429,29 @@ fn append_message_to_response_items( input_items, ); let call_id = tool_use.id.to_string(); - input_items.push(ResponseInputItem::FunctionCall(ResponseFunctionCallItem { - call_id, - name: tool_use.name.to_string(), - arguments: tool_use.raw_input, - })); + match tool_use.input { + LanguageModelToolUseInput::Json(_) => { + tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Function); + input_items.push(ResponseInputItem::FunctionCall( + ResponseFunctionCallItem { + call_id, + name: tool_use.name.to_string(), + arguments: tool_use.raw_input, + }, + )); + } + LanguageModelToolUseInput::Text(_) => { + tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Custom); + input_items.push(ResponseInputItem::CustomToolCall( + ResponseCustomToolCallItem { + id: None, + call_id, + name: tool_use.name.to_string(), + input: tool_use.raw_input, + }, + )); + } + } } MessageContent::ToolResult(tool_result) => { flush_response_parts( @@ -424,12 +485,24 @@ fn append_message_to_response_items( ResponseFunctionCallOutputContent::List(parts) } }; - input_items.push(ResponseInputItem::FunctionCallOutput( - ResponseFunctionCallOutputItem { - call_id: tool_result.tool_use_id.to_string(), - output, - }, - )); + match tool_use_kinds_by_id.get(&tool_result.tool_use_id) { + Some(ReplayToolKind::Custom) => { + input_items.push(ResponseInputItem::CustomToolCallOutput( + ResponseCustomToolCallOutputItem { + call_id: tool_result.tool_use_id.to_string(), + output, + }, + )); + } + Some(ReplayToolKind::Function) | None => { + input_items.push(ResponseInputItem::FunctionCallOutput( + ResponseFunctionCallOutputItem { + call_id: tool_result.tool_use_id.to_string(), + output, + }, + )); + } + } } } } @@ -443,6 +516,33 @@ fn append_message_to_response_items( ); } +#[derive(Clone, Copy)] +enum ReplayToolKind { + Function, + Custom, +} + +fn custom_tool_format_into_open_ai( + format: LanguageModelCustomToolFormat, +) -> crate::responses::CustomToolFormat { + match format { + LanguageModelCustomToolFormat::Text => crate::responses::CustomToolFormat::Text, + LanguageModelCustomToolFormat::Grammar { syntax, definition } => { + crate::responses::CustomToolFormat::Grammar { + syntax: match syntax { + LanguageModelCustomToolGrammarSyntax::Lark => { + crate::responses::CustomToolGrammarSyntax::Lark + } + LanguageModelCustomToolGrammarSyntax::Regex => { + crate::responses::CustomToolGrammarSyntax::Regex + } + }, + definition, + } + } + } +} + fn append_reasoning_details_to_response_items( reasoning_details: Option<&serde_json::Value>, replayed_reasoning_item_indexes: &mut HashMap, @@ -665,7 +765,7 @@ impl OpenAiEventMapper { id: entry.id.clone().into(), name: entry.name.as_str().into(), is_input_complete: false, - input, + input: LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: None, }, @@ -688,7 +788,7 @@ impl OpenAiEventMapper { id: tool_call.id.clone().into(), name: tool_call.name.as_str().into(), is_input_complete: true, - input, + input: LanguageModelToolUseInput::Json(input), raw_input: tool_call.arguments.clone(), thought_signature: None, }, @@ -736,6 +836,7 @@ struct RawToolCall { pub struct OpenAiResponseEventMapper { function_calls_by_item: HashMap, + custom_tool_calls_by_item: HashMap, reasoning_items: Vec, current_message_phase: Option, pending_stop_reason: Option, @@ -748,10 +849,17 @@ struct PendingResponseFunctionCall { arguments: String, } +struct PendingResponseCustomToolCall { + call_id: String, + name: Arc, + input: String, +} + impl OpenAiResponseEventMapper { pub fn new() -> Self { Self { function_calls_by_item: HashMap::default(), + custom_tool_calls_by_item: HashMap::default(), reasoning_items: Vec::new(), current_message_phase: None, pending_stop_reason: None, @@ -805,6 +913,23 @@ impl OpenAiResponseEventMapper { self.function_calls_by_item.insert(item_id, entry); } } + ResponseOutputItem::CustomToolCall(custom_tool_call) => { + if let Some(item_id) = custom_tool_call.id.clone() { + let call_id = custom_tool_call + .call_id + .clone() + .or_else(|| custom_tool_call.id.clone()) + .unwrap_or_else(|| item_id.clone()); + let entry = PendingResponseCustomToolCall { + call_id, + name: Arc::::from( + custom_tool_call.name.clone().unwrap_or_default(), + ), + input: custom_tool_call.input.clone(), + }; + self.custom_tool_calls_by_item.insert(item_id, entry); + } + } ResponseOutputItem::Compaction(_) => { events.push(Ok(LanguageModelCompletionEvent::Compaction( CompactionContent::Pending, @@ -848,7 +973,7 @@ impl OpenAiResponseEventMapper { id: LanguageModelToolUseId::from(entry.call_id.clone()), name: entry.name.clone(), is_input_complete: false, - input, + input: LanguageModelToolUseInput::Json(input), raw_input: entry.arguments.clone(), thought_signature: None, }, @@ -873,7 +998,7 @@ impl OpenAiResponseEventMapper { id: LanguageModelToolUseId::from(entry.call_id.clone()), name: entry.name.clone(), is_input_complete: true, - input, + input: LanguageModelToolUseInput::Json(input), raw_input, thought_signature: None, }, @@ -892,6 +1017,30 @@ impl OpenAiResponseEventMapper { Vec::new() } } + ResponsesStreamEvent::CustomToolCallInputDelta { item_id, delta, .. } => { + if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id) { + entry.input.push_str(&delta); + return vec![Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: LanguageModelToolUseId::from(entry.call_id.clone()), + name: entry.name.clone(), + is_input_complete: false, + input: LanguageModelToolUseInput::Text(entry.input.clone()), + raw_input: entry.input.clone(), + thought_signature: None, + }, + ))]; + } + Vec::new() + } + ResponsesStreamEvent::CustomToolCallInputDone { item_id, input, .. } => { + if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id) + && !input.is_empty() + { + entry.input = input; + } + self.finish_pending_custom_tool_call(&item_id, None) + } ResponsesStreamEvent::Completed { response } => { self.handle_completion(response, StopReason::EndTurn) } @@ -959,6 +1108,13 @@ impl OpenAiResponseEventMapper { ResponsesStreamEvent::OutputItemDone { item, .. } => match item { ResponseOutputItem::Reasoning(reasoning) => self.capture_reasoning_item(&reasoning), ResponseOutputItem::Message(message) => self.capture_message_phase(&message), + ResponseOutputItem::CustomToolCall(custom_tool_call) => { + if let Some(item_id) = custom_tool_call.id.as_ref() { + self.finish_pending_custom_tool_call(item_id, Some(&custom_tool_call)) + } else { + Vec::new() + } + } ResponseOutputItem::Compaction(compaction) => { vec![Ok(LanguageModelCompletionEvent::Compaction( CompactionContent::Encrypted { @@ -1015,48 +1171,109 @@ impl OpenAiResponseEventMapper { ) -> Vec> { let mut events = Vec::new(); for item in output { - if let ResponseOutputItem::FunctionCall(function_call) = item { - let Some(call_id) = function_call - .call_id - .clone() - .or_else(|| function_call.id.clone()) - else { - log::error!( - "Function call item missing both call_id and id: {:?}", - function_call - ); - continue; - }; - let name: Arc = Arc::from(function_call.name.clone().unwrap_or_default()); - let arguments = &function_call.arguments; - self.pending_stop_reason = Some(StopReason::ToolUse); - match parse_tool_arguments(arguments) { - Ok(input) => { - events.push(Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { + match item { + ResponseOutputItem::FunctionCall(function_call) => { + let Some(call_id) = function_call + .call_id + .clone() + .or_else(|| function_call.id.clone()) + else { + log::error!( + "Function call item missing both call_id and id: {:?}", + function_call + ); + continue; + }; + let name: Arc = Arc::from(function_call.name.clone().unwrap_or_default()); + let arguments = &function_call.arguments; + self.pending_stop_reason = Some(StopReason::ToolUse); + match parse_tool_arguments(arguments) { + Ok(input) => { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: LanguageModelToolUseId::from(call_id.clone()), + name: name.clone(), + is_input_complete: true, + input: LanguageModelToolUseInput::Json(input), + raw_input: arguments.clone(), + thought_signature: None, + }, + ))); + } + Err(error) => { + events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { id: LanguageModelToolUseId::from(call_id.clone()), - name: name.clone(), - is_input_complete: true, - input, - raw_input: arguments.clone(), - thought_signature: None, - }, - ))); - } - Err(error) => { - events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { - id: LanguageModelToolUseId::from(call_id.clone()), - tool_name: name.clone(), - raw_input: Arc::::from(arguments.clone()), - json_parse_error: error.to_string(), - })); + tool_name: name.clone(), + raw_input: Arc::::from(arguments.clone()), + json_parse_error: error.to_string(), + })); + } } } + ResponseOutputItem::CustomToolCall(custom_tool_call) => { + events.extend(self.emit_custom_tool_call(custom_tool_call)); + } + _ => {} } } events } + fn emit_custom_tool_call( + &mut self, + custom_tool_call: &crate::responses::ResponseCustomToolCall, + ) -> Vec> { + let Some(call_id) = custom_tool_call + .call_id + .clone() + .or_else(|| custom_tool_call.id.clone()) + else { + log::error!( + "Custom tool call item missing both call_id and id: {:?}", + custom_tool_call + ); + return Vec::new(); + }; + self.pending_stop_reason = Some(StopReason::ToolUse); + let input = custom_tool_call.input.clone(); + vec![Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: LanguageModelToolUseId::from(call_id), + name: Arc::from(custom_tool_call.name.clone().unwrap_or_default()), + is_input_complete: true, + input: LanguageModelToolUseInput::Text(input.clone()), + raw_input: input, + thought_signature: None, + }, + ))] + } + + fn finish_pending_custom_tool_call( + &mut self, + item_id: &str, + fallback: Option<&crate::responses::ResponseCustomToolCall>, + ) -> Vec> { + let Some(mut entry) = self.custom_tool_calls_by_item.remove(item_id) else { + return Vec::new(); + }; + if let Some(fallback) = fallback + && !fallback.input.is_empty() + { + entry.input = fallback.input.clone(); + } + self.pending_stop_reason = Some(StopReason::ToolUse); + vec![Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: LanguageModelToolUseId::from(entry.call_id), + name: entry.name, + is_input_complete: true, + input: LanguageModelToolUseInput::Text(entry.input.clone()), + raw_input: entry.input, + thought_signature: None, + }, + ))] + } + fn capture_reasoning_items_from_output( &mut self, output: &[ResponseOutputItem], @@ -1245,15 +1462,17 @@ fn response_reasoning_input_item_from_output( #[cfg(test)] mod tests { use crate::responses::{ - ReasoningSummaryPart, ResponseError, ResponseFunctionToolCall, ResponseIncompleteDetails, - ResponseInputTokensDetails, ResponseOutputItem, ResponseOutputMessage, - ResponseReasoningItem, ResponseSummary, ResponseUsage, StreamEvent as ResponsesStreamEvent, + ReasoningSummaryPart, ResponseCustomToolCall, ResponseError, ResponseFunctionToolCall, + ResponseIncompleteDetails, ResponseInputItem, ResponseInputTokensDetails, + ResponseOutputItem, ResponseOutputMessage, ResponseReasoningItem, ResponseSummary, + ResponseUsage, StreamEvent as ResponsesStreamEvent, ToolDefinition, }; use futures::{StreamExt, executor::block_on}; use language_model_core::{ - LanguageModelImage, LanguageModelRequestMessage, LanguageModelRequestTool, + LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage, + LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelRequestToolInput, LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse, - LanguageModelToolUseId, SharedString, Speed, + LanguageModelToolUseId, LanguageModelToolUseInput, SharedString, Speed, }; use pretty_assertions::assert_eq; use serde_json::json; @@ -1306,6 +1525,16 @@ mod tests { }) } + fn response_item_custom_tool_call(id: &str, input: &str) -> ResponseOutputItem { + ResponseOutputItem::CustomToolCall(ResponseCustomToolCall { + id: Some(id.to_string()), + status: Some("in_progress".to_string()), + name: Some("apply_patch".to_string()), + call_id: Some("call_abc".to_string()), + input: input.to_string(), + }) + } + fn response_reasoning_item( id: &str, summary: Vec, @@ -1417,6 +1646,97 @@ mod tests { Ok(()) } + #[test] + fn responses_custom_tool_wire_types_round_trip() -> Result<()> { + let tool_json = json!({ + "type": "custom", + "name": "apply_patch", + "description": "Apply a patch", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /.+/" + } + }); + let tool: ToolDefinition = serde_json::from_value(tool_json.clone())?; + assert_eq!(serde_json::to_value(tool)?, tool_json); + + let text_tool_json = json!({ + "type": "custom", + "name": "write_text", + "format": { "type": "text" } + }); + let text_tool: ToolDefinition = serde_json::from_value(text_tool_json.clone())?; + assert_eq!(serde_json::to_value(text_tool)?, text_tool_json); + + let input_json = json!({ + "type": "custom_tool_call", + "id": "ctc_1", + "call_id": "call_abc", + "name": "apply_patch", + "input": "*** Begin Patch\n*** End Patch" + }); + let input: ResponseInputItem = serde_json::from_value(input_json.clone())?; + assert_eq!(serde_json::to_value(input)?, input_json); + + let output_json = json!({ + "type": "custom_tool_call_output", + "call_id": "call_abc", + "output": "ok" + }); + let output: ResponseInputItem = serde_json::from_value(output_json.clone())?; + assert_eq!(serde_json::to_value(output)?, output_json); + + let output_item_json = json!({ + "id": "ctc_1", + "type": "custom_tool_call", + "status": "completed", + "call_id": "call_abc", + "name": "apply_patch", + "input": "*** Begin Patch\n*** End Patch" + }); + let output_item: ResponseOutputItem = serde_json::from_value(output_item_json.clone())?; + assert_eq!(serde_json::to_value(output_item)?, output_item_json); + + let delta_json = json!({ + "type": "response.custom_tool_call_input.delta", + "output_index": 0, + "item_id": "ctc_1", + "sequence_number": 5, + "delta": "chunk" + }); + let delta: ResponsesStreamEvent = serde_json::from_value(delta_json)?; + assert!(matches!( + delta, + ResponsesStreamEvent::CustomToolCallInputDelta { + output_index: 0, + sequence_number: Some(5), + ref item_id, + ref delta, + } if item_id == "ctc_1" && delta == "chunk" + )); + + let done_json = json!({ + "type": "response.custom_tool_call_input.done", + "output_index": 0, + "item_id": "ctc_1", + "sequence_number": 6, + "input": "full text" + }); + let done: ResponsesStreamEvent = serde_json::from_value(done_json)?; + assert!(matches!( + done, + ResponsesStreamEvent::CustomToolCallInputDone { + output_index: 0, + sequence_number: Some(6), + ref item_id, + ref input, + } if item_id == "ctc_1" && input == "full text" + )); + + Ok(()) + } + #[test] fn into_open_ai_response_builds_complete_payload() { let tool_call_id = LanguageModelToolUseId::from("call-42"); @@ -1426,7 +1746,7 @@ mod tests { id: tool_call_id.clone(), name: Arc::from("get_weather"), raw_input: tool_arguments.clone(), - input: tool_input, + input: LanguageModelToolUseInput::Json(tool_input), is_input_complete: true, thought_signature: None, }; @@ -1478,12 +1798,12 @@ mod tests { reasoning_details: None, }, ], - tools: vec![LanguageModelRequestTool { - name: "get_weather".into(), - description: "Fetches the weather".into(), - input_schema: json!({ "type": "object" }), - use_input_streaming: false, - }], + tools: vec![LanguageModelRequestTool::function( + "get_weather".into(), + "Fetches the weather".into(), + json!({ "type": "object" }), + false, + )], tool_choice: Some(LanguageModelToolChoice::Any), stop: vec!["".into()], temperature: None, @@ -1562,6 +1882,166 @@ mod tests { assert_eq!(serialized, expected); } + #[test] + fn responses_stream_maps_custom_tool_input() { + let events = vec![ + ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: response_item_custom_tool_call("ctc_1", ""), + }, + ResponsesStreamEvent::CustomToolCallInputDelta { + item_id: "ctc_1".into(), + output_index: 0, + delta: "*** Begin".into(), + sequence_number: Some(1), + }, + ResponsesStreamEvent::CustomToolCallInputDelta { + item_id: "ctc_1".into(), + output_index: 0, + delta: " Patch".into(), + sequence_number: Some(2), + }, + ResponsesStreamEvent::CustomToolCallInputDone { + item_id: "ctc_1".into(), + output_index: 0, + input: "*** Begin Patch".into(), + sequence_number: Some(3), + }, + ResponsesStreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: response_item_custom_tool_call("ctc_1", "*** Begin Patch"), + }, + ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }, + ]; + + let mapped = map_response_events(events); + assert_eq!( + mapped, + vec![ + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: LanguageModelToolUseId::from("call_abc"), + name: Arc::from("apply_patch"), + raw_input: "*** Begin".into(), + input: LanguageModelToolUseInput::Text("*** Begin".into()), + is_input_complete: false, + thought_signature: None, + }), + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: LanguageModelToolUseId::from("call_abc"), + name: Arc::from("apply_patch"), + raw_input: "*** Begin Patch".into(), + input: LanguageModelToolUseInput::Text("*** Begin Patch".into()), + is_input_complete: false, + thought_signature: None, + }), + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: LanguageModelToolUseId::from("call_abc"), + name: Arc::from("apply_patch"), + raw_input: "*** Begin Patch".into(), + input: LanguageModelToolUseInput::Text("*** Begin Patch".into()), + is_input_complete: true, + thought_signature: None, + }), + LanguageModelCompletionEvent::Stop(StopReason::ToolUse), + ] + ); + } + + #[test] + fn into_open_ai_response_replays_custom_tool_calls() { + let tool_call_id = LanguageModelToolUseId::from("call_abc"); + let raw_input = "*** Begin Patch\n*** End Patch".to_string(); + let tool_use = LanguageModelToolUse { + id: tool_call_id.clone(), + name: Arc::from("apply_patch"), + raw_input: raw_input.clone(), + input: LanguageModelToolUseInput::Text(raw_input.clone()), + is_input_complete: true, + thought_signature: None, + }; + let tool_result = LanguageModelToolResult { + tool_use_id: tool_call_id, + tool_name: Arc::from("apply_patch"), + is_error: false, + content: vec![LanguageModelToolResultContent::Text(Arc::from("ok"))], + output: None, + }; + + let request = LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: None, + messages: vec![LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::ToolUse(tool_use), + MessageContent::ToolResult(tool_result), + ], + cache: false, + reasoning_details: None, + }], + tools: vec![LanguageModelRequestTool { + name: "apply_patch".into(), + description: "Apply a patch".into(), + input: LanguageModelRequestToolInput::Custom { + format: Some(LanguageModelCustomToolFormat::Grammar { + syntax: LanguageModelCustomToolGrammarSyntax::Lark, + definition: "start: /.+/".into(), + }), + }, + }], + tool_choice: None, + stop: Vec::new(), + temperature: None, + thinking_allowed: false, + thinking_effort: None, + speed: None, + compact_at_tokens: None, + }; + + let response = + into_open_ai_response(request, "custom-model", false, false, None, None, false); + let serialized = serde_json::to_value(response).unwrap(); + assert_eq!( + serialized, + json!({ + "model": "custom-model", + "input": [ + { + "type": "custom_tool_call", + "call_id": "call_abc", + "name": "apply_patch", + "input": raw_input + }, + { + "type": "custom_tool_call_output", + "call_id": "call_abc", + "output": "ok" + } + ], + "store": false, + "stream": true, + "parallel_tool_calls": false, + "tools": [ + { + "type": "custom", + "name": "apply_patch", + "description": "Apply a patch", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /.+/" + } + } + ] + }) + ); + } + #[test] fn into_open_ai_response_replays_encrypted_reasoning_details() { let tool_call_id = LanguageModelToolUseId::from("call-42"); @@ -1570,7 +2050,7 @@ mod tests { id: tool_call_id, name: Arc::from("get_weather"), raw_input: tool_arguments.clone(), - input: json!({ "city": "Boston" }), + input: LanguageModelToolUseInput::Json(json!({ "city": "Boston" })), is_input_complete: true, thought_signature: None, }; @@ -1850,7 +2330,7 @@ mod tests { ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, - ); + )?; let serialized = serde_json::to_value(&chat)?; assert_eq!( @@ -1895,7 +2375,7 @@ mod tests { ChatCompletionMaxTokensParameter::MaxTokens, None, false, - ); + )?; let serialized = serde_json::to_value(&chat)?; assert_eq!(serialized.get("max_completion_tokens"), None); @@ -2700,8 +3180,7 @@ mod tests { }) if id.to_string() == "call_123" && name.as_ref() == "get_weather" && raw_input == "" - && input.is_object() - && input.as_object().unwrap().is_empty() + && matches!(input, LanguageModelToolUseInput::Json(value) if value.as_object().is_some_and(|object| object.is_empty())) )); assert!(matches!( mapped[1], @@ -3198,7 +3677,7 @@ mod tests { id: tool_use_id.clone(), name: Arc::from("search"), raw_input: tool_arguments.clone(), - input: tool_input, + input: LanguageModelToolUseInput::Json(tool_input), is_input_complete: true, thought_signature: None, }; @@ -3259,7 +3738,8 @@ mod tests { ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, true, - ); + ) + .unwrap(); assert_eq!( serde_json::to_value(&result).unwrap()["messages"], json!([ @@ -3283,7 +3763,8 @@ mod tests { ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, - ); + ) + .unwrap(); assert_eq!( serde_json::to_value(&result).unwrap()["messages"], json!([ diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index ec25a80d7e2..3f1502b9cb0 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -68,7 +68,6 @@ pub enum Model { #[serde(rename = "gpt-5")] Five, #[serde(rename = "gpt-5-mini")] - #[default] FiveMini, #[serde(rename = "gpt-5-nano")] FiveNano, @@ -90,6 +89,13 @@ pub enum Model { FivePointFive, #[serde(rename = "gpt-5.5-pro")] FivePointFivePro, + #[serde(rename = "gpt-5.6-sol")] + #[default] + FivePointSixSol, + #[serde(rename = "gpt-5.6-terra")] + FivePointSixTerra, + #[serde(rename = "gpt-5.6-luna")] + FivePointSixLuna, #[serde(rename = "custom")] Custom { name: String, @@ -116,7 +122,7 @@ const fn default_supports_images() -> bool { impl Model { pub fn default_fast() -> Self { - Self::FiveMini + Self::FivePointSixLuna } pub fn from_id(id: &str) -> Result { @@ -136,6 +142,9 @@ impl Model { "gpt-5.4-pro" => Ok(Self::FivePointFourPro), "gpt-5.5" => Ok(Self::FivePointFive), "gpt-5.5-pro" => Ok(Self::FivePointFivePro), + "gpt-5.6-sol" => Ok(Self::FivePointSixSol), + "gpt-5.6-terra" => Ok(Self::FivePointSixTerra), + "gpt-5.6-luna" => Ok(Self::FivePointSixLuna), invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"), } } @@ -157,6 +166,9 @@ impl Model { Self::FivePointFourPro => "gpt-5.4-pro", Self::FivePointFive => "gpt-5.5", Self::FivePointFivePro => "gpt-5.5-pro", + Self::FivePointSixSol => "gpt-5.6-sol", + Self::FivePointSixTerra => "gpt-5.6-terra", + Self::FivePointSixLuna => "gpt-5.6-luna", Self::Custom { name, .. } => name, } } @@ -178,6 +190,9 @@ impl Model { Self::FivePointFourPro => "gpt-5.4-pro", Self::FivePointFive => "gpt-5.5", Self::FivePointFivePro => "gpt-5.5-pro", + Self::FivePointSixSol => "gpt-5.6-sol", + Self::FivePointSixTerra => "gpt-5.6-terra", + Self::FivePointSixLuna => "gpt-5.6-luna", Self::Custom { display_name, .. } => display_name.as_deref().unwrap_or(&self.id()), } } @@ -199,6 +214,9 @@ impl Model { Self::FivePointFourPro => 1_050_000, Self::FivePointFive => 1_050_000, Self::FivePointFivePro => 1_050_000, + Self::FivePointSixSol => 1_050_000, + Self::FivePointSixTerra => 1_050_000, + Self::FivePointSixLuna => 1_050_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -223,6 +241,9 @@ impl Model { Self::FivePointFourPro => Some(128_000), Self::FivePointFive => Some(128_000), Self::FivePointFivePro => Some(128_000), + Self::FivePointSixSol => Some(128_000), + Self::FivePointSixTerra => Some(128_000), + Self::FivePointSixLuna => Some(128_000), } } @@ -236,6 +257,7 @@ impl Model { | Self::FivePointFour | Self::FivePointFourMini | Self::FivePointFourNano => Some(ReasoningEffort::None), + Self::FivePointSixSol => Some(ReasoningEffort::Low), Self::O3 | Self::Five | Self::FiveMini @@ -243,7 +265,9 @@ impl Model { | Self::FivePointThreeCodex | Self::FivePointFourPro | Self::FivePointFive - | Self::FivePointFivePro => Some(ReasoningEffort::Medium), + | Self::FivePointFivePro + | Self::FivePointSixTerra + | Self::FivePointSixLuna => Some(ReasoningEffort::Medium), _ => None, } } @@ -290,6 +314,14 @@ impl Model { ReasoningEffort::High, ReasoningEffort::XHigh, ], + Self::FivePointSixSol | Self::FivePointSixTerra | Self::FivePointSixLuna => &[ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ReasoningEffort::Max, + ], Self::FivePointTwo | Self::FivePointFour | Self::FivePointFive @@ -333,6 +365,9 @@ impl Model { | Self::FivePointFourPro | Self::FivePointFive | Self::FivePointFivePro + | Self::FivePointSixSol + | Self::FivePointSixTerra + | Self::FivePointSixLuna | Self::FiveNano => true, Self::O3 | Model::Custom { .. } => false, } @@ -360,7 +395,10 @@ impl Model { | Self::FivePointFour | Self::FivePointFourPro | Self::FivePointFive - | Self::FivePointFivePro => true, + | Self::FivePointFivePro + | Self::FivePointSixSol + | Self::FivePointSixTerra + | Self::FivePointSixLuna => true, Self::Four | Self::FourOmniMini | Self::O3 @@ -387,7 +425,10 @@ impl Model { | Self::FivePointThreeCodex | Self::FivePointFourMini | Self::FivePointFour - | Self::FivePointFive => true, + | Self::FivePointFive + | Self::FivePointSixSol + | Self::FivePointSixTerra + | Self::FivePointSixLuna => true, Self::Four | Self::FiveNano | Self::FivePointFourNano diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 33ec1dfba46..f508a274605 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -66,6 +66,8 @@ pub enum ResponseInputItem { Message(ResponseMessageItem), FunctionCall(ResponseFunctionCallItem), FunctionCallOutput(ResponseFunctionCallOutputItem), + CustomToolCall(ResponseCustomToolCallItem), + CustomToolCallOutput(ResponseCustomToolCallOutputItem), Reasoning(ResponseReasoningInputItem), Compaction(ResponseCompactionItem), } @@ -98,6 +100,21 @@ pub struct ResponseFunctionCallOutputItem { pub output: ResponseFunctionCallOutputContent, } +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponseCustomToolCallItem { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub call_id: String, + pub name: String, + pub input: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResponseCustomToolCallOutputItem { + pub call_id: String, + pub output: ResponseFunctionCallOutputContent, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ResponseReasoningInputItem { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -157,7 +174,7 @@ pub enum ReasoningSummaryMode { Detailed, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ToolDefinition { Function { @@ -169,9 +186,33 @@ pub enum ToolDefinition { #[serde(skip_serializing_if = "Option::is_none")] strict: Option, }, + Custom { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, + }, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CustomToolFormat { + Text, + Grammar { + syntax: CustomToolGrammarSyntax, + definition: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CustomToolGrammarSyntax { + Lark, + Regex, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResponseError { #[serde(default)] pub code: Option, @@ -343,6 +384,22 @@ pub enum StreamEvent { #[serde(default)] sequence_number: Option, }, + #[serde(rename = "response.custom_tool_call_input.delta")] + CustomToolCallInputDelta { + item_id: String, + output_index: usize, + delta: String, + #[serde(default)] + sequence_number: Option, + }, + #[serde(rename = "response.custom_tool_call_input.done")] + CustomToolCallInputDone { + item_id: String, + output_index: usize, + input: String, + #[serde(default)] + sequence_number: Option, + }, #[serde(rename = "response.completed")] Completed { response: ResponseSummary }, #[serde(rename = "response.incomplete")] @@ -360,7 +417,7 @@ pub enum StreamEvent { Unknown, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseSummary { #[serde(default)] pub id: Option, @@ -378,13 +435,13 @@ pub struct ResponseSummary { pub service_tier: Option, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseIncompleteDetails { #[serde(default)] pub reason: Option, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseUsage { #[serde(default)] pub input_tokens: Option, @@ -398,30 +455,32 @@ pub struct ResponseUsage { pub total_tokens: Option, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseInputTokensDetails { #[serde(default)] pub cached_tokens: u64, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseOutputTokensDetails { #[serde(default)] pub reasoning_tokens: u64, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseOutputItem { Message(ResponseOutputMessage), FunctionCall(ResponseFunctionToolCall), + CustomToolCall(ResponseCustomToolCall), Reasoning(ResponseReasoningItem), Compaction(ResponseCompactionItem), + /// Deserialization-only catch-all; must never be serialized back to the API. #[serde(other)] Unknown, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResponseReasoningItem { #[serde(default)] pub id: Option, @@ -435,7 +494,7 @@ pub struct ResponseReasoningItem { pub status: Option, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningSummaryPart { SummaryText { @@ -445,7 +504,7 @@ pub enum ReasoningSummaryPart { Unknown, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResponseOutputMessage { #[serde(default)] pub id: Option, @@ -459,7 +518,7 @@ pub struct ResponseOutputMessage { pub phase: Option, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResponseFunctionToolCall { #[serde(default)] pub id: Option, @@ -473,6 +532,20 @@ pub struct ResponseFunctionToolCall { pub status: Option, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ResponseCustomToolCall { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub call_id: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub input: String, +} + pub async fn stream_response( client: &dyn HttpClient, provider_name: &str, @@ -580,6 +653,16 @@ pub async fn stream_response( }); } } + ResponseOutputItem::CustomToolCall(custom_tool_call) => { + if let Some(ref item_id) = custom_tool_call.id { + all_events.push(StreamEvent::CustomToolCallInputDone { + item_id: item_id.clone(), + output_index, + input: custom_tool_call.input.clone(), + sequence_number: None, + }); + } + } ResponseOutputItem::Reasoning(reasoning) => { if let Some(ref item_id) = reasoning.id { for part in &reasoning.summary { diff --git a/crates/picker/src/footer.rs b/crates/picker/src/footer.rs index ff0b11caa94..a91ec6b5b4e 100644 --- a/crates/picker/src/footer.rs +++ b/crates/picker/src/footer.rs @@ -123,7 +123,7 @@ impl Picker { fn render_preview_controls( &self, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let focus_handle = self.focus_handle(cx); @@ -132,6 +132,12 @@ impl Picker { let current = self.preview_layout().unwrap_or(preview::Layout::Hidden); let preview_visible = current != preview::Layout::Hidden; + let diff_split = if self.is_auto_vertical(window) { + IconName::DiffSplitAuto + } else { + IconName::DiffSplit + }; + h_flex() .child( Button::new("picker-preview-toggle", "Preview") @@ -146,22 +152,6 @@ impl Picker { ) .when(preview_visible, |this| { this.child(Divider::vertical().mx_1()) - .child( - IconButton::new("picker-preview-right", IconName::DiffSplit) - .icon_size(IconSize::Small) - .toggle_state(current == preview::Layout::Right) - .tooltip(move |_window, cx| { - Tooltip::for_action_in( - "Preview to the Right", - &SetPreviewRight, - &right_focus_handle, - cx, - ) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.set_preview_layout(preview::Layout::Right, window, cx) - })), - ) .child( IconButton::new("picker-preview-below", IconName::DiffUnified) .icon_size(IconSize::Small) @@ -178,6 +168,22 @@ impl Picker { this.set_preview_layout(preview::Layout::Below, window, cx) })), ) + .child( + IconButton::new("picker-preview-right", diff_split) + .icon_size(IconSize::Small) + .toggle_state(current == preview::Layout::Right) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Preview to the Right", + &SetPreviewRight, + &right_focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.set_preview_layout(preview::Layout::Right, window, cx) + })), + ) }) } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 7915e6fd001..c74e78f1e84 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -1280,10 +1280,34 @@ impl Picker { fn preview_layout(&self) -> Option { self.preview.as_ref().map(|p| p.layout) } + fn is_auto_vertical(&self, window: &Window) -> bool { + self.preview_layout() == Some(preview::Layout::Right) + && self + .size_bounds + .would_clamp_width_if_horizontal(&self.shape, window) + } + /// To check whether we're rendering vertically instead of + /// horizontally due to the auto override + fn preview_layout_rendered(&self, window: &Window) -> Option { + let would_clamp = matches!( + self.preview, + Some(Preview { + layout: preview::Layout::Right, + .. + }) + ) && self.is_auto_vertical(window); + if would_clamp { + Some(preview::Layout::Below) + } else { + self.preview_layout() + } + } #[cfg(any(test, feature = "test-support"))] pub fn results_width(&self, window: &Window) -> gpui::Pixels { - let layout = self.preview_layout().unwrap_or(preview::Layout::Hidden); + let layout = self + .preview_layout_rendered(window) + .unwrap_or(preview::Layout::Hidden); let pos = self .shape .results_position_and_size(layout, &self.size_bounds, window); diff --git a/crates/picker/src/render.rs b/crates/picker/src/render.rs index 4ff7c425622..e60b46469c8 100644 --- a/crates/picker/src/render.rs +++ b/crates/picker/src/render.rs @@ -22,7 +22,9 @@ pub mod window_controls; impl Render for Picker { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.finish_any_completed_resize(window, cx); - + // toggle between BelowForced and Right based on whether it'd clamp if + // horizontal + let rendered_layout = self.preview_layout_rendered(window); let content = match &self.preview { Some( preview @ Preview { @@ -32,6 +34,15 @@ impl Render for Picker { ) => self .render_with_preview_below(preview, window, cx) .into_any_element(), + // render sideways based on bounds + Some( + preview @ Preview { + layout: Layout::Right, + .. + }, + ) if rendered_layout == Some(Layout::Below) => self + .render_with_preview_below(preview, window, cx) + .into_any_element(), Some( preview @ Preview { layout: Layout::Right, @@ -58,7 +69,9 @@ impl Render for Picker { .when(has_preview, |this| this.overflow_hidden()) .child(content); - let layout = self.preview_layout().unwrap_or(Layout::Hidden); + let layout = self + .preview_layout_rendered(window) + .unwrap_or(Layout::Hidden); div() .relative() @@ -101,7 +114,7 @@ impl Picker { .relative() .map(|this| { self.shape.apply_results_size( - self.preview_layout(), + self.preview_layout_rendered(window), &self.size_bounds, self.fill_height(), this, @@ -309,7 +322,11 @@ impl Picker { ), ) .when(self.is_resizable(), |this| { - this.child(self.render_resize(window_controls::Middle(preview.layout), window, cx)) + this.child(self.render_resize( + window_controls::Middle(preview::Layout::Below), + window, + cx, + )) }) } @@ -365,7 +382,8 @@ impl Picker { if let Shape::Resizing(pos) = self.shape && !cx.has_active_drag() { - let centered = Shape::centered_and_relative(pos, self.preview_layout(), window); + let centered = + Shape::centered_and_relative(pos, self.preview_layout_rendered(window), window); persistence::store_shape_for_this_layout( D::name(), self.preview_layout(), diff --git a/crates/picker/src/render/window_controls.rs b/crates/picker/src/render/window_controls.rs index a604a600e1f..cc7fbf2dc7c 100644 --- a/crates/picker/src/render/window_controls.rs +++ b/crates/picker/src/render/window_controls.rs @@ -438,7 +438,7 @@ impl Picker { side.position( this, self.shape.clamped_position_and_size( - self.preview_layout(), + self.preview_layout_rendered(window), &self.size_bounds, window, ), @@ -451,7 +451,7 @@ impl Picker { ResizeDrag::::start_new( self.shape, &self.size_bounds, - self.preview_layout(), + self.preview_layout_rendered(window), window, ), |_, _, _, cx| cx.new(|_| DragPreview), @@ -464,7 +464,7 @@ impl Picker { side.clamp( &mut working, &this.size_bounds, - this.preview_layout(), + this.preview_layout_rendered(window), window, ); this.shape = Shape::Resizing(working); @@ -487,9 +487,11 @@ impl Picker { return; } side.revert_to_default_size(&mut self.shape, &self.default_shape, window); - let pos = - self.shape - .clamped_position_and_size(self.preview_layout(), &self.size_bounds, window); + let pos = self.shape.clamped_position_and_size( + self.preview_layout_rendered(window), + &self.size_bounds, + window, + ); self.shape = Shape::Resizing(pos); cx.notify(); } diff --git a/crates/picker/src/shape.rs b/crates/picker/src/shape.rs index d37e2b0aae5..217ff6a8fc5 100644 --- a/crates/picker/src/shape.rs +++ b/crates/picker/src/shape.rs @@ -20,6 +20,12 @@ pub(crate) struct PositionAndShape { pub(crate) preview: Pixels, } +impl PositionAndShape { + pub(crate) fn width(&self) -> Pixels { + self.right - self.left + } +} + macro_rules! relative_size { ($name:ident, $accessor:ident) => { /// Size type that is the sum of a relative size to the viewport and a @@ -379,6 +385,16 @@ impl SizeBounds { working.preview = working.preview.clamp(min_preview, max_preview); } + pub(crate) fn would_clamp_width_if_horizontal(&self, shape: &Shape, window: &Window) -> bool { + let min_width = self.min_width(Some(Layout::Right), window); + + let unbounded_width = shape + .picker_position_and_size(Some(Layout::Right), window) + .width(); + + unbounded_width <= min_width + } + /// Clamps a whole picker rect (results + preview) into bounds: the total size /// against the per-layout min/max, then the divider so both panes keep their /// minimums. Width is clamped about its center, height anchored at the top. diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index f9076753998..816cf97c865 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -647,6 +647,12 @@ impl LocalBufferStore { let path = path.clone(); let buffer = match load_file.await { Ok(loaded) => { + let is_writable = loaded.is_writable; + let capability = if is_writable { + Capability::ReadWrite + } else { + Capability::Read + }; let reservation = cx.reserve_entity::(); let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64()); let text_buffer = cx @@ -655,8 +661,7 @@ impl LocalBufferStore { }) .await; cx.insert_entity(reservation, |_| { - let mut buffer = - Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite); + let mut buffer = Buffer::build(text_buffer, Some(loaded.file), capability); buffer.set_encoding(loaded.encoding); buffer.set_has_bom(loaded.has_bom); buffer diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index bbd53e1e733..3fb29cdb1c9 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -22,7 +22,7 @@ use rand::Rng as _; use registry::ContextServerDescriptorRegistry; use remote::{Interactive, RemoteClient}; use rpc::{AnyProtoClient, TypedEnvelope, proto}; -use settings::{Settings as _, SettingsStore}; +use settings::{Settings as _, SettingsLocation, SettingsStore, WorktreeId}; use util::{ResultExt as _, rel_path::RelPath}; use crate::{ @@ -280,9 +280,15 @@ enum ContextServerStoreState { }, } +#[derive(Clone, PartialEq)] +struct ContextServerSettingsEntry { + worktree_id: Option, + settings: ContextServerSettings, +} + pub struct ContextServerStore { state: ContextServerStoreState, - context_server_settings: HashMap, ContextServerSettings>, + context_server_settings: HashMap, ContextServerSettingsEntry>, servers: HashMap, server_ids: Vec, worktree_store: Entity, @@ -365,7 +371,7 @@ impl ContextServerStore { pub fn configured_server_ids(&self) -> Vec { self.context_server_settings .iter() - .filter(|(_, settings)| settings.enabled()) + .filter(|(_, entry)| entry.settings.enabled()) .map(|(id, _)| ContextServerId(id.clone())) .collect() } @@ -451,12 +457,11 @@ impl ContextServerStore { let ai_was_disabled = this.ai_disabled; this.ai_disabled = ai_disabled; - let settings = - &Self::resolve_project_settings(&this.worktree_store, cx).context_servers; - let settings_changed = &this.context_server_settings != settings; + let settings = Self::resolve_all_context_server_settings(&this.worktree_store, cx); + let settings_changed = this.context_server_settings != settings; if settings_changed { - this.context_server_settings = settings.clone(); + this.context_server_settings = settings; } // When AI is disabled, stop all running servers @@ -496,9 +501,7 @@ impl ContextServerStore { let mut this = Self { state, _subscriptions: subscriptions, - context_server_settings: Self::resolve_project_settings(&worktree_store, cx) - .context_servers - .clone(), + context_server_settings: Self::resolve_all_context_server_settings(&worktree_store, cx), worktree_store, project: weak_project, registry, @@ -542,7 +545,9 @@ impl ContextServerStore { /// or project settings. This is available regardless of whether the server is /// currently running, unlike [`Self::configuration_for_server`]. pub fn settings_for_server(&self, id: &ContextServerId) -> Option<&ContextServerSettings> { - self.context_server_settings.get(&id.0) + self.context_server_settings + .get(&id.0) + .map(|entry| &entry.settings) } /// Returns whether a server is provided by an extension (as opposed to a @@ -552,7 +557,7 @@ impl ContextServerStore { /// configuration, so it stays correct even when a custom server is disabled /// or has not been started yet (in which case it has no runtime state). pub fn is_extension_provided(&self, id: &ContextServerId, cx: &App) -> bool { - match self.context_server_settings.get(&id.0) { + match self.settings_for_server(id) { Some(ContextServerSettings::Stdio { .. } | ContextServerSettings::Http { .. }) => false, Some(ContextServerSettings::Extension { .. }) => true, // No custom settings entry: the server can only originate from an @@ -624,13 +629,13 @@ impl ContextServerStore { cx.spawn(async move |this, cx| { let this = this.upgrade().context("Context server store dropped")?; let id = server.id(); - let settings = this + let settings_entry = this .update(cx, |this, _| { this.context_server_settings.get(&id.0).cloned() }) .context("Failed to get context server settings")?; - if !settings.enabled() { + if !settings_entry.settings.enabled() { return anyhow::Ok(()); } @@ -638,7 +643,7 @@ impl ContextServerStore { (this.registry.clone(), this.worktree_store.clone()) }); let configuration = ContextServerConfiguration::from_settings( - settings, + settings_entry.settings, id.clone(), registry, worktree_store, @@ -981,8 +986,7 @@ impl ContextServerStore { }; let server: Arc = this.update(cx, |this, cx| { - let global_timeout = - Self::resolve_project_settings(&this.worktree_store, cx).context_server_timeout; + let global_timeout = this.timeout_for_server(&id, cx); match configuration.as_ref() { ContextServerConfiguration::Http { @@ -1039,31 +1043,37 @@ impl ContextServerStore { ) -> Result { let server_id = ContextServerId(envelope.payload.server_id.into()); - let (settings, registry, worktree_store) = this.update(&mut cx, |this, inner_cx| { - let ContextServerStoreState::Local { - is_headless: true, .. - } = &this.state - else { - anyhow::bail!("unexpected GetContextServerCommand request in a non-local project"); - }; + let (settings_entry, registry, worktree_store) = + this.update(&mut cx, |this, inner_cx| { + let ContextServerStoreState::Local { + is_headless: true, .. + } = &this.state + else { + anyhow::bail!( + "unexpected GetContextServerCommand request in a non-local project" + ); + }; - let settings = this - .context_server_settings - .get(&server_id.0) - .cloned() - .or_else(|| { - this.registry - .read(inner_cx) - .context_server_descriptor(&server_id.0) - .map(|_| ContextServerSettings::default_extension()) - }) - .with_context(|| format!("context server `{}` not found", server_id))?; + let settings = this + .context_server_settings + .get(&server_id.0) + .cloned() + .or_else(|| { + this.registry + .read(inner_cx) + .context_server_descriptor(&server_id.0) + .map(|_| ContextServerSettingsEntry { + worktree_id: None, + settings: ContextServerSettings::default_extension(), + }) + }) + .with_context(|| format!("context server `{}` not found", server_id))?; - anyhow::Ok((settings, this.registry.clone(), this.worktree_store.clone())) - })?; + anyhow::Ok((settings, this.registry.clone(), this.worktree_store.clone())) + })?; let configuration = ContextServerConfiguration::from_settings( - settings, + settings_entry.settings, server_id.clone(), registry, worktree_store, @@ -1087,19 +1097,29 @@ impl ContextServerStore { }) } - fn resolve_project_settings<'a>( - worktree_store: &'a Entity, - cx: &'a App, - ) -> &'a ProjectSettings { - let location = worktree_store - .read(cx) - .visible_worktrees(cx) - .next() - .map(|worktree| settings::SettingsLocation { - worktree_id: worktree.read(cx).id(), + /// Merges context server settings from all visible worktrees so that servers defined + /// in any project folder in a multi-root workspace are picked up. + fn resolve_all_context_server_settings( + worktree_store: &Entity, + cx: &App, + ) -> HashMap, ContextServerSettingsEntry> { + let mut merged = HashMap::default(); + for worktree in worktree_store.read(cx).visible_worktrees(cx) { + let worktree_id = worktree.read(cx).id(); + let location = settings::SettingsLocation { + worktree_id, path: RelPath::empty(), - }); - ProjectSettings::get(location, cx) + }; + for (id, settings) in &ProjectSettings::get(Some(location), cx).context_servers { + merged + .entry(id.clone()) + .or_insert_with(|| ContextServerSettingsEntry { + worktree_id: Some(worktree_id), + settings: settings.clone(), + }); + } + } + merged } fn create_oauth_token_provider( @@ -1134,6 +1154,23 @@ impl ContextServerStore { )) } + fn timeout_for_server(&self, id: &ContextServerId, cx: &App) -> u64 { + let worktree_id = self + .context_server_settings + .get(&id.0) + .as_ref() + .and_then(|entry| entry.worktree_id); + + ProjectSettings::get( + worktree_id.map(|id| SettingsLocation { + worktree_id: id, + path: &RelPath::empty(), + }), + cx, + ) + .context_server_timeout + } + /// Initiate the OAuth browser flow for a server in the `AuthRequired` state. /// /// This starts a loopback HTTP callback server on an ephemeral port, builds @@ -1146,6 +1183,7 @@ impl ContextServerStore { cx: &mut Context, ) -> Result<()> { let state = self.servers.get(id).context("Context server not found")?; + let global_timeout = self.timeout_for_server(id, cx); let (discovery, server, configuration) = match state { ContextServerState::AuthRequired { @@ -1204,6 +1242,7 @@ impl ContextServerStore { id.clone(), discovery.clone(), configuration.clone(), + global_timeout, cx, ) .await; @@ -1247,6 +1286,7 @@ impl ContextServerStore { cx: &mut Context, ) -> Result<()> { let state = self.servers.get(id).context("Context server not found")?; + let global_timeout = self.timeout_for_server(id, cx); let (server, configuration, discovery) = match state { ContextServerState::ClientSecretRequired { @@ -1290,6 +1330,7 @@ impl ContextServerStore { id.clone(), discovery.clone(), configuration.clone(), + global_timeout, cx, ) .await; @@ -1359,6 +1400,7 @@ impl ContextServerStore { id: ContextServerId, discovery: Arc, configuration: Arc, + global_timeout: u64, cx: &mut AsyncApp, ) -> Result<()> { let resource = oauth::canonical_server_uri(&discovery.resource_metadata.resource); @@ -1459,34 +1501,29 @@ impl ContextServerStore { cx, ); - let new_server = this.update(cx, |this, cx| { - let global_timeout = - Self::resolve_project_settings(&this.worktree_store, cx).context_server_timeout; - - match configuration.as_ref() { - ContextServerConfiguration::Http { - url, - headers, - timeout, - oauth: _, - } => { - let transport = HttpTransport::new_with_token_provider( - http_client.clone(), - url.to_string(), - headers.clone(), - cx.background_executor().clone(), - Some(token_provider.clone()), - ); - Ok(Arc::new(ContextServer::new_with_timeout( - id.clone(), - Arc::new(transport), - Some(Duration::from_secs( - timeout.unwrap_or(global_timeout).min(MAX_TIMEOUT_SECS), - )), - ))) - } - _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"), + let new_server = this.update(cx, |_this, cx| match configuration.as_ref() { + ContextServerConfiguration::Http { + url, + headers, + timeout, + oauth: _, + } => { + let transport = HttpTransport::new_with_token_provider( + http_client.clone(), + url.to_string(), + headers.clone(), + cx.background_executor().clone(), + Some(token_provider.clone()), + ); + Ok(Arc::new(ContextServer::new_with_timeout( + id.clone(), + Arc::new(transport), + Some(Duration::from_secs( + timeout.unwrap_or(global_timeout).min(MAX_TIMEOUT_SECS), + )), + ))) } + _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"), })??; this.update(cx, |this, cx| { @@ -1681,29 +1718,33 @@ impl ContextServerStore { for (id, _) in registry.read_with(cx, |registry, _| registry.context_server_descriptors()) { configured_servers .entry(id) - .or_insert(ContextServerSettings::default_extension()); + .or_insert(ContextServerSettingsEntry { + worktree_id: None, + settings: ContextServerSettings::default_extension(), + }); } let (enabled_servers, disabled_servers): (HashMap<_, _>, HashMap<_, _>) = configured_servers .into_iter() - .partition(|(_, settings)| settings.enabled()); + .partition(|(_, entry)| entry.settings.enabled()); - let configured_servers = join_all(enabled_servers.into_iter().map(|(id, settings)| { - let id = ContextServerId(id); - ContextServerConfiguration::from_settings( - settings, - id.clone(), - registry.clone(), - worktree_store.clone(), - cx, - ) - .map(move |config| (id, config)) - })) - .await - .into_iter() - .filter_map(|(id, config)| config.map(|config| (id, config))) - .collect::>(); + let configured_servers = + join_all(enabled_servers.into_iter().map(|(id, settings_entry)| { + let id = ContextServerId(id); + ContextServerConfiguration::from_settings( + settings_entry.settings, + id.clone(), + registry.clone(), + worktree_store.clone(), + cx, + ) + .map(move |config| (id, config)) + })) + .await + .into_iter() + .filter_map(|(id, config)| config.map(|config| (id, config))) + .collect::>(); let mut servers_to_start = Vec::new(); let mut servers_to_remove = HashSet::default(); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 6bf3fb85005..550433c7ec8 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -3540,7 +3540,7 @@ impl GitStore { let branch = repository_handle .update(&mut cx, |repository_handle, _| { - repository_handle.default_branch(false) + repository_handle.default_branch(envelope.payload.include_remote_name) }) .await?? .map(Into::into); @@ -8349,6 +8349,7 @@ impl Repository { .request(proto::GetDefaultBranch { project_id: project_id.0, repository_id: id.to_proto(), + include_remote_name, }) .await?; diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 1555a25dd21..f9e563ccdb3 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1733,9 +1733,13 @@ impl LocalLspStore { let formatters = match (trigger, &settings.format_on_save) { (FormatTrigger::Save, FormatOnSave::Off) => &[], - (FormatTrigger::Manual, _) | (FormatTrigger::Save, FormatOnSave::On) => { - settings.formatter.as_ref() - } + (FormatTrigger::Manual, _) + | ( + FormatTrigger::Save, + FormatOnSave::On + | FormatOnSave::Modifications + | FormatOnSave::ModificationsIfAvailable, + ) => settings.formatter.as_ref(), }; let formatters = code_actions_on_format_formatters @@ -1763,6 +1767,7 @@ impl LocalLspStore { &adapters_and_servers, &settings, request_timeout, + trigger, logger, cx, ) @@ -1783,6 +1788,7 @@ impl LocalLspStore { adapters_and_servers: &[(Arc, Arc)], settings: &LanguageSettings, request_timeout: Duration, + trigger: FormatTrigger, logger: zlog::Logger, cx: &mut AsyncApp, ) -> anyhow::Result<()> { @@ -1925,23 +1931,22 @@ impl LocalLspStore { }; let Some(language_server) = language_server else { - log::debug!( - "No language server found to format buffer '{:?}'. Skipping", - buffer_path_abs.as_path().to_string_lossy() + zlog::debug!( + logger => + "No language server found to format buffer {buffer_path_abs:?}. Skipping", ); return Ok(()); }; zlog::trace!( logger => - "Formatting buffer '{:?}' using language server '{:?}'", - buffer_path_abs.as_path().to_string_lossy(), + "Formatting buffer {buffer_path_abs:?} using language server {:?}", language_server.name() ); let edits = if let Some(ranges) = buffer.ranges.as_ref() { zlog::trace!(logger => "formatting ranges"); - Self::format_ranges_via_lsp( + let range_edits = Self::format_ranges_via_lsp( &lsp_store, &buffer.handle, ranges, @@ -1951,8 +1956,38 @@ impl LocalLspStore { cx, ) .await - .context("Failed to format ranges via language server")? - .unwrap_or_default() + .context("Failed to format ranges via language server")?; + + match range_edits { + Some(edits) => edits, + None => { + if trigger == FormatTrigger::Save + && settings.format_on_save == FormatOnSave::ModificationsIfAvailable + { + zlog::debug!( + logger => + "Falling back to full format - LSP does not support range formatting" + ); + Self::format_via_lsp( + &lsp_store, + &buffer.handle, + buffer_path_abs, + &language_server, + &settings, + cx, + ) + .await + .context("failed to format via language server")? + } else { + zlog::debug!( + logger => + "Skipping range format - language server {:?} does not support range formatting", + language_server.name() + ); + Vec::new() + } + } + } } else { zlog::trace!(logger => "formatting full"); Self::format_via_lsp( diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index c2804f853a3..ccb38746a2b 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -251,6 +251,7 @@ impl SearchQuery { let regex = RegexBuilder::new(&pattern) .case_insensitive(!case_sensitive) + .crlf(true) .build()?; Ok(Self::Regex { regex, diff --git a/crates/project/tests/integration/context_server_store.rs b/crates/project/tests/integration/context_server_store.rs index a91cdd2ea81..2600708df09 100644 --- a/crates/project/tests/integration/context_server_store.rs +++ b/crates/project/tests/integration/context_server_store.rs @@ -1488,6 +1488,176 @@ async fn test_context_server_stdio_timeout(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_multi_worktree_context_server_settings(cx: &mut TestAppContext) { + const SERVER_A: &str = "server-from-project-a"; + const SERVER_B: &str = "server-from-project-b"; + + let server_a_id = ContextServerId(SERVER_A.into()); + let server_b_id = ContextServerId(SERVER_B.into()); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project_a"), + json!({ + ".zed": { + "settings.json": serde_json::to_string(&json!({ + "context_servers": { + "server-from-project-a": { + "command": "server-a-binary", + "args": [] + } + } + })).unwrap() + }, + "code.rs": "" + }), + ) + .await; + fs.insert_tree( + path!("/project_b"), + json!({ + ".zed": { + "settings.json": serde_json::to_string(&json!({ + "context_servers": { + "server-from-project-b": { + "command": "server-b-binary", + "args": [] + } + } + })).unwrap() + }, + "code.rs": "" + }), + ) + .await; + + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + + // Create project with only project_a initially + let project = Project::test(fs.clone(), [path!("/project_a").as_ref()], cx).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())), + )) + })); + }); + + cx.run_until_parked(); + + // Only server-a should be configured + cx.update(|cx| { + let configured = store.read(cx).configured_server_ids(); + assert!( + configured.contains(&server_a_id), + "server-a should be configured from project_a" + ); + assert!( + !configured.contains(&server_b_id), + "server-b should not be configured yet" + ); + }); + + // Add project_b as a second worktree + project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/project_b"), true, cx) + }) + .await + .expect("Failed to add second worktree"); + + cx.run_until_parked(); + + // Both servers should now be configured + cx.update(|cx| { + let configured = store.read(cx).configured_server_ids(); + assert!( + configured.contains(&server_a_id), + "server-a should still be configured from project_a" + ); + assert!( + configured.contains(&server_b_id), + "server-b should now be configured from project_b" + ); + }); +} + +#[gpui::test] +async fn test_multi_worktree_duplicate_server_first_wins(cx: &mut TestAppContext) { + const SHARED_SERVER: &str = "shared-server"; + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project_a"), + json!({ + ".zed": { + "settings.json": serde_json::to_string(&json!({ + "context_servers": { + "shared-server": { + "command": "binary-from-a", + "args": ["arg-a"] + } + } + })).unwrap() + }, + "code.rs": "" + }), + ) + .await; + fs.insert_tree( + path!("/project_b"), + json!({ + ".zed": { + "settings.json": serde_json::to_string(&json!({ + "context_servers": { + "shared-server": { + "command": "binary-from-b", + "args": ["arg-b"] + } + } + })).unwrap() + }, + "code.rs": "" + }), + ) + .await; + + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + + // Create project with both worktrees + let project = Project::test( + fs.clone(), + [path!("/project_a").as_ref(), path!("/project_b").as_ref()], + cx, + ) + .await; + + cx.run_until_parked(); + + let store = project.read_with(cx, |project, _| project.context_server_store()); + + // The server should appear exactly once + cx.update(|cx| { + let configured = store.read(cx).configured_server_ids(); + let count = configured + .iter() + .filter(|id| id.0.as_ref() == SHARED_SERVER) + .count(); + assert_eq!(count, 1, "duplicate server ID should appear exactly once"); + }); +} + fn assert_server_events( store: &Entity, expected_events: Vec<(ContextServerId, ContextServerStatus)>, diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 47ff6c07409..2b2ba5dece6 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -25,7 +25,7 @@ use buffer_diff::{ }; use collections::{BTreeSet, HashMap, HashSet}; use encoding_rs; -use fs::{FakeFs, PathEventKind}; +use fs::{FakeFs, PathEventKind, RealFs}; use futures::{StreamExt, future}; use git::{ GitHostingProviderRegistry, @@ -15467,6 +15467,44 @@ async fn test_read_only_files_empty_setting(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +#[cfg(not(windows))] +async fn test_os_read_only_files_open_as_read_only(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let root = TempTree::new(json!({ + "project": { + "test.txt": "hello", + }, + })); + let file_path = root.path().join("project/test.txt"); + let mut permissions = std::fs::metadata(&file_path).unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&file_path, permissions).unwrap(); + + let project = Project::test( + Arc::new(RealFs::new(None, cx.executor())), + [root.path()], + cx, + ) + .await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(file_path.as_path(), cx) + }) + .await + .unwrap(); + + buffer.read_with(cx, |buffer, _| { + assert!( + buffer.read_only(), + "OS read-only files should open as read-only" + ); + }); +} + #[gpui::test] async fn test_read_only_files_with_lock_files(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/project/tests/integration/search.rs b/crates/project/tests/integration/search.rs index 79266405084..98cf7dc90ab 100644 --- a/crates/project/tests/integration/search.rs +++ b/crates/project/tests/integration/search.rs @@ -1,3 +1,4 @@ +use language::Buffer; use project::search::SearchQuery; use text::Rope; use util::{ @@ -130,6 +131,30 @@ fn test_case_sensitive_pattern_items() { ); } +#[gpui::test] +async fn test_multiline_regex_crlf(cx: &mut gpui::TestAppContext) { + let search_query = SearchQuery::regex( + "^hello$\r?\n", + false, + false, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("Should be able to create a regex SearchQuery"); + + let text = Rope::from("hello\r\nworld\r\nhello\r\nworld"); + let snapshot = cx + .update(|app| Buffer::build_snapshot(text, None, None, None, app)) + .await; + + let results = search_query.search(&snapshot, None).await; + assert_eq!(results, vec![0..7, 14..21]); +} + #[gpui::test] async fn test_multiline_regex(cx: &mut gpui::TestAppContext) { let search_query = SearchQuery::regex( @@ -145,7 +170,6 @@ async fn test_multiline_regex(cx: &mut gpui::TestAppContext) { ) .expect("Should be able to create a regex SearchQuery"); - use language::Buffer; let text = Rope::from("hello\nworld\nhello\nworld"); let snapshot = cx .update(|app| Buffer::build_snapshot(text, None, None, None, app)) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 8b23423a973..e8c4bf2f63d 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -2553,7 +2553,7 @@ impl ProjectPanel { cx: &mut Context, ) { maybe!({ - let items_to_delete = self.disjoint_effective_entries(cx); + let items_to_delete = self.disjoint_effective_entries_excluding_roots(cx); if items_to_delete.is_empty() { return None; } @@ -3266,7 +3266,7 @@ impl ProjectPanel { } fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) { - let entries = self.disjoint_effective_entries(cx); + let entries = self.disjoint_effective_entries_excluding_roots(cx); if !entries.is_empty() { self.write_entries_to_system_clipboard(&entries, cx); self.clipboard = Some(ClipboardEntry::Cut(entries)); @@ -3275,7 +3275,7 @@ impl ProjectPanel { } fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - let entries = self.disjoint_effective_entries(cx); + let entries = self.disjoint_effective_entries_excluding_roots(cx); if !entries.is_empty() { self.write_entries_to_system_clipboard(&entries, cx); self.clipboard = Some(ClipboardEntry::Copied(entries)); @@ -3870,25 +3870,6 @@ impl ProjectPanel { } } - fn move_entry( - &mut self, - entry_to_move: ProjectEntryId, - destination: ProjectEntryId, - destination_is_file: bool, - cx: &mut Context, - ) -> Option>> { - if self - .project - .read(cx) - .entry_is_worktree_root(entry_to_move, cx) - { - self.move_worktree_root(entry_to_move, destination, cx); - None - } else { - self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx) - } - } - fn move_worktree_root( &mut self, entry_to_move: ProjectEntryId, @@ -3971,8 +3952,14 @@ impl ProjectPanel { self.index_for_entry(selection.entry_id, selection.worktree_id) } - fn disjoint_effective_entries(&self, cx: &App) -> BTreeSet { - self.disjoint_entries(self.effective_entries(), cx) + fn disjoint_effective_entries_excluding_roots(&self, cx: &App) -> BTreeSet { + let project = self.project.read(cx); + let entries = self + .effective_entries() + .into_iter() + .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx)) + .collect(); + self.disjoint_entries(entries, cx) } fn disjoint_entries( @@ -3988,7 +3975,6 @@ impl ProjectPanel { let project = self.project.read(cx); let entries_by_worktree: HashMap> = entries .into_iter() - .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx)) .fold(HashMap::default(), |mut map, entry| { map.entry(entry.worktree_id).or_default().push(entry); map @@ -4745,6 +4731,21 @@ impl ProjectPanel { .collect::>(); let entries = self.disjoint_entries(resolved_selections, cx); + let root_entries: Vec = { + let project = self.project.read(cx); + entries + .iter() + .filter(|entry| project.entry_is_worktree_root(entry.entry_id, cx)) + .map(|entry| entry.entry_id) + .collect() + }; + if !root_entries.is_empty() { + for entry_id in root_entries { + self.move_worktree_root(entry_id, target_entry_id, cx); + } + return; + } + if Self::is_copy_modifier_set(&window.modifiers()) { let _ = maybe!({ let project = self.project.read(cx); @@ -4862,7 +4863,9 @@ impl ProjectPanel { // results with folded selections that need refreshing. let mut move_tasks: Vec<(ProjectEntryId, Task>)> = Vec::new(); for entry in entries { - if let Some(task) = self.move_entry(entry.entry_id, target_entry_id, is_file, cx) { + if let Some(task) = + self.move_worktree_entry(entry.entry_id, target_entry_id, is_file, cx) + { move_tasks.push((entry.entry_id, task)); } } diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 3e4e3788da7..efc5a191b1c 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -4428,6 +4428,141 @@ async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) { } } +async fn setup_three_worktree_panel( + cx: &mut gpui::TestAppContext, +) -> (Entity, VisualTestContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root1", json!({ "a.txt": "" })).await; + fs.insert_tree("/root2", json!({ "b.txt": "" })).await; + fs.insert_tree("/root3", json!({ "c.txt": "" })).await; + + let project = Project::test( + fs.clone(), + ["/root1".as_ref(), "/root2".as_ref(), "/root3".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 mut cx = VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(&mut cx, ProjectPanel::new); + cx.run_until_parked(); + (panel, cx) +} + +#[gpui::test] +async fn test_drag_worktree_root_reorders_worktrees(cx: &mut gpui::TestAppContext) { + let (panel, mut cx) = setup_three_worktree_panel(cx).await; + let cx = &mut cx; + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " a.txt", + "v root2", + " b.txt", + "v root3", + " c.txt", + ], + "worktrees should start in insertion order" + ); + + // [r1, r2, r3] -> [r2, r1, r3]. + drag_entries_onto(&panel, &["root1"], "root2", false, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root2", + " b.txt", + "v root1", + " a.txt", + "v root3", + " c.txt", + ], + "dragging root1 onto root2 should swap their positions" + ); + + // [r2, r1, r3] -> [r3, r2, r1]. + drag_entries_onto(&panel, &["root3"], "root2", false, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root3", + " c.txt", + "v root2", + " b.txt", + "v root1", + " a.txt", + ], + "dragging the last root onto the first should move it to the front" + ); +} + +#[gpui::test] +async fn test_drag_including_worktree_root_only_reorders(cx: &mut gpui::TestAppContext) { + let (panel, mut cx) = setup_three_worktree_panel(cx).await; + let cx = &mut cx; + + // Drag {root1, root2/b.txt} onto root3's root entry: only the worktree + // reorder should happen and b.txt must stay in root2. + drag_entries_onto(&panel, &["root1", "root2/b.txt"], "root3", false, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root2", + " b.txt", + "v root3", + " c.txt", + "v root1", + " a.txt", + ], + "dropping a mixed selection on a root should only reorder worktrees" + ); + + // Drag {root2, root3/c.txt} onto root1/a.txt (a non-root entry): the root + // still reorders to root1's position and c.txt must stay in root3. + drag_entries_onto(&panel, &["root2", "root3/c.txt"], "root1/a.txt", true, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root3", + " c.txt", + "v root1", + " a.txt", + "v root2", + " b.txt", + ], + "dropping a mixed selection on a non-root entry should only reorder worktrees" + ); + + // With the copy modifier held, a selection containing a root should still + // only reorder worktrees and copy nothing. + cx.simulate_modifiers_change(gpui::Modifiers { + alt: true, + control: true, + ..Default::default() + }); + drag_entries_onto(&panel, &["root3", "root1/a.txt"], "root2", false, cx); + cx.simulate_modifiers_change(Default::default()); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " a.txt", + "v root2", + " b.txt", + "v root3", + " c.txt", + ], + "copy-dragging a mixed selection should only reorder worktrees and copy nothing" + ); +} + #[gpui::test] async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); @@ -10069,6 +10204,48 @@ pub(crate) fn drag_selection_to( cx.executor().run_until_parked(); } +/// Drags the entries at `source_paths` onto `target_path`. Paths are worktree +/// root names optionally followed by a path inside the worktree, e.g. "root1" +/// or "root1/dir/file.txt". The first source path is the active selection. +pub(crate) fn drag_entries_onto( + panel: &Entity, + source_paths: &[&str], + target_path: &str, + target_is_file: bool, + cx: &mut VisualTestContext, +) { + let target_entry_id = find_project_entry(panel, target_path, cx) + .unwrap_or_else(|| panic!("no entry for target path {target_path:?}")); + let selections: Vec = source_paths + .iter() + .map(|path| { + let entry_id = find_project_entry(panel, path, cx) + .unwrap_or_else(|| panic!("no entry for source path {path:?}")); + let worktree_id = panel + .update(cx, |panel, cx| { + panel.project.read(cx).worktree_id_for_entry(entry_id, cx) + }) + .unwrap_or_else(|| panic!("no worktree for source path {path:?}")); + SelectedEntry { + worktree_id, + entry_id, + } + }) + .collect(); + let active_selection = *selections + .first() + .expect("at least one source path is required"); + + panel.update_in(cx, |panel, window, cx| { + let drag = DraggedSelection { + active_selection, + marked_selections: Arc::from(selections), + }; + panel.drag_onto(&drag, target_entry_id, target_is_file, window, cx); + }); + cx.executor().run_until_parked(); +} + pub(crate) fn find_project_entry( panel: &Entity, path: &str, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 8d589f947cd..1592237cda8 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -512,6 +512,7 @@ message BlameBufferResponse { message GetDefaultBranch { uint64 project_id = 1; uint64 repository_id = 2; + bool include_remote_name = 3; } message GetDefaultBranchResponse { diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 46e2eb1eb62..53aec1f5673 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2800,6 +2800,20 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA }); assert_eq!(server_branch.name(), "totally-new-branch"); + + let default_branch = cx + .update(|cx| repository.update(cx, |repository, _cx| repository.default_branch(false))) + .await + .unwrap() + .unwrap(); + assert_eq!(default_branch.as_deref(), Some("main")); + + let default_branch_with_remote = cx + .update(|cx| repository.update(cx, |repository, _cx| repository.default_branch(true))) + .await + .unwrap() + .unwrap(); + assert_eq!(default_branch_with_remote.as_deref(), Some("origin/main")); } #[gpui::test] diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml index c9211dfa99f..58951c16317 100644 --- a/crates/sandbox/Cargo.toml +++ b/crates/sandbox/Cargo.toml @@ -58,6 +58,10 @@ libc.workspace = true # Safe wrappers for the SCM_RIGHTS fd-passing and `fstat` the bind validator # needs, so that code doesn't hand-roll `msghdr`/`CMSG_*`/`mem::zeroed` unsafe. nix = { workspace = true, features = ["fs", "socket", "uio"] } +# Builds the in-sandbox seccomp-BPF filter that blocks the untrusted command from +# creating `AF_UNIX` (and other non-IP) sockets, `io_uring`, `ptrace`, etc. — the +# syscall-level half of preventing session-IPC-socket sandbox escapes. +seccompiler.workspace = true [target.'cfg(target_os = "linux")'.dev-dependencies] tempfile.workspace = true diff --git a/crates/sandbox/README.md b/crates/sandbox/README.md index ab23ecad891..d2b24249737 100644 --- a/crates/sandbox/README.md +++ b/crates/sandbox/README.md @@ -228,6 +228,47 @@ If the attacker managed to change a path to point to a different inode to when the FD was captured, the check will fail, and we don't run the untrusted command. +#### Blocking IPC-socket escapes (seccomp) + +A read-only bind mount does **not** stop a process from `connect()`-ing to a +Unix-domain socket: the kernel deliberately exempts sockets (and FIFOs, and +device nodes) from the read-only-filesystem write check, because connecting +modifies no filesystem data. So even with `--ro-bind / /`, a sandboxed command +could reach a session IPC socket in `$XDG_RUNTIME_DIR` (a Wayland compositor, +the D-Bus session bus, ...) or a system socket like the Docker daemon, and use +it to run a process *outside* the sandbox — defeating both the filesystem and +network restrictions, regardless of the read/write grant. `--unshare-net` does +not help: it isolates abstract sockets and TCP/IP, but these are pathname +sockets on the bound filesystem. + +The fix is a seccomp-BPF filter (built with `seccompiler`) installed on the +untrusted command just before it runs. Rather than trying to hide every socket, +it stops the command from *obtaining* one it could escape through: + +- `socket()` is allowed only for `AF_INET`/`AF_INET6`/`AF_NETLINK`; every other + family — notably `AF_UNIX` (session IPC) and `AF_VSOCK` (the VM host) — is + denied with `EPERM`. +- `socketpair()` is allowed only for `AF_UNIX` (a process-local pair that can't + reach anything outside the sandbox). +- `io_uring_*` is denied, so its ring operations can't create/connect a socket + without going through the filtered syscalls; `ptrace`/`process_vm_*` are denied. +- `connect`/`recvmsg`/`sendmsg`/`bind`/`listen`/`accept` stay allowed. With no + way to create a forbidden socket — and, by fd hygiene, none inherited — there + is nothing dangerous for them to act on, and blocking `connect` would break + legitimate loopback/proxy use. `seccompiler`'s architecture check kills + foreign-arch syscalls, closing the 32-bit (`socketcall`) bypass. + +The filter must apply to the command but **not** to the launcher/bridge process, +which keeps using `AF_UNIX` to reach the host proxy for every request. So it is +installed inline right before `exec` in the direct case, and via the child's +`pre_exec` in the restricted-network bridge case. Because the filter lives in the +in-sandbox launcher, the launcher is now **always** run (even when there are no +writable binds to validate and no bridge), so the filter is always installed. + +This is Linux/WSL-specific. On macOS, Seatbelt gates Unix-socket `connect` as a +separate `network-outbound` capability that is denied by default, so the same +escape is already closed there without a seccomp filter. + ### Windows > [!NOTE] The Windows implementation depends heavily on the details of the Linux @@ -241,6 +282,27 @@ To work around this, we launch `zed --wsl-sandbox-helper` in WSL, which is a shim that captures the FDs and sets up the socket. We download this to `~/.local/libexec/zed`, so that it does not conflict with the Windows `zed.exe` binary that WSL will inject into the Linux `$PATH` (yes the `.exe` is stripped). + +### MacOS + +MacOS uses seatbelt, which enforces a rules file. This generally makes +enforcement more straightforward. Unlike Linux, paths are resolved and checked +at *syscall time*, meaning the symlink swap attack will not succeed. + +However, care has to be taken with various parts of the rules file, specifically +when it comes to `mach-lookup`. This controls access to, among other things, +Launch Services, which allows unsandboxed code execution. + +The exact policy is defined in `src/macos_seatbelt.rs`, and is inspired by a +mixture of Codex and Chromium's rules. + +Some of the denied services are somewhat questionable (i.e. +`com.apple.FontObjectsServer`) - there are legitimate uses for an application to +use this, but on the other hand, fonts can contain executable code, and have +historically been exploited to achieve RCE. Given that, in the Zed agent, it is +easy to opt-out of the sandbox, denying seems like a good choice. But we may +want to revisit this. + ## Code design ### `HostFilesystemLocation` diff --git a/crates/sandbox/src/bwrap_test_helper.rs b/crates/sandbox/src/bwrap_test_helper.rs index f6aa0cb6f29..68ef38659b2 100644 --- a/crates/sandbox/src/bwrap_test_helper.rs +++ b/crates/sandbox/src/bwrap_test_helper.rs @@ -46,6 +46,12 @@ mod imp { /// successful round-trip, non-zero otherwise. Run *inside* the sandbox. const SUBCOMMAND_ECHO_CHECK: &str = "__echo_check"; + /// Internal subcommand: connect to the unix-domain socket at the given path + /// and round-trip a byte through it. Exits 0 on a successful round-trip, + /// non-zero on any failure (including `socket(AF_UNIX)` being blocked once + /// the seccomp guard lands). Run *inside* the sandbox. + const SUBCOMMAND_UNIX_CONNECT_CHECK: &str = "__unix_connect_check"; + /// Default port for echo targets given as a bare hostname (e.g. `echo1`). const DEFAULT_ECHO_PORT: &str = "7000"; @@ -57,6 +63,9 @@ mod imp { let args: Vec = std::env::args().collect(); let result = match args.get(1).map(String::as_str) { Some(SUBCOMMAND_ECHO_CHECK) => run_echo_check(args.get(2).map(String::as_str)), + Some(SUBCOMMAND_UNIX_CONNECT_CHECK) => { + run_unix_connect_check(args.get(2).map(String::as_str)) + } _ => run_checks(), }; @@ -93,8 +102,8 @@ mod imp { /// One declarative check: a sandbox policy, an operation, and the expected /// result. Deserialized from the JSON the Nix test produces. /// - /// Exactly one operation field (`read`, `write`, `network`, or `canCreate`) - /// must be set. Policy fields default to the most-confined policy + /// Exactly one operation field (`read`, `write`, `network`, `socketPath`, or + /// `canCreate`) must be set. Policy fields default to the most-confined policy /// (restricted filesystem with no writable paths, blocked network). #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -127,6 +136,9 @@ mod imp { /// the sandbox. #[serde(default)] network: Option, + /// Connect to this unix-domain socket path from inside the sandbox. + #[serde(default)] + socket_path: Option, /// Assert that `Sandbox::can_create` for this policy matches the value: /// `true` => the sandbox can be created, `false` => it cannot. #[serde(default)] @@ -239,6 +251,8 @@ mod imp { format!("write {path}") } else if let Some(host) = &check.network { format!("network {host}") + } else if let Some(path) = &check.socket_path { + format!("socket_connect {path}") } else if let Some(expected) = check.can_create { format!("can_create == {expected}") } else { @@ -277,6 +291,8 @@ mod imp { run_write(check, path)? } else if let Some(host) = &check.network { run_network(check, host, echo_port)? + } else if let Some(path) = &check.socket_path { + run_socket_connect(check, path)? } else { bail!("check {label:?} has no operation"); }; @@ -354,6 +370,21 @@ mod imp { run_command(&mut sandbox, &exe, &[SUBCOMMAND_ECHO_CHECK, &target]) } + /// Attempt to connect to the unix-domain socket at `path` from inside the + /// sandbox via the `__unix_connect_check` subcommand, returning whether the + /// round-trip succeeded. A read-only bind mount of `/` leaves the socket + /// reachable, so a sandboxed command can currently `connect()` to a session + /// IPC socket owned by a process *outside* the sandbox — the escape a + /// `socket(AF_UNIX)` seccomp filter is meant to block. When that guard lands, + /// `socket(AF_UNIX)` returns `EPERM`, the subcommand fails, and this returns + /// `false`. + fn run_socket_connect(check: &Check, path: &str) -> Result { + let exe = current_exe_str()?; + let policy = policy_of(check)?; + let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?; + run_command(&mut sandbox, &exe, &[SUBCOMMAND_UNIX_CONNECT_CHECK, path]) + } + fn error_matches(error: &SandboxError, expected: &str) -> bool { matches!( (error, expected), @@ -429,6 +460,35 @@ mod imp { } } + /// Inner command: connect to the unix-domain socket at `path` and round-trip + /// a byte through it. + /// + /// Any failure — `socket(AF_UNIX)` being denied (how the seccomp guard will + /// manifest, as `EPERM`), `connect()` failing, or a bad round-trip — exits + /// non-zero, so the caller reads it as "not connected". A clean round-trip + /// (exit 0) means the socket outside the sandbox was reachable. + fn run_unix_connect_check(path: Option<&str>) -> Result<()> { + use std::os::unix::net::UnixStream; + + let path = path.context("unix connect check requires a socket path argument")?; + let mut stream = UnixStream::connect(path) + .with_context(|| format!("failed to connect to unix socket {path}"))?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + stream + .write_all(b"ping\n") + .context("failed to write to unix socket")?; + let mut buffer = [0u8; 32]; + let read = stream + .read(&mut buffer) + .context("failed to read from unix socket")?; + let echoed = String::from_utf8_lossy(&buffer[..read]); + if echoed.contains("ping") { + Ok(()) + } else { + bail!("unix socket returned unexpected data: {echoed:?}"); + } + } + /// Read an HTTP status line (up to the first CRLF), then drain the rest of /// the header block (up to the blank line) so the stream is positioned at /// the tunneled body. diff --git a/crates/sandbox/src/linux_bubblewrap.rs b/crates/sandbox/src/linux_bubblewrap.rs index 21a4602523b..373f3520786 100644 --- a/crates/sandbox/src/linux_bubblewrap.rs +++ b/crates/sandbox/src/linux_bubblewrap.rs @@ -610,15 +610,20 @@ pub fn wrap_invocation( // Create the requested writable directories up front, with the agent's // ambient permissions, so each can be bind-mounted at its exact path (see - // `build_bwrap_args`). Without this a not-yet-existing writable path could - // not be bound, and the command could not create it either (its parent is - // read-only inside the sandbox). Best-effort: a directory we can't create is - // left unbound rather than widening the sandbox to an existing ancestor. + // `build_bwrap_args`): `bwrap` can't bind a nonexistent source, and the + // command can't create it either (its parent is read-only inside the + // sandbox). If a path still doesn't exist afterwards we can't grant the + // write access the agent asked for, and running anyway would give the + // command silently less access than it believes it has — so fail closed with + // a clear error instead. (An existing *file* makes `create_dir_all` error + // but is fine: it exists and the `--bind` below handles it.) if !permissions.allow_fs_write { for directory in writable_dirs { - if let Err(error) = std::fs::create_dir_all(directory) { - log::warn!( - "[sandbox] could not create writable directory {}: {error}", + if let Err(error) = std::fs::create_dir_all(directory) + && !directory.exists() + { + bail!( + "failed to provide writable sandbox path {}: {error}", directory.display() ); } @@ -652,41 +657,41 @@ pub fn wrap_invocation( NetworkAccess::None | NetworkAccess::All => None, }; - // The launcher is only needed when there is something for it to do: validate - // writable binds, and/or run the restricted-network bridge. Otherwise the - // command runs directly under bwrap. - if validation_socket.is_some() || bridge.is_some() { - bwrap_args.push(bridge_program.to_string()); - bwrap_args.push(LAUNCHER_FLAG.to_string()); - // Field 1: validation socket (in-sandbox path) or sentinel. - bwrap_args.push(match validation_socket { - Some(socket) => socket.sandbox_socket_path.to_string_lossy().into_owned(), - None => LAUNCHER_NONE.to_string(), - }); - // Fields 2-3: bridge socket (in-sandbox path) + port, or sentinels. - match &bridge { - Some((socket, port)) => { - bwrap_args.push(socket.to_string_lossy().into_owned()); - bwrap_args.push(port.to_string()); - } - None => { - bwrap_args.push(LAUNCHER_NONE.to_string()); - bwrap_args.push(LAUNCHER_NONE.to_string()); - } + // Always route through the in-sandbox launcher, even when there are no + // writable binds to validate and no restricted-network bridge: the launcher + // is where the seccomp filter is installed on the untrusted command (see + // `exec_command` / `run_bridge`). Absent fields are passed as the `-` + // sentinel, and `run_launcher` then just installs the filter and `exec`s. + bwrap_args.push(bridge_program.to_string()); + bwrap_args.push(LAUNCHER_FLAG.to_string()); + // Field 1: validation socket (in-sandbox path) or sentinel. + bwrap_args.push(match validation_socket { + Some(socket) => socket.sandbox_socket_path.to_string_lossy().into_owned(), + None => LAUNCHER_NONE.to_string(), + }); + // Fields 2-3: bridge socket (in-sandbox path) + port, or sentinels. + match &bridge { + Some((socket, port)) => { + bwrap_args.push(socket.to_string_lossy().into_owned()); + bwrap_args.push(port.to_string()); } - // Field 4: the writable bind-destination paths to validate (count, then - // the paths), in the same order the host sends their fds. - let validation_paths: &[&Path] = if validation_socket.is_some() { - writable_dirs - } else { - &[] - }; - bwrap_args.push(validation_paths.len().to_string()); - for path in validation_paths { - bwrap_args.push(path.to_string_lossy().into_owned()); + None => { + bwrap_args.push(LAUNCHER_NONE.to_string()); + bwrap_args.push(LAUNCHER_NONE.to_string()); } - bwrap_args.push("--".to_string()); } + // Field 4: the writable bind-destination paths to validate (count, then + // the paths), in the same order the host sends their fds. + let validation_paths: &[&Path] = if validation_socket.is_some() { + writable_dirs + } else { + &[] + }; + bwrap_args.push(validation_paths.len().to_string()); + for path in validation_paths { + bwrap_args.push(path.to_string_lossy().into_owned()); + } + bwrap_args.push("--".to_string()); bwrap_args.push(program.to_string()); bwrap_args.extend(args.iter().cloned()); @@ -859,9 +864,126 @@ fn validate_binds(socket_path: &Path, paths: &[PathBuf]) -> Result<()> { Ok(()) } +/// Build the seccomp-BPF program installed on the untrusted command before it +/// runs. This is the syscall-level half of preventing session-IPC-socket +/// sandbox escapes: a read-only bind mount does not stop `connect()` to a unix +/// socket, so instead we stop the command from ever *obtaining* a non-IP socket. +/// +/// The program (default action: allow): +/// - `socket()` is denied (`EPERM`) unless the family is `AF_INET`/`AF_INET6`/ +/// `AF_NETLINK` — so no `AF_UNIX` (session IPC) or `AF_VSOCK` (VM host) sockets. +/// - `socketpair()` is allowed only for `AF_UNIX` (a process-local pair that +/// cannot reach anything outside the sandbox). +/// - `io_uring_*` is denied, so its ring ops can't create/connect sockets +/// without going through the filtered syscalls. +/// - `ptrace`/`process_vm_*` are denied. +/// +/// `connect`/`recvmsg`/`sendmsg`/`bind`/`listen`/`accept` stay allowed: with no +/// way to create a forbidden socket (and — by fd hygiene — no forbidden fd +/// inherited), there is nothing dangerous for them to act on, and blocking +/// `connect` would break legitimate loopback/proxy use. Foreign-architecture +/// syscalls are killed by seccompiler's arch check, closing the 32-bit-ABI +/// (`socketcall`) bypass. +/// +/// Returns `None` on an architecture seccompiler can't target (we ship on +/// x86_64/aarch64, so that is not a configuration we run in practice). +fn build_command_seccomp_program() -> Result> { + use seccompiler::{ + BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, + SeccompRule, TargetArch, + }; + use std::collections::BTreeMap; + + let Ok(target_arch) = TargetArch::try_from(std::env::consts::ARCH) else { + return Ok(None); + }; + + // `socket(domain, ...)`: deny unless `domain` (arg0, an `int` — compare the + // low 32 bits) is an allowed IP/netlink family. The rule matches when the + // family is none of the allowed ones, and a matched rule takes the deny + // action; an allowed family matches no rule and falls through to `Allow`. + let socket_deny = SeccompRule::new(vec![ + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_INET as u64, + )?, + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_INET6 as u64, + )?, + SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_NETLINK as u64, + )?, + ])?; + // `socketpair(domain, ...)`: allow only `AF_UNIX`. + let socketpair_deny = SeccompRule::new(vec![SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_UNIX as u64, + )?])?; + + let mut rules: BTreeMap> = BTreeMap::new(); + rules.insert(libc::SYS_socket, vec![socket_deny]); + rules.insert(libc::SYS_socketpair, vec![socketpair_deny]); + // Unconditional denials (an empty rule chain always takes the match action). + for syscall in [ + libc::SYS_io_uring_setup, + libc::SYS_io_uring_enter, + libc::SYS_io_uring_register, + libc::SYS_ptrace, + libc::SYS_process_vm_readv, + libc::SYS_process_vm_writev, + ] { + rules.insert(syscall, Vec::new()); + } + + let filter = SeccompFilter::new( + rules, + SeccompAction::Allow, + SeccompAction::Errno(libc::EPERM as u32), + target_arch, + ) + .context("building sandbox command seccomp filter")?; + let program = + BpfProgram::try_from(filter).context("compiling sandbox command seccomp filter")?; + Ok(Some(program)) +} + +/// Install [`build_command_seccomp_program`] on the calling thread, which is +/// about to become (or `exec` into) the untrusted command; the filter survives +/// `exec`. `apply_filter` also sets `PR_SET_NO_NEW_PRIVS`. On an unsupported +/// architecture there is no filter to install — log and proceed rather than +/// break the sandbox on a platform we don't ship. +fn install_command_seccomp_filter() -> Result<()> { + match build_command_seccomp_program()? { + Some(program) => seccompiler::apply_filter(&program) + .context("installing sandbox command seccomp filter")?, + None => log::warn!( + "[sandbox] seccomp is unavailable on {}; the unix-socket syscall guard \ + was not installed", + std::env::consts::ARCH + ), + } + Ok(()) +} + /// Replace this process with the sandboxed command. Only returns (after logging) /// if `exec` itself fails. fn exec_command(program: &OsStr, args: &[OsString]) -> ! { + // Lock down socket/io_uring/ptrace syscalls right before handing control to + // the untrusted command; the filter survives `exec`. + if let Err(error) = install_command_seccomp_filter() { + eprintln!("zed: failed to install sandbox seccomp filter: {error:#}"); + std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); + } let error = Command::new(program).args(args).exec(); eprintln!("zed: failed to exec sandboxed command: {error}"); std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); @@ -889,7 +1011,32 @@ fn run_bridge(socket_path: PathBuf, port: u16, program: &OsStr, program_args: &[ std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); } - let mut child = match Command::new(program).args(program_args).spawn() { + // The command runs under the syscall filter, installed in the child via + // `pre_exec` — *this* bridge process must NOT be filtered, since it keeps + // using `AF_UNIX` to reach the host proxy for every request the command + // makes. Build the program before the fork; the child only applies it. + let seccomp_program = match build_command_seccomp_program() { + Ok(program) => program, + Err(error) => { + eprintln!("zed: failed to build sandbox seccomp filter: {error:#}"); + std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); + } + }; + let mut command = Command::new(program); + command.args(program_args); + // SAFETY: the closure runs in the forked child after `fork` and before + // `exec`. It only calls `seccompiler::apply_filter` (a `prctl` on a program + // built before the fork) — async-signal-safe and allocation-free. + unsafe { + command.pre_exec(move || { + if let Some(program) = &seccomp_program { + seccompiler::apply_filter(program) + .map_err(|error| std::io::Error::other(format!("seccomp: {error}")))?; + } + Ok(()) + }); + } + let mut child = match command.spawn() { Ok(child) => child, Err(error) => { eprintln!("zed: failed to spawn sandboxed command: {error}"); @@ -1134,29 +1281,42 @@ fn run_wsl_helper(invocation: WslHelperInvocation) -> ! { }; let mut args = invocation.base_args.clone(); + // Always re-exec ourselves as the in-sandbox launcher, even when there is + // nothing to validate: the launcher is where the seccomp filter is installed + // on the untrusted command (see `exec_command`). When there are writable + // binds, also bind the validation socket so the launcher can verify them. + // WSL has no restricted-network bridge, so both bridge fields are the absent + // sentinel. if let Some(sender) = &validation { - // Bind the validation socket in (after the base args' tmpfs and writable - // binds so it isn't shadowed), then re-exec ourselves inside the sandbox - // as the validator before the real command. WSL has no restricted-network - // bridge, so both bridge fields are the absent sentinel. + // Bind the validation socket in, after the base args' tmpfs and writable + // binds so it isn't shadowed. args.push(OsString::from("--bind")); args.push(sender.host_socket_path().as_os_str().to_os_string()); args.push(sender.sandbox_socket_path().as_os_str().to_os_string()); - args.push(OsString::from("--")); - args.push(current_exe.into_os_string()); - args.push(OsString::from(LAUNCHER_FLAG)); - args.push(sender.sandbox_socket_path().as_os_str().to_os_string()); - args.push(OsString::from(LAUNCHER_NONE)); - args.push(OsString::from(LAUNCHER_NONE)); - args.push(OsString::from(invocation.writable_paths.len().to_string())); - for path in &invocation.writable_paths { - args.push(path.clone().into_os_string()); - } - args.push(OsString::from("--")); - } else { - // Nothing to validate — run the command directly under bwrap. - args.push(OsString::from("--")); } + args.push(OsString::from("--")); + args.push(current_exe.into_os_string()); + args.push(OsString::from(LAUNCHER_FLAG)); + // Field 1: validation socket (in-sandbox path) or sentinel. + match &validation { + Some(sender) => args.push(sender.sandbox_socket_path().as_os_str().to_os_string()), + None => args.push(OsString::from(LAUNCHER_NONE)), + } + // Fields 2-3: bridge socket + port (WSL has no bridge). + args.push(OsString::from(LAUNCHER_NONE)); + args.push(OsString::from(LAUNCHER_NONE)); + // Field 4: writable bind-destination paths to validate (count, then paths); + // empty when there is nothing to validate. + let validation_paths: &[PathBuf] = if validation.is_some() { + &invocation.writable_paths + } else { + &[] + }; + args.push(OsString::from(validation_paths.len().to_string())); + for path in validation_paths { + args.push(path.clone().into_os_string()); + } + args.push(OsString::from("--")); args.push(invocation.program.clone()); args.extend(invocation.args.iter().cloned()); @@ -1664,4 +1824,100 @@ mod tests { "unexpected error: {error:#}" ); } + + // The filter compiles to a non-empty BPF program on architectures + // seccompiler can target (the ones we ship). On others it's `None`, which is + // acceptable — no filter is installed there. + #[test] + fn test_command_seccomp_program_builds() { + let program = build_command_seccomp_program().expect("build seccomp program"); + if let Some(program) = program { + assert!(!program.is_empty(), "seccomp program must not be empty"); + } + } + + // Actually enforce the filter: in a child process, apply it and confirm that + // `socket(AF_UNIX)` is denied while `socket(AF_INET)` and + // `socketpair(AF_UNIX)` still work — the exact guarantee that closes the + // unix-socket sandbox escape. + #[test] + fn test_command_seccomp_filter_blocks_unix_but_allows_ip() { + // Build in the parent (this allocates); the child only applies the + // prebuilt program and makes raw syscalls. + let Some(program) = build_command_seccomp_program().expect("build seccomp program") else { + return; // unsupported arch: nothing to enforce + }; + + // SAFETY: after `fork`, the child calls only async-signal-safe + // libc/`prctl` (via `apply_filter` on a program built before the fork) + // and `_exit`; it never returns to Rust or allocates. + let pid = unsafe { libc::fork() }; + assert!(pid >= 0, "fork failed"); + if pid == 0 { + if seccompiler::apply_filter(&program).is_err() { + unsafe { libc::_exit(10) }; + } + // AF_UNIX socket creation must be denied. + if unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) } >= 0 { + unsafe { libc::_exit(11) }; + } + // AF_INET socket creation must still work. + let inet = unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0) }; + if inet < 0 { + unsafe { libc::_exit(12) }; + } + // AF_UNIX socketpair (process-local) must still work. + let mut fds = [0i32; 2]; + if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) } + != 0 + { + unsafe { libc::_exit(13) }; + } + unsafe { libc::_exit(0) }; + } + + let mut status = 0i32; + let waited = unsafe { libc::waitpid(pid, &mut status, 0) }; + assert_eq!(waited, pid, "waitpid failed"); + assert!( + libc::WIFEXITED(status), + "child did not exit normally: {status:#x}" + ); + let code = libc::WEXITSTATUS(status); + assert_eq!( + code, 0, + "child reported a seccomp mismatch (exit {code}): 10=apply failed, \ + 11=AF_UNIX allowed, 12=AF_INET blocked, 13=socketpair blocked" + ); + } + + // A requested writable path that can't be created (here, under an existing + // file, so `create_dir_all` errors and the path never exists) must fail the + // whole invocation — not run the command with silently less write access + // than the agent asked for. This check runs before `resolve_bwrap`, so the + // test needs no real `bwrap`. + #[test] + fn test_wrap_invocation_fails_when_writable_path_cannot_be_provided() { + let file = tempfile::NamedTempFile::new().unwrap(); + let unbindable = file.path().join("subdir"); + let result = wrap_invocation( + "/proc/self/exe", + SandboxPermissions { + network: NetworkAccess::None, + allow_fs_write: false, + }, + &[unbindable.as_path()], + &[], + None, + "/bin/true", + &[], + None, + None, + ); + let error = result.expect_err("must fail closed when a writable path can't be provided"); + assert!( + error.to_string().contains("writable sandbox path"), + "unexpected error: {error:#}" + ); + } } diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs index 17a2db9928e..a62803a84f5 100644 --- a/crates/sandbox/src/macos_seatbelt.rs +++ b/crates/sandbox/src/macos_seatbelt.rs @@ -243,8 +243,44 @@ fn generate_seatbelt_config( ; Allow sysctl reads (needed for many system calls) (allow sysctl-read) -; Allow mach lookups (needed for IPC) -(allow mach-lookup) +; Mach service lookups. This is an ALLOWLIST, not a blanket `(allow mach-lookup)`. +; An unrestricted mach-lookup lets a sandboxed command reach LaunchServices / +; launchd and have a process spawned *outside* the sandbox (e.g. `open -a +; Terminal`, or opening a crafted `.app`) — the launched process does not inherit +; this profile, so that is a full sandbox escape. So we allow only the services +; ordinary dev tooling needs and deliberately EXCLUDE the LaunchServices/launchd +; endpoints (closing that escape), the pasteboard (silent clipboard theft), and +; audio (mic/privacy). Curated from Codex's and Chromium's Seatbelt policies; add +; an entry here (with a comment) if a legitimate toolchain needs another service. +; A non-existent name is simply never matched, so erring toward including +; plausible infrastructure services is safe. +(allow mach-lookup + ; identity: user & group resolution (getpwuid, id, whoami, perm checks) + (global-name "com.apple.system.opendirectoryd.libinfo") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.system.DirectoryService.libinfo_v1") + ; per-user temp/cache dir resolution ($TMPDIR, /var/folders/...) + (global-name "com.apple.bsd.dirhelper") + ; CFPreferences (pervasive in Apple frameworks linked by dev tools). + ; Chromium denies cfprefsd.daemon to force in-process prefs; we follow Codex + ; and allow it since dev commands legitimately use many prefs domains — it's + ; a prefs read/write, not an escape. + (global-name "com.apple.cfprefsd.daemon") + (global-name "com.apple.cfprefsd.agent") + (local-name "com.apple.cfprefsd.agent") + ; logging / diagnostics (os_log, ASL, Darwin notifications) + (global-name "com.apple.logd") + (global-name "com.apple.logd.events") + (global-name "com.apple.system.logger") + (global-name "com.apple.diagnosticd") + (global-name "com.apple.system.notification_center") + ; Apple telemetry (data goes to Apple only; harmless, avoids init latency) + (global-name "com.apple.analyticsd") + (global-name "com.apple.analyticsd.messagetracer") + ; power assertions (caffeinate / prevent idle sleep during long builds) + (global-name "com.apple.PowerManagement.control") + ; developer-tools automation-mode flag (our workload is dev tooling) + (global-name "com.apple.dt.automationmode.reader")) ; Allow pseudo-terminal operations (allow pseudo-tty) @@ -345,6 +381,33 @@ fn generate_seatbelt_config( } } + // When outbound network is permitted at all, tools that do their own DNS + // resolution, TLS trust evaluation, and network-configuration lookups need a + // few more Mach services. Kept out of the base allowlist so a no-network + // command can't reach them. Still an allowlist (mirrors Codex's Seatbelt + // network policy) that excludes LaunchServices/launchd. + if !matches!(permissions.network, NetworkAccess::None) { + config.push_str( + r#" +; Extra Mach services for DNS / TLS-trust / network configuration, needed only +; when outbound network is permitted. Still an allowlist that excludes +; LaunchServices/launchd. If hostname resolution fails, add +; `com.apple.mDNSResponder`; if offline code-signature verification of loaded +; dylibs/plugins fails, move the trust services into the base block above. +(allow mach-lookup + ; network / DNS configuration + (global-name "com.apple.SystemConfiguration.configd") + (global-name "com.apple.SystemConfiguration.DNSConfiguration") + (global-name "com.apple.networkd") + ; TLS certificate trust / keychain / revocation + (global-name "com.apple.SecurityServer") + (global-name "com.apple.trustd") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.ocspd")) +"#, + ); + } + if !allowed_unix_socket_paths.is_empty() { config.push_str( r#" @@ -396,6 +459,19 @@ mod tests { use super::*; use std::path::PathBuf; + /// Strip SBPL comment lines (`;`-prefixed) from a generated Seatbelt profile + /// so assertions match on the actual rules rather than on documentation. + /// Several comments legitimately mention rule syntax (for example the + /// blanket `(allow mach-lookup)` form they explain we avoid), which would + /// otherwise cause a naive substring check to spuriously match. + fn seatbelt_rules_only(config: &str) -> String { + config + .lines() + .filter(|line| !line.trim_start().starts_with(';')) + .collect::>() + .join("\n") + } + #[test] fn test_generate_seatbelt_config_contains_read_and_project_write_permissions_by_default() { let dir = PathBuf::from("/Users/test/projects/myproject"); @@ -443,6 +519,57 @@ mod tests { assert!(config.contains("^/dev/ttys[0-9]+")); } + #[test] + fn test_generate_seatbelt_config_scopes_mach_lookup_and_excludes_escape_services() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = + generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default()) + .unwrap(); + // Assert on the rules only: the mach-lookup comment intentionally spells + // out the blanket `(allow mach-lookup)` form it avoids, which a raw + // `config.contains` would match. + let rules = seatbelt_rules_only(&config); + + // A scoped allowlist, never the blanket form — a blanket `(allow + // mach-lookup)` would let a command reach LaunchServices/launchd and + // escape the sandbox via `open`. + assert!(rules.contains("(allow mach-lookup")); + assert!(!rules.contains("(allow mach-lookup)")); + assert!(rules.contains("com.apple.cfprefsd.daemon")); + + // The escape/abuse endpoints must fall through to `(deny default)`. + assert!(!rules.contains("launchservicesd")); + assert!(!rules.contains("com.apple.lsd")); + assert!(!rules.contains("com.apple.pasteboard")); + + // Network-only services must not be granted without network. + assert!(!rules.contains("com.apple.SecurityServer")); + assert!(!rules.contains("com.apple.SystemConfiguration.configd")); + } + + #[test] + fn test_generate_seatbelt_config_adds_network_mach_services_when_network_allowed() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = generate_seatbelt_config( + &[dir.as_path()], + &[], + &[], + SandboxPermissions { + network: NetworkAccess::All, + allow_fs_write: false, + }, + ) + .unwrap(); + + // DNS / TLS / network-config services appear only when network is allowed. + assert!(config.contains("com.apple.SystemConfiguration.configd")); + assert!(config.contains("com.apple.SecurityServer")); + assert!(config.contains("com.apple.trustd")); + assert!(config.contains("com.apple.trustd.agent")); + // ...but never the escape endpoints. + assert!(!config.contains("launchservicesd")); + } + #[test] fn test_generate_seatbelt_config_allows_unix_socket_paths_without_network() { let dir = PathBuf::from("/Users/test/projects/myproject"); diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs index 067a4c50c1a..466ced18c05 100644 --- a/crates/sandbox/src/sandbox.rs +++ b/crates/sandbox/src/sandbox.rs @@ -1487,6 +1487,184 @@ mod tests { } } +/// A directory that is about to be granted as a sandbox write path but may not +/// exist yet. Preparing one resolves the platform difference in how a +/// not-yet-existing write grant is materialized, while keeping the same security +/// property: the caller shows [`Self::canonical_path`] to the user and records +/// *that* as the grant, so approval is always against the real, symlink-resolved +/// target. +/// +/// - **Linux/WSL**: bubblewrap can only bind an existing inode, so the missing +/// directory (and any missing parents) is created **eagerly**, per component, +/// and the leaf's inode is pinned to read back its canonical path. If the +/// grant is denied, [`Self::discard`] removes exactly the directories that were +/// created, deepest-first, following no symlinks (`rmdir` only removes empty +/// dirs and never traverses a swapped-in symlink). +/// - **macOS**: Seatbelt resolves paths at syscall time and can grant a missing +/// path, so nothing is created here; the directory is materialized only after +/// approval via [`Self::finalize`]. +/// +/// The eventual bind is still protected by the usual capture-and-revalidate path +/// (`HostFilesystemLocation`), which re-pins the inode when the command runs. +pub struct GrantableWriteDir { + canonical_path: PathBuf, + /// Directories created eagerly to pin the inode, shallowest-first. Empty on + /// platforms that defer creation to [`Self::finalize`]. + eagerly_created: Vec, +} + +impl GrantableWriteDir { + /// Prepare `path` for use as a sandbox write grant. `path` must be absolute. + pub fn prepare(path: &Path) -> std::io::Result { + #[cfg(target_os = "linux")] + { + let mut eagerly_created = Vec::new(); + if let Err(error) = create_missing_dirs(path, &mut eagerly_created) { + // Roll back any partial creation so a failure leaves no litter. + for dir in eagerly_created.iter().rev() { + let _ = std::fs::remove_dir(dir); + } + return Err(error); + } + let canonical_path = pinned_canonical_path(path)?; + Ok(Self { + canonical_path, + eagerly_created, + }) + } + #[cfg(target_os = "macos")] + { + Ok(Self { + canonical_path: canonicalize_allowing_missing_leaf(path), + eagerly_created: Vec::new(), + }) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = path; + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "granting a not-yet-existing write directory is not supported on this platform", + )) + } + } + + /// The canonical, symlink-resolved path to show the user and record as the + /// grant. + pub fn canonical_path(&self) -> &Path { + &self.canonical_path + } + + /// Materialize the directory once the grant is approved. A no-op on platforms + /// that already created it eagerly. + pub fn finalize(&self) -> std::io::Result<()> { + #[cfg(target_os = "macos")] + { + std::fs::create_dir_all(&self.canonical_path)?; + } + Ok(()) + } + + /// Remove exactly the directories we created (deepest-first) when the grant + /// is denied. Best-effort; `rmdir` leaves non-empty dirs and swapped-in + /// symlinks untouched. + pub fn discard(self) { + for dir in self.eagerly_created.iter().rev() { + let _ = std::fs::remove_dir(dir); + } + } +} + +/// Create each missing component of `path` with `create_dir` (never +/// `create_dir_all`), recording exactly the directories created so they can be +/// removed again if the grant is denied. Components that already exist are left +/// alone. +#[cfg(target_os = "linux")] +fn create_missing_dirs(path: &Path, created: &mut Vec) -> std::io::Result<()> { + let mut cur = PathBuf::new(); + for component in path.components() { + cur.push(component); + match std::fs::create_dir(&cur) { + Ok(()) => created.push(cur.clone()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + +/// Open an `O_PATH` handle to `path` and read back the canonical path of the +/// inode it pins, so the value shown to the user reflects the real target even +/// when a component is a symlink. +#[cfg(target_os = "linux")] +fn pinned_canonical_path(path: &Path) -> std::io::Result { + use std::os::fd::AsRawFd as _; + use std::os::unix::fs::OpenOptionsExt as _; + let handle = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_CLOEXEC) + .open(path)?; + std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd())) +} + +#[cfg(all(test, target_os = "linux"))] +mod grantable_write_dir_tests { + use super::GrantableWriteDir; + use std::fs; + + #[test] + fn creates_missing_dirs_and_discard_removes_only_those() { + let root = tempfile::tempdir().unwrap(); + let existing = root.path().join("existing"); + fs::create_dir(&existing).unwrap(); + let target = existing.join("a").join("b").join("c"); + + let prepared = GrantableWriteDir::prepare(&target).unwrap(); + assert!(target.is_dir()); + assert_eq!(prepared.canonical_path(), target.canonicalize().unwrap()); + + prepared.discard(); + // Everything we created is gone... + assert!(!existing.join("a").exists()); + // ...but the pre-existing ancestor is untouched. + assert!(existing.is_dir()); + } + + #[test] + fn existing_dir_is_left_alone_and_not_removed_on_discard() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("already"); + fs::create_dir(&target).unwrap(); + + let prepared = GrantableWriteDir::prepare(&target).unwrap(); + assert!(target.is_dir()); + // We created nothing, so discard removes nothing. + prepared.discard(); + assert!(target.is_dir()); + } + + #[test] + fn canonical_path_resolves_a_symlinked_parent() { + let root = tempfile::tempdir().unwrap(); + let real = root.path().join("real"); + fs::create_dir(&real).unwrap(); + let link = root.path().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + // Granting `link/child` where `link` legitimately points at `real` must + // succeed and show the user the *resolved* `real/child`, not fail. + let prepared = GrantableWriteDir::prepare(&link.join("child")).unwrap(); + assert_eq!( + prepared.canonical_path(), + real.canonicalize().unwrap().join("child") + ); + assert!(real.join("child").is_dir()); + + prepared.discard(); + assert!(!real.join("child").exists()); + } +} + /// Canonicalize `path`, resolving symlinks, even when its final component /// doesn't exist yet. /// diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 29d9f91dc6b..ae63dbe75fc 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -2461,6 +2461,148 @@ mod tests { .unindent(), cx, ); + + // formatOnSave: true with formatOnSaveMode: modificationsIfAvailable + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": true, "editor.formatOnSaveMode": "modificationsIfAvailable" }"# + .to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "modifications_if_available" + } + "# + .unindent(), + cx, + ); + + // formatOnSave: true with formatOnSaveMode: modifications + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": true, "editor.formatOnSaveMode": "modifications" }"# + .to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "modifications" + } + "# + .unindent(), + cx, + ); + + // formatOnSave: true with formatOnSaveMode: file + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": true, "editor.formatOnSaveMode": "file" }"#.to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "on" + } + "# + .unindent(), + cx, + ); + + // formatOnSaveMode is ignored when formatOnSave is disabled, as in VS Code + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": false, "editor.formatOnSaveMode": "modifications" }"# + .to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "off" + } + "# + .unindent(), + cx, + ); + + // formatOnSaveMode alone does nothing, as formatOnSave defaults to false in VS Code + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSaveMode": "modifications" }"#.to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } + } + "# + .unindent(), + cx, + ); + + // formatOnSaveMode not set, formatOnSave: true + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": true }"#.to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "on" + } + "# + .unindent(), + cx, + ); + + // formatOnSaveMode not set, formatOnSave: false + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": false }"#.to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "off" + } + "# + .unindent(), + cx, + ); } #[track_caller] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 9b8584c4e6b..0d2f5511430 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -554,9 +554,15 @@ impl VsCodeSettings { extend_comment_on_newline: None, extend_list_on_newline: None, indent_list_on_tab: None, - format_on_save: self.read_bool("editor.guides.formatOnSave").map(|b| { - if b { - FormatOnSave::On + // In VS Code, `editor.formatOnSaveMode` only applies when `editor.formatOnSave` is enabled. + format_on_save: self.read_bool("editor.formatOnSave").map(|enabled| { + if enabled { + self.read_enum("editor.formatOnSaveMode", |s| match s { + "modificationsIfAvailable" => Some(FormatOnSave::ModificationsIfAvailable), + "modifications" => Some(FormatOnSave::Modifications), + _ => None, + }) + .unwrap_or(FormatOnSave::On) } else { FormatOnSave::Off } diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 0d27b3f9a84..460714c4622 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -812,6 +812,14 @@ pub struct SandboxPermissionsContent { /// to without prompting. Paths written by Zed are absolute. /// Default: [] pub write_paths: Option>, + + /// Whether to warn when a sandbox escalation prompt requests a domain or + /// write path that contains potentially confusable Unicode characters + /// (homoglyphs, invisible characters, or bidirectional overrides). When + /// enabled, such prompts show a warning that must be acknowledged before + /// the request can be allowed. + /// Default: true + pub warn_confusable_unicode: Option, } #[with_fallible_options] diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index f31ef08e1d2..71beec78f7d 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -923,12 +923,22 @@ pub struct PrettierSettingsContent { strum::VariantArray, strum::VariantNames, )] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum FormatOnSave { /// Files should be formatted on save. On, /// Files should not be formatted on save. Off, + /// Only lines with unstaged changes are formatted on save. + /// Requires source control and LSP range formatting support. + /// If no git diff is available or if the LSP doesn't support + /// range formatting, formatting is skipped. + Modifications, + /// Only lines with unstaged changes are formatted on save. + /// If no git diff is available (e.g., when source control is + /// unavailable) or if the LSP doesn't support range formatting, + /// falls back to formatting the whole file. + ModificationsIfAvailable, } /// Controls how line endings are normalized when a buffer is saved. diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 5abc65dc805..4203bc232af 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -106,6 +106,8 @@ pub struct AnthropicAvailableModel { pub default_temperature: Option, #[serde(default)] pub extra_beta_headers: Vec, + /// Whether Anthropic's fast mode is available for this model. + pub supports_fast_mode: Option, /// The model's mode (e.g. thinking) pub mode: Option, } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index f0f652d2baa..4628bcf34e5 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -787,6 +787,7 @@ pub enum GitPanelGroupBy { None, #[default] Status, + Staging, } #[derive( diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index b3a145c6598..ecc19ef8ac5 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -21,6 +21,7 @@ agent_settings.workspace = true agent_skills.workspace = true anyhow.workspace = true audio.workspace = true +client.workspace = true cloud_api_types.workspace = true component.workspace = true codestral.workspace = true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index ba35c4875ef..2f6ce0beec5 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -8934,7 +8934,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { SettingsPageItem::SectionHeader("Formatting"), SettingsPageItem::SettingItem(SettingItem { title: "Format On Save", - description: "Whether or not to perform a buffer format before saving.", + description: "On: format the whole buffer.\nOff: do not format.\nModifications: format only lines with unstaged changes; skips formatting when a git diff or LSP range formatting is unavailable.\nModifications If Available: same, but falls back to formatting the whole buffer.", field: Box::new( // TODO(settings_ui): this setting should just be a bool SettingField { diff --git a/crates/settings_ui/src/pages/sandbox_settings.rs b/crates/settings_ui/src/pages/sandbox_settings.rs index da69fdd5761..533054f015f 100644 --- a/crates/settings_ui/src/pages/sandbox_settings.rs +++ b/crates/settings_ui/src/pages/sandbox_settings.rs @@ -73,6 +73,25 @@ pub(crate) fn render_sandbox_settings_page( ) .tab_index(0), ) + .child({ + let docs_url = + client::zed_urls::sandboxing_docs(Some("persistent-sandbox-permissions"), cx); + let tooltip = format!("Opens {docs_url}"); + // Wrap in a row so the button shrinks to its content width instead + // of stretching across the settings page. + h_flex().child( + Button::new("sandbox-docs-link", "Learn more about sandboxing") + .label_size(LabelSize::Small) + .color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .tooltip(Tooltip::text(tooltip)) + .on_click(move |_, _, cx| cx.open_url(&docs_url)), + ) + }) .when(sandbox_enabled, |this| this .when_some(validation_error, |this, error| { this.child( @@ -145,6 +164,27 @@ pub(crate) fn render_sandbox_settings_page( empty_border, )), ) + .child(Divider::horizontal()) + .child( + v_flex() + .gap_4() + .child(SettingsSectionHeader::new("Escalation Prompts").no_padding(true)) + .child( + SwitchField::new( + "sandbox-warn-confusable-unicode", + Some("Warn About Confusable Unicode"), + Some( + "Warn when an approval prompt requests a domain or write path that contains potentially confusable Unicode characters, such as homoglyphs (i.e. two symbols that look similar, such as a Cyrillic `а`)" + .into(), + ), + permissions.warn_confusable_unicode, + move |state, _window, cx| { + set_warn_confusable_unicode(*state == ToggleState::Selected, cx); + }, + ) + .tab_index(0), + ), + ) ) .into_any_element() } @@ -418,6 +458,12 @@ fn set_allow_fs_write_all(value: bool, cx: &mut App) { }); } +fn set_warn_confusable_unicode(value: bool, cx: &mut App) { + update_sandbox_permissions(cx, move |permissions| { + permissions.warn_confusable_unicode = Some(value); + }); +} + fn add_network_host(host: String, cx: &mut App) { update_sandbox_permissions(cx, move |permissions| { let hosts = &mut permissions.network_hosts.get_or_insert_default().0; diff --git a/crates/terminal/src/alacritty.rs b/crates/terminal/src/alacritty.rs index 5766d8b8fde..6a8e9d0dd2f 100644 --- a/crates/terminal/src/alacritty.rs +++ b/crates/terminal/src/alacritty.rs @@ -821,6 +821,14 @@ pub(super) fn make_content(term: &Term, last_content: &Content) -> None }; + let bottom_line = term.screen_lines() as i32 - 1 - content.display_offset as i32; + let bottom_row_occupied = content.cursor.point.line.0 >= bottom_line + || cells + .iter() + .rev() + .take_while(|cell| cell.point.line >= bottom_line) + .any(|cell| cell.cell.character() != ' '); + Content { cells, mode: terminal_modes_from_alacritty(content.mode), @@ -835,6 +843,7 @@ pub(super) fn make_content(term: &Term, last_content: &Content) -> last_hovered_word: last_content.last_hovered_word.clone(), scrolled_to_top: content.display_offset == term.history_size(), scrolled_to_bottom: content.display_offset == 0, + bottom_row_occupied, } } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 796593fe45d..a1e1c1c937e 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -498,6 +498,7 @@ pub struct Content { pub last_hovered_word: Option, pub scrolled_to_top: bool, pub scrolled_to_bottom: bool, + pub bottom_row_occupied: bool, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -524,6 +525,7 @@ impl Default for Content { last_hovered_word: None, scrolled_to_top: false, scrolled_to_bottom: false, + bottom_row_occupied: false, } } } @@ -2673,7 +2675,8 @@ impl Terminal { Some(new_offset - old_offset) } - TouchPhase::Ended => None, + // Cancellation does not commit a scroll, same as a plain end. + TouchPhase::Ended | TouchPhase::Cancelled => None, } } diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index b74d28593d0..84c5f986617 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1033,6 +1033,10 @@ impl Element for TerminalElement { origin.x += gutter; if matches!(self.terminal_view.read(cx).mode, TerminalMode::Standalone) { + let should_anchor_to_bottom = { + let content = self.terminal.read(cx).last_content(); + content.scrolled_to_bottom && content.bottom_row_occupied + }; let scale_factor = window.scale_factor(); let line_height_pixels = px(line_height); let line_height_device_px = (f32::from(line_height_pixels) * scale_factor) @@ -1054,7 +1058,7 @@ impl Element for TerminalElement { let padding = px(padding_device_px as f32 / scale_factor.max(1.0)); size.height = snapped_height; - if self.terminal.read(cx).scrolled_to_bottom() { + if should_anchor_to_bottom { origin.y += padding; } } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 3b1e6db97fc..1e1c4b78190 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -2316,6 +2316,28 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[gpui::test] + async fn ctrl_q_is_forwarded_to_terminal_not_quit(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.simulate_keystrokes("ctrl-q"); + assert_eq!( + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()), + vec![vec![0x11]], + "ctrl-q in a focused terminal should send 0x11 to the PTY, not trigger zed::Quit", + ); + } + // Working directory calculation tests // No Worktrees in project -> home_dir() @@ -3031,6 +3053,72 @@ mod tests { }); } + async fn draw_standalone_terminal( + output: &[u8], + cx: &mut TestAppContext, + ) -> (gpui::Bounds, gpui::Size) { + let (project, workspace) = init_test(cx).await; + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + CursorShape::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + terminal.update(cx, |terminal, cx| { + terminal.write_output(output, cx); + }); + + let (terminal_view, cx) = cx.add_window_view(|window, cx| { + TerminalView::new( + terminal.clone(), + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }); + + let draw_size = gpui::size(px(400.), px(201.)); + cx.simulate_resize(draw_size); + cx.draw(gpui::Point::default(), draw_size, |_, _| { + terminal_view.clone().into_any_element() + }); + cx.run_until_parked(); + cx.draw(gpui::Point::default(), draw_size, |_, _| { + terminal_view.clone().into_any_element() + }); + + let bounds = terminal.read_with(cx, |terminal, _| { + terminal.last_content().terminal_bounds.bounds + }); + (bounds, draw_size) + } + + #[gpui::test] + async fn test_short_standalone_terminal_stays_top_anchored_on_resize(cx: &mut TestAppContext) { + let (bounds, _) = draw_standalone_terminal(b"$ ", cx).await; + assert_eq!(bounds.origin.y, px(0.)); + } + + #[gpui::test] + async fn test_full_standalone_terminal_stays_bottom_anchored_on_resize( + cx: &mut TestAppContext, + ) { + let (bounds, draw_size) = draw_standalone_terminal( + b"one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n", + cx, + ) + .await; + assert!(bounds.origin.y > px(0.)); + assert_eq!(bounds.bottom(), draw_size.height); + } + #[gpui::test] async fn test_tab_content_shows_terminal_title_when_custom_title_directly_set_empty( cx: &mut TestAppContext, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index dd88d767413..0fc345b1b7c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -6294,7 +6294,7 @@ impl Workspace { .children( self.notifications .iter() - .map(|(_, notification)| notification.clone().into_any()), + .map(|(_, notification)| notification.clone().into_any_element()), ), ) } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index c31385f4553..b83532b1636 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -24,7 +24,8 @@ use fuzzy::CharBag; use git::{ BISECT_LOG, COMMIT_MESSAGE, DOT_GIT, FETCH_HEAD, FSMONITOR_DAEMON, GC_PID, GITIGNORE, HOOKS_DIR, INFO_DIR, LFS_DIR, LOGS_DIR, LOGS_REF_STASH, OBJECTS_DIR, ORIG_HEAD, - REBASE_APPLY_DIR, REBASE_MERGE_DIR, REPO_EXCLUDE, SEQUENCER_DIR, status::GitSummary, + REBASE_APPLY_DIR, REBASE_MERGE_DIR, REFS_DIR, REFTABLE_DIR, REPO_EXCLUDE, SEQUENCER_DIR, + status::GitSummary, }; use gpui::{ App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority, @@ -111,6 +112,7 @@ pub struct LoadedFile { pub text: String, pub encoding: &'static Encoding, pub has_bom: bool, + pub is_writable: bool, } pub struct LoadedBinaryFile { @@ -272,16 +274,65 @@ struct BackgroundScannerState { watched_dir_abs_paths_by_entry_id: HashMap>, path_prefixes_to_scan: HashSet>, paths_to_scan: HashSet>, - /// The ids of all of the entries that were removed from the snapshot - /// as part of the current update. These entry ids may be re-used - /// if the same inode is discovered at a new path, or if the given - /// path is re-created after being deleted. - removed_entries: HashMap, + removed_entries: RemovedEntries, changed_paths: Vec>, prev_snapshot: Snapshot, scanning_enabled: bool, } +/// The entries that were removed from the snapshot as part of the current +/// update. Their entry ids may be re-used if the same inode is discovered +/// at a new path, or if the given path is re-created after being deleted. +/// +/// Symlink aliases inside the worktree share their inode (and usually mtime) +/// with the symlink target, so an inode may correspond to several entries. +/// The path index allows an exact match to take precedence over the +/// inode-based rename heuristics in that case. +#[derive(Default)] +struct RemovedEntries { + by_inode: HashMap, + by_path: HashMap, Entry>, +} + +impl RemovedEntries { + fn insert(&mut self, entry: &Entry) { + self.by_path.insert(entry.path.clone(), entry.clone()); + match self.by_inode.entry(entry.inode) { + hash_map::Entry::Occupied(mut o) => { + if entry.id > o.get().id { + o.insert(entry.clone()); + } + } + hash_map::Entry::Vacant(v) => { + v.insert(entry.clone()); + } + } + } + + fn take_by_path(&mut self, path: &RelPath, inode: u64) -> Option { + if self.by_path.get(path)?.inode != inode { + return None; + } + let removed = self.by_path.remove(path)?; + if let hash_map::Entry::Occupied(o) = self.by_inode.entry(removed.inode) + && o.get().id == removed.id + { + o.remove(); + } + Some(removed) + } + + fn take_by_inode(&mut self, inode: u64) -> Option { + let removed = self.by_inode.remove(&inode)?; + if let hash_map::Entry::Occupied(o) = self.by_path.entry(removed.path.clone()) + && o.get().id == removed.id + { + o.remove(); + } + Some(removed) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct EventRoot { path: Arc, @@ -1229,7 +1280,7 @@ impl LocalWorktree { scanning_enabled, path_prefixes_to_scan: Default::default(), paths_to_scan: Default::default(), - removed_entries: Default::default(), + removed_entries: RemovedEntries::default(), changed_paths: Default::default(), }), phase: BackgroundScannerPhase::InitialScan, @@ -1545,15 +1596,15 @@ impl LocalWorktree { // if it is too large // 5GB seems to be more reasonable, peaking at ~16GB, while 6GB jumps up to >24GB which seems like a // reasonable limit + const FILE_SIZE_MAX: u64 = 6 * 1024 * 1024 * 1024; // 6GB + let metadata = fs.metadata(&abs_path).await?; + if let Some(metadata) = metadata.as_ref() + && metadata.len >= FILE_SIZE_MAX { - const FILE_SIZE_MAX: u64 = 6 * 1024 * 1024 * 1024; // 6GB - if let Ok(Some(metadata)) = fs.metadata(&abs_path).await - && metadata.len >= FILE_SIZE_MAX - { - anyhow::bail!("File is too large to load"); - } + anyhow::bail!("File is too large to load"); } let (text, encoding, has_bom) = decode_file_text(fs.as_ref(), &abs_path).await?; + let is_writable = metadata.is_some_and(|metadata| metadata.is_writable); let worktree = this.upgrade().context("worktree was dropped")?; let file = match entry.await? { @@ -1587,6 +1638,7 @@ impl LocalWorktree { text, encoding, has_bom, + is_writable, }) }) } @@ -3086,21 +3138,11 @@ impl BackgroundScannerState { } fn reuse_entry_id(&mut self, entry: &mut Entry) { - if let Some(mtime) = entry.mtime { - // If an entry with the same inode was removed from the worktree during this scan, - // then it *might* represent the same file or directory. But the OS might also have - // re-used the inode for a completely different file or directory. - // - // Conditionally reuse the old entry's id: - // * if the mtime is the same, the file was probably been renamed. - // * if the path is the same, the file may just have been updated - if let Some(removed_entry) = self.removed_entries.remove(&entry.inode) { - if removed_entry.mtime == Some(mtime) || removed_entry.path == entry.path { - entry.id = removed_entry.id; - } - } else if let Some(existing_entry) = self.snapshot.entry_for_path(&entry.path) { - entry.id = existing_entry.id; - } + let Some(mtime) = entry.mtime else { + return; + }; + if let Some(entry_id) = self.reused_entry_id(&entry.path, entry.inode, mtime) { + entry.id = entry_id; } } @@ -3110,6 +3152,20 @@ impl BackgroundScannerState { path: &RelPath, metadata: &fs::Metadata, ) -> ProjectEntryId { + self.reused_entry_id(path, metadata.inode, metadata.mtime) + .unwrap_or_else(|| ProjectEntryId::new(next_entry_id)) + } + + fn reused_entry_id( + &mut self, + path: &RelPath, + inode: u64, + mtime: MTime, + ) -> Option { + if let Some(removed_entry) = self.removed_entries.take_by_path(path, inode) { + return Some(removed_entry.id); + } + // If an entry with the same inode was removed from the worktree during this scan, // then it *might* represent the same file or directory. But the OS might also have // re-used the inode for a completely different file or directory. @@ -3117,14 +3173,12 @@ impl BackgroundScannerState { // Conditionally reuse the old entry's id: // * if the mtime is the same, the file was probably been renamed. // * if the path is the same, the file may just have been updated - if let Some(removed_entry) = self.removed_entries.remove(&metadata.inode) { - if removed_entry.mtime == Some(metadata.mtime) || *removed_entry.path == *path { - return removed_entry.id; - } - } else if let Some(existing_entry) = self.snapshot.entry_for_path(path) { - return existing_entry.id; + if let Some(removed_entry) = self.removed_entries.take_by_inode(inode) { + (removed_entry.mtime == Some(mtime) || *removed_entry.path == *path) + .then_some(removed_entry.id) + } else { + Some(self.snapshot.entry_for_path(path)?.id) } - ProjectEntryId::new(next_entry_id) } async fn insert_entry(&mut self, entry: Entry, fs: &dyn Fs, watcher: &dyn Watcher) -> Entry { @@ -3292,17 +3346,7 @@ impl BackgroundScannerState { removed_dir_abs_paths.push(watch_path); } - match self.removed_entries.entry(entry.inode) { - hash_map::Entry::Occupied(mut e) => { - let prev_removed_entry = e.get_mut(); - if entry.id > prev_removed_entry.id { - *prev_removed_entry = entry.clone(); - } - } - hash_map::Entry::Vacant(e) => { - e.insert(entry.clone()); - } - } + self.removed_entries.insert(entry); if entry.path.file_name() == Some(GITIGNORE) { let abs_parent_path = self.snapshot.absolutize(&entry.path.parent().unwrap()); @@ -3420,14 +3464,9 @@ impl BackgroundScannerState { .context("failed to add repository directory to watcher") .log_err(); - // On Linux and FreeBSD, the native watcher is non-recursive, so subdirectories inside `.git` need explicit watching. - // For repos using the reftable backend, watch the `.git/reftable` directory so that ref changes are detected. - let reftable_path = common_dir_abs_path.join("reftable"); - if fs.is_dir(&reftable_path).await { - watcher - .add(&reftable_path) - .context("failed to add reftable directory to watcher") - .log_err(); + watch_git_dir_subdirectories(&common_dir_abs_path, fs, watcher).await; + if repository_dir_abs_path != common_dir_abs_path { + watch_git_dir_subdirectories(&repository_dir_abs_path, fs, watcher).await; } let work_directory_id = work_dir_entry.id; @@ -3451,6 +3490,55 @@ impl BackgroundScannerState { } } +/// Watches the directories inside a git directory that git writes ref updates to. +/// +/// On Linux and FreeBSD the native file watcher is non-recursive, so a watch on the git +/// directory itself does not report changes to files nested below it, such as the loose +/// refs that git updates on commit, fetch, and branch operations. Watch the `refs` tree +/// (its directories are watched individually because branch names may contain slashes) +/// and, for repositories using the reftable backend, the `reftable` directory. On +/// platforms with recursive watchers these calls are deduplicated against the existing +/// recursive registration, making them effectively free. +async fn watch_git_dir_subdirectories(git_dir_abs_path: &Path, fs: &dyn Fs, watcher: &dyn Watcher) { + let reftable_dir_abs_path = git_dir_abs_path.join(REFTABLE_DIR); + if fs.is_dir(&reftable_dir_abs_path).await { + watcher + .add(&reftable_dir_abs_path) + .context("failed to add reftable directory to watcher") + .log_err(); + } + + watch_dir_tree(git_dir_abs_path.join(REFS_DIR), fs, watcher).await; +} + +/// Watches a directory and all of its descendant directories. +/// +/// Each directory is watched before its children are enumerated, so that a child +/// created concurrently is either seen by the enumeration or reported by the watch. +async fn watch_dir_tree(root_abs_path: PathBuf, fs: &dyn Fs, watcher: &dyn Watcher) { + let mut dirs_to_watch = vec![root_abs_path]; + while let Some(dir_abs_path) = dirs_to_watch.pop() { + if !fs.is_dir(&dir_abs_path).await { + continue; + } + watcher + .add(&dir_abs_path) + .with_context(|| format!("failed to watch directory {dir_abs_path:?}")) + .log_err(); + let Some(mut children) = fs.read_dir(&dir_abs_path).await.log_err() else { + continue; + }; + while let Some(child_abs_path) = children.next().await { + let Some(child_abs_path) = child_abs_path.log_err() else { + continue; + }; + if fs.is_dir(&child_abs_path).await { + dirs_to_watch.push(child_abs_path); + } + } + } +} + async fn is_dot_git(path: &Path, fs: &dyn Fs) -> bool { if let Some(file_name) = path.file_name() && file_name == DOT_GIT @@ -4605,6 +4693,24 @@ impl BackgroundScanner { continue; } + // New directories can appear under the `refs` tree at any time, e.g. when a + // remote is added or a branch name contains slashes. On platforms where the + // native watcher is non-recursive they need their own watches, or subsequent + // ref updates inside them would go unnoticed. The subtree is walked because + // nested directories may have been created before this watch took effect. + if matches!(event.kind, Some(PathEventKind::Created)) + && path_in_git_dir + .components() + .any(|component| component.as_os_str() == OsStr::new(REFS_DIR)) + { + watch_dir_tree( + abs_path.as_path().to_path_buf(), + self.fs.as_ref(), + self.watcher.as_ref(), + ) + .await; + } + if !dot_git_abs_paths.contains(&dot_git_abs_path) { log::debug!( "detected update within git repo at {dot_git_abs_path:?}: {abs_path:?}" @@ -4800,7 +4906,8 @@ impl BackgroundScanner { { let mut state = self.state.lock().await; state.snapshot.completed_scan_id = state.snapshot.scan_id; - for (_, entry) in mem::take(&mut state.removed_entries) { + let RemovedEntries { by_inode, by_path } = mem::take(&mut state.removed_entries); + for entry in by_inode.into_values().chain(by_path.into_values()) { state.scanned_dirs.remove(&entry.id); } } @@ -5720,56 +5827,57 @@ impl BackgroundScanner { let scan_id = state.snapshot.scan_id; let mut affected_repo_roots = Vec::new(); for dot_git_dir in dot_git_paths { - let existing_repository_entry = - state - .snapshot - .git_repositories - .iter() - .find_map(|(_, repo)| { - let dot_git_dir = SanitizedPath::new(&dot_git_dir); - if SanitizedPath::new(repo.common_dir_abs_path.as_ref()) == dot_git_dir - || SanitizedPath::new(repo.repository_dir_abs_path.as_ref()) - == dot_git_dir - || SanitizedPath::new(repo.dot_git_abs_path.as_ref()) == dot_git_dir - { - Some(repo.clone()) - } else { - None - } - }); + // Several repositories can share a git directory: a linked worktree's + // commondir is the main checkout's `.git`, so a ref update there must + // refresh every repository that reads from it. + let existing_work_directory_ids = state + .snapshot + .git_repositories + .iter() + .filter_map(|(&work_directory_id, repo)| { + let dot_git_dir = SanitizedPath::new(&dot_git_dir); + if SanitizedPath::new(repo.common_dir_abs_path.as_ref()) == dot_git_dir + || SanitizedPath::new(repo.repository_dir_abs_path.as_ref()) == dot_git_dir + || SanitizedPath::new(repo.dot_git_abs_path.as_ref()) == dot_git_dir + { + Some(work_directory_id) + } else { + None + } + }) + .collect::>(); - match existing_repository_entry { - None => { - let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else { - // A `.git` path outside the worktree root is not - // ours to register. This happens legitimately when - // `.git` is a gitfile pointing outside the worktree - // (linked worktrees and submodules), and also when - // a rescan of a linked worktree's commondir arrives - // after the worktree's repository has already been - // unregistered. - continue; - }; - affected_repo_roots.push(dot_git_dir.parent().unwrap().into()); + if existing_work_directory_ids.is_empty() { + let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else { + // A `.git` path outside the worktree root is not + // ours to register. This happens legitimately when + // `.git` is a gitfile pointing outside the worktree + // (linked worktrees and submodules), and also when + // a rescan of a linked worktree's commondir arrives + // after the worktree's repository has already been + // unregistered. + continue; + }; + affected_repo_roots.push(dot_git_dir.parent().unwrap().into()); + state + .insert_git_repository( + RelPath::new(relative, PathStyle::local()) + .unwrap() + .into_arc(), + self.fs.as_ref(), + self.watcher.as_ref(), + ) + .await; + } else { + for work_directory_id in existing_work_directory_ids { state - .insert_git_repository( - RelPath::new(relative, PathStyle::local()) - .unwrap() - .into_arc(), - self.fs.as_ref(), - self.watcher.as_ref(), - ) - .await; - } - Some(local_repository) => { - state.snapshot.git_repositories.update( - &local_repository.work_directory_id, - |entry| { + .snapshot + .git_repositories + .update(&work_directory_id, |entry| { entry.git_dir_scan_id = scan_id; - }, - ); + }); } - }; + } } // Remove any git repositories whose .git entry no longer exists. diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index 1fd7ed5b557..b4b6c2d1aee 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -1198,6 +1198,98 @@ async fn test_real_fs_scan_symlinks_expanded(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_internal_symlink_updates_preserve_entry_ids(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + + fs.insert_tree( + "/root", + json!({ + "project": { + "real-dir": { + "existing.rs": "old", + }, + "links": {} + } + }), + ) + .await; + + fs.create_symlink( + "/root/project/links/internal".as_ref(), + "../real-dir".into(), + ) + .await + .unwrap(); + + let tree = Worktree::local( + Path::new("/root/project"), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + let (real_entry_id, symlink_entry_id, old_mtime) = tree.read_with(cx, |tree, _| { + let real_entry = tree + .entry_for_path(rel_path("real-dir/existing.rs")) + .unwrap(); + let symlink_entry = tree + .entry_for_path(rel_path("links/internal/existing.rs")) + .unwrap(); + assert_eq!(real_entry.inode, symlink_entry.inode); + assert_eq!(real_entry.mtime, symlink_entry.mtime); + assert_ne!(real_entry.id, symlink_entry.id); + (real_entry.id, symlink_entry.id, real_entry.mtime) + }); + + fs.write(Path::new("/root/project/real-dir/existing.rs"), b"new") + .await + .unwrap(); + + wait_for_condition(cx, |cx| { + tree.read_with(cx, |tree, _| { + let real_entry = tree + .entry_for_path(rel_path("real-dir/existing.rs")) + .unwrap(); + let symlink_entry = tree + .entry_for_path(rel_path("links/internal/existing.rs")) + .unwrap(); + real_entry.mtime != old_mtime && symlink_entry.mtime != old_mtime + }) + }) + .await; + + tree.read_with(cx, |tree, _| { + let real_entry = tree + .entry_for_path(rel_path("real-dir/existing.rs")) + .unwrap(); + let symlink_entry = tree + .entry_for_path(rel_path("links/internal/existing.rs")) + .unwrap(); + + assert_eq!(real_entry.inode, symlink_entry.inode); + assert_eq!(real_entry.id, real_entry_id); + assert_eq!( + tree.entry_for_id(real_entry_id).unwrap().path.as_ref(), + rel_path("real-dir/existing.rs") + ); + assert_eq!(symlink_entry.id, symlink_entry_id); + assert_eq!( + tree.entry_for_id(symlink_entry_id).unwrap().path.as_ref(), + rel_path("links/internal/existing.rs") + ); + }); +} + #[cfg(target_os = "macos")] #[gpui::test] async fn test_renaming_case_only(cx: &mut TestAppContext) { @@ -4107,6 +4199,86 @@ async fn test_linked_worktree_gitfile_event_preserves_repo( }); } +#[gpui::test] +async fn test_shared_common_dir_event_updates_all_repositories( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + // A main checkout and one of its linked worktrees can both live inside the + // same project worktree, sharing a common git directory. An event in that + // common directory (e.g. a ref update) must refresh every repository that + // reads from it, not just the first match. + init_test(cx); + + use git::repository::Worktree as GitWorktree; + + let fs = FakeFs::new(executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "main_repo": { + ".git": {}, + "file.txt": "content", + }, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new(path!("/project/main_repo/.git")), + false, + GitWorktree { + path: PathBuf::from(path!("/project/linked")), + ref_name: Some("refs/heads/feature".into()), + sha: "abc123".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + let tree = Worktree::local( + path!("/project").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 mut events = cx.events(&tree); + fs.emit_fs_event( + path!("/project/main_repo/.git/refs/heads/main"), + Some(PathEventKind::Changed), + ); + executor.run_until_parked(); + + let mut updated_work_dirs = Vec::new(); + while let Ok(event) = events.try_recv() { + if let Event::UpdatedGitRepositories(updates) = event { + updated_work_dirs.extend( + updates + .iter() + .filter_map(|update| update.new_work_directory_abs_path.clone()), + ); + } + } + updated_work_dirs.sort(); + assert_eq!( + updated_work_dirs, + [ + Arc::from(Path::new(path!("/project/linked"))), + Arc::from(Path::new(path!("/project/main_repo"))), + ], + "a ref update in the shared common dir should refresh both repositories" + ); +} + #[gpui::test] async fn test_noisy_dot_git_events_do_not_emit_git_repo_update( executor: BackgroundExecutor, @@ -4449,6 +4621,89 @@ async fn test_dot_git_dir_event_does_not_suppress_children( } } +#[gpui::test] +async fn test_ref_updates_in_dot_git_subdirectories_are_detected(cx: &mut TestAppContext) { + // On Linux and FreeBSD the native file watcher is non-recursive: watching `.git` + // does not deliver events for files nested below it, like the loose refs that git + // updates on commit, fetch, and branch operations. The worktree must watch the + // `refs` tree explicitly, including directories created after the initial scan. + init_test(cx); + cx.executor().allow_parking(); + + let dir = TempTree::new(json!({ + ".git": {}, + "a.txt": "a-contents", + })); + std::fs::write( + dir.path().join(".git/refs/heads/main"), + "0000000000000000000000000000000000000000\n", + ) + .unwrap(); + + let tree = Worktree::local( + dir.path(), + true, + Arc::new(RealFs::new(None, cx.executor())), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + tree.flush_fs_events(cx).await; + + let mut events = cx.events(&tree); + std::fs::write( + dir.path().join(".git/refs/heads/main"), + "1111111111111111111111111111111111111111\n", + ) + .unwrap(); + expect_git_repo_update(&mut events, cx, "updating a loose ref").await; + + std::fs::create_dir_all(dir.path().join(".git/refs/remotes/origin")).unwrap(); + expect_git_repo_update(&mut events, cx, "creating a directory under refs").await; + tree.flush_fs_events(cx).await; + drain_git_repo_updates(&mut events); + + std::fs::write( + dir.path().join(".git/refs/remotes/origin/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + expect_git_repo_update( + &mut events, + cx, + "updating a ref in a directory created after the initial scan", + ) + .await; +} + +async fn expect_git_repo_update( + events: &mut futures::channel::mpsc::UnboundedReceiver, + cx: &mut TestAppContext, + description: &str, +) { + let mut elapsed = std::time::Duration::ZERO; + let timeout = std::time::Duration::from_secs(10); + let poll_interval = std::time::Duration::from_millis(50); + loop { + match events.try_recv() { + Ok(Event::UpdatedGitRepositories(_)) => return, + Ok(_) => continue, + Err(_) => {} + } + assert!( + elapsed < timeout, + "timed out waiting for UpdatedGitRepositories after {description}" + ); + cx.background_executor.timer(poll_interval).await; + elapsed += poll_interval; + } +} + fn drain_git_repo_updates(events: &mut futures::channel::mpsc::UnboundedReceiver) -> bool { let mut found = false; while let Ok(event) = events.try_recv() { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 2b67736bf0e..213147207c3 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.11.0" +version = "1.12.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index fd0705d5d94..d59e80a35d2 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -818,10 +818,7 @@ async fn open_workspaces( ) -> Result<()> { if paths.is_empty() && diff_paths.is_empty() - && !matches!( - open_behavior, - cli::OpenBehavior::AlwaysNew | cli::OpenBehavior::PreferNewWindow - ) + && !matches!(open_behavior, cli::OpenBehavior::AlwaysNew) { return restore_or_create_workspace(app_state, cx).await; } @@ -1109,7 +1106,8 @@ mod tests { use remote::SshConnectionOptions; use rope::Rope; use serde_json::json; - use std::{sync::Arc, task::Poll}; + use session::Session; + use std::{path::Path, sync::Arc, task::Poll}; use util::path; use workspace::{AppState, MultiWorkspace}; @@ -2653,6 +2651,78 @@ mod tests { ); } + #[gpui::test] + async fn test_e2e_new_window_setting_restores_workspace_when_no_paths(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree(path!("/project"), json!({ "file.txt": "content" })) + .await; + + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.cli_default_open_behavior = + Some(settings::CliDefaultOpenBehavior::NewWindow); + }); + }); + }); + + let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned()); + + open_workspace_file(path!("/project"), Default::default(), app_state.clone(), cx).await; + assert_eq!(cx.windows().len(), 1); + + let multi_workspace = cx.windows()[0].downcast::().unwrap(); + let serialization_tasks = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.flush_all_serialization(window, cx) + }) + .unwrap(); + futures::future::join_all(serialization_tasks).await; + + multi_workspace + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + cx.run_until_parked(); + assert_eq!(cx.windows().len(), 0); + + cx.update(|cx| { + app_state.session.update(cx, |app_session, _cx| { + app_session.replace_session_for_test(Session::test_with_old_session(session_id)); + }); + }); + + let (status, prompt_shown) = run_cli_with_zed_handler( + cx, + app_state, + make_cli_open_request(Vec::new(), cli::OpenBehavior::Default), + None, + ); + + assert_eq!(status, 0); + assert!( + !prompt_shown, + "no prompt should be shown when no windows exist" + ); + assert_eq!(cx.windows().len(), 1); + + let restored_window = cx.windows()[0].downcast::().unwrap(); + restored_window + .read_with(cx, |multi_workspace, cx| { + let root_paths = multi_workspace.workspace().read(cx).root_paths(cx); + assert!( + root_paths + .iter() + .any(|path| path.as_ref() == Path::new(path!("/project"))), + "expected CLI launch with no paths to restore /project, got {root_paths:?}" + ); + }) + .unwrap(); + } + #[gpui::test] async fn test_e2e_new_window_setting_opens_project_root_in_new_window(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/docs/book.toml b/docs/book.toml index 2eed5f7907a..e1a48d1f7f0 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -45,10 +45,10 @@ enable = false "/assistant.html" = "/docs/assistant/assistant.html" "/assistant/assistant-panel.html" = "/docs/ai/agent-panel.html" "/assistant/assistant.html" = "/docs/ai/overview.html" -"/assistant/commands.html" = "/docs/ai/text-threads.html" +"/assistant/commands.html" = "/docs/ai/agent-panel.html" "/assistant/configuration.html" = "/docs/ai/quick-start.html" "/assistant/context-servers.html" = "/docs/ai/mcp.html" -"/assistant/contexts.html" = "/docs/ai/text-threads.html" +"/assistant/contexts.html" = "/docs/ai/agent-panel.html" "/assistant/inline-assistant.html" = "/docs/ai/inline-assistant.html" "/assistant/model-context-protocol.html" = "/docs/ai/mcp.html" "/assistant/prompting.html" = "/docs/ai/skills.html" diff --git a/docs/src/account/zed-hosted-models.md b/docs/src/account/zed-hosted-models.md index a74fd81b572..cc79a54ddf7 100644 --- a/docs/src/account/zed-hosted-models.md +++ b/docs/src/account/zed-hosted-models.md @@ -1,16 +1,20 @@ --- title: Zed-Hosted Models - Zed -description: AI models available via Zed Pro including Claude, GPT-5.5, Gemini 3.1 Pro, and Gemini 3.5 Flash. Pricing, context windows, and tool call support. +description: AI models available via Zed Pro including Claude Fable 5, Claude Sonnet 5, GPT-5.6, and Gemini 3. Pricing, context windows, and tool call support. --- # Zed-Hosted Models Zed's plans offer hosted versions of major LLMs with higher rate limits than direct API access. Model availability is updated regularly. To use your own API keys instead, see [LLM Providers](../ai/llm-providers.md). For general setup, see [AI Quick Start](../ai/quick-start.md). -> **Note:** Claude Opus models, GPT-5.5 pro, and GPT-5.4 pro are only available on Zed Pro and Zed Business. +> **Note:** Claude Fable 5, Claude Opus models, GPT-5.5 pro, and GPT-5.4 pro are only available on Zed Pro and Zed Business. | Model | Provider | Token Type | Provider Price per 1M tokens | Zed Price per 1M tokens | | ----------------- | --------- | ------------------- | ---------------------------- | ----------------------- | +| Claude Fable 5 | Anthropic | Input | $10.00 | $11.00 | +| | Anthropic | Output | $50.00 | $55.00 | +| | Anthropic | Input - Cache Write | $12.50 | $13.75 | +| | Anthropic | Input - Cache Read | $1.00 | $1.10 | | Claude Opus 4.5 | Anthropic | Input | $5.00 | $5.50 | | | Anthropic | Output | $25.00 | $27.50 | | | Anthropic | Input - Cache Write | $6.25 | $6.875 | @@ -27,6 +31,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 Sonnet 5 | Anthropic | Input | $2.00 | $2.20 | +| | Anthropic | Output | $10.00 | $11.00 | +| | Anthropic | Input - Cache Write | $2.50 | $2.75 | +| | Anthropic | Input - Cache Read | $0.20 | $0.22 | | Claude Sonnet 4.5 | Anthropic | Input | $3.00 | $3.30 | | | Anthropic | Output | $15.00 | $16.50 | | | Anthropic | Input - Cache Write | $3.75 | $4.125 | @@ -39,6 +47,18 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir | | Anthropic | Output | $5.00 | $5.50 | | | Anthropic | Input - Cache Write | $1.25 | $1.375 | | | Anthropic | Input - Cache Read | $0.10 | $0.11 | +| GPT-5.6 Sol | OpenAI | Input | $5.00 | $5.50 | +| | OpenAI | Output | $30.00 | $33.00 | +| | OpenAI | Input - Cache Write | $6.25 | $6.875 | +| | OpenAI | Cached Input | $0.50 | $0.55 | +| GPT-5.6 Terra | OpenAI | Input | $2.50 | $2.75 | +| | OpenAI | Output | $15.00 | $16.50 | +| | OpenAI | Input - Cache Write | $3.125 | $3.4375 | +| | OpenAI | Cached Input | $0.25 | $0.275 | +| GPT-5.6 Luna | OpenAI | Input | $1.00 | $1.10 | +| | OpenAI | Output | $6.00 | $6.60 | +| | OpenAI | Input - Cache Write | $1.25 | $1.375 | +| | OpenAI | Cached Input | $0.10 | $0.11 | | GPT-5.5 pro | OpenAI | Input | $30.00 | $33.00 | | | OpenAI | Output | $180.00 | $198.00 | | GPT-5.5 | OpenAI | Input | $5.00 | $5.50 | @@ -71,17 +91,22 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir | Gemini 3 Flash | Google | Input | $0.50 | $0.55 | | | Google | Output | $3.00 | $3.30 | -## Recent Model Retirements +> **Warn:** Anthropic retains prompts and outputs sent to Claude Fable 5 for at least 30 days for trust and safety purposes. The no-training commitment still applies, and Zed does not retain your prompts or outputs. See [Provider Safety Retention for Designated Models](../ai/privacy-and-security.md#provider-safety-retention). -As of February 19, 2026, Zed Pro serves newer model versions in place of the retired models below: +The Claude Sonnet 5 prices shown above use Anthropic's introductory pricing through August 31, 2026. + +## Recent Model Retirements {#recent-model-retirements} + +Zed no longer offers the models below: - 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 +- Claude Sonnet 3.7 (retired February 19, 2026) → Claude Sonnet 4.5 or Claude Sonnet 4.6 - GPT-5.1 and GPT-5 → GPT-5.2 or GPT-5.2-Codex - Gemini 2.5 Pro → Gemini 3.1 Pro -- Gemini 3 Pro → Gemini 3.1 Pro +- Gemini 3 Pro (retired March 26, 2026) → Gemini 3.1 Pro - Gemini 2.5 Flash → Gemini 3 Flash or Gemini 3.5 Flash +- Grok 4, Grok 4 Fast, Grok 4 Fast (Non-Reasoning), and Grok Code Fast 1 ([retired May 15, 2026](https://docs.x.ai/developers/migration/may-15-retirement)). Zed no longer offers hosted xAI models. ## Usage {#usage} @@ -97,13 +122,18 @@ A context window is the maximum span of text and code an LLM can consider at onc | Model | Provider | Zed-Hosted Context Window | | ----------------- | --------- | ------------------------- | +| Claude Fable 5 | Anthropic | 1M | | 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 5 | Anthropic | 1M | | Claude Sonnet 4.5 | Anthropic | 200k | | Claude Sonnet 4.6 | Anthropic | 1M | | Claude Haiku 4.5 | Anthropic | 200k | +| GPT-5.6 Sol | OpenAI | 272k input / 400k total | +| GPT-5.6 Terra | OpenAI | 272k input / 400k total | +| GPT-5.6 Luna | OpenAI | 272k input / 400k total | | GPT-5.5 pro | OpenAI | 272k input / 400k total | | GPT-5.5 | OpenAI | 272k input / 400k total | | GPT-5.4 pro | OpenAI | 272k input / 400k total | diff --git a/docs/src/ai/ai-improvement.md b/docs/src/ai/ai-improvement.md index 72ff46b37fa..172a70e3f97 100644 --- a/docs/src/ai/ai-improvement.md +++ b/docs/src/ai/ai-improvement.md @@ -10,7 +10,7 @@ Normal AI requests are not retained by Zed. For prohibit training on your prompts or code context and require zero data retention, except for [provider-designated models with safety retention](./privacy-and-security.md#provider-safety-retention), -such as Anthropic's Mythos-class models. +such as Anthropic's Covered Models. This page covers the cases where Zed may retain AI data because you explicitly shared it or opted in. @@ -28,7 +28,7 @@ For the broader request path and provider data boundaries, see Zed-hosted model zero-data-retention and no-training commitments, including the exception for provider-designated models with safety retention (such as -Anthropic's Mythos-class models), are documented +Anthropic's Covered Models), are documented on [AI Privacy](./privacy-and-security.md#data-retention-and-training). ## Response Ratings and Feedback {#ai-feedback-with-ratings} diff --git a/docs/src/ai/privacy-and-security.md b/docs/src/ai/privacy-and-security.md index 354feba64af..3943206a37f 100644 --- a/docs/src/ai/privacy-and-security.md +++ b/docs/src/ai/privacy-and-security.md @@ -15,24 +15,24 @@ Zed does not retain your prompts or code context by default. For commitments from model providers, and provider agreements require zero data retention for inference requests except for [provider-designated models with safety retention](#provider-safety-retention), -such as Anthropic's Mythos-class models. +such as Anthropic's Covered Models. Zed only retains AI data when you explicitly share feedback or opt in to training data collection. ## AI Request Paths {#ai-request-paths} -| Path | Who handles model requests | What to know | Details | -| ------------------------------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| [Zed-hosted models](../account/zed-hosted-models.md) | Zed routes requests to hosted model providers | Provider agreements prohibit training on your prompts or code context and require zero data retention for inference requests, except for provider-designated models with safety retention, such as Anthropic's Mythos-class models. | [Zed-hosted model commitments](#data-retention-and-training) | -| [Provider API keys](./use-api-access.md) | The configured provider | The provider handles requests under its own terms. Provider keys saved through Zed are stored in the system keychain, not in `settings.json`. | [Use API Access](./use-api-access.md) | -| [Existing subscriptions](./use-an-existing-subscription.md) | The subscription provider | The provider handles requests under the subscription terms. | [Use an Existing Subscription](./use-an-existing-subscription.md) | -| [Gateways](./use-a-gateway.md) | The configured gateway and upstream providers | The gateway and upstream providers handle requests under their own terms. | [Use a Gateway](./use-a-gateway.md) | -| [Local models](./use-a-local-model.md) | The local server or self-hosted endpoint | The local server handles requests according to how you configured that server. | [Use a Local Model](./use-a-local-model.md) | -| [External Agents](./external-agents.md) | The External Agent and its configured providers | The External Agent handles model requests under its own terms. Tool and MCP behavior depends on agent and ACP configuration. | [External Agents](./external-agents.md) | -| [Terminal Threads](./terminal-threads.md) | The CLI or TUI running in the terminal | The CLI or TUI owns its auth, model routing, tools, instructions, MCP configuration, and data handling. | [Terminal Threads](./terminal-threads.md) | -| [Edit Prediction](./edit-prediction.md) | The selected edit prediction provider | Each keystroke can send local editing context to the selected provider. Zeta requests are processed transiently unless training data collection is enabled; third-party providers follow their own terms. | [Edit Prediction](./edit-prediction.md), [Feedback and Training Data](./ai-improvement.md) | -| [Agent tools](./tools.md), [MCP](./mcp.md), and integrations | Zed, configured MCP servers, and external systems | Tools can read, edit, search, run commands, fetch URLs, or call external systems depending on profile, MCP server, and tool permission settings. | [Agent Profiles](./agent-profiles.md), [Tool Permissions](./tool-permissions.md), [MCP](./mcp.md) | -| Project trust and instructions | Zed and the trusted worktree | Project-local instructions and skills are loaded from trusted worktrees. External Agents and Terminal Threads may read their own instruction files. | [Worktree Trust](../worktree-trust.md), [Skills](./skills.md), [Instructions](./instructions.md) | +| Path | Who handles model requests | What to know | Details | +| ------------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | +| [Zed-hosted models](../account/zed-hosted-models.md) | Zed routes requests to hosted model providers | Provider agreements prohibit training on your prompts or code context and require zero data retention for inference requests, except for provider-designated models with safety retention, such as Anthropic's Covered Models. | [Zed-hosted model commitments](#data-retention-and-training) | +| [Provider API keys](./use-api-access.md) | The configured provider | The provider handles requests under its own terms. Provider keys saved through Zed are stored in the system keychain, not in `settings.json`. | [Use API Access](./use-api-access.md) | +| [Existing subscriptions](./use-an-existing-subscription.md) | The subscription provider | The provider handles requests under the subscription terms. | [Use an Existing Subscription](./use-an-existing-subscription.md) | +| [Gateways](./use-a-gateway.md) | The configured gateway and upstream providers | The gateway and upstream providers handle requests under their own terms. | [Use a Gateway](./use-a-gateway.md) | +| [Local models](./use-a-local-model.md) | The local server or self-hosted endpoint | The local server handles requests according to how you configured that server. | [Use a Local Model](./use-a-local-model.md) | +| [External Agents](./external-agents.md) | The External Agent and its configured providers | The External Agent handles model requests under its own terms. Tool and MCP behavior depends on agent and ACP configuration. | [External Agents](./external-agents.md) | +| [Terminal Threads](./terminal-threads.md) | The CLI or TUI running in the terminal | The CLI or TUI owns its auth, model routing, tools, instructions, MCP configuration, and data handling. | [Terminal Threads](./terminal-threads.md) | +| [Edit Prediction](./edit-prediction.md) | The selected edit prediction provider | Each keystroke can send local editing context to the selected provider. Zeta requests are processed transiently unless training data collection is enabled; third-party providers follow their own terms. | [Edit Prediction](./edit-prediction.md), [Feedback and Training Data](./ai-improvement.md) | +| [Agent tools](./tools.md), [MCP](./mcp.md), and integrations | Zed, configured MCP servers, and external systems | Tools can read, edit, search, run commands, fetch URLs, or call external systems depending on profile, MCP server, and tool permission settings. | [Agent Profiles](./agent-profiles.md), [Tool Permissions](./tool-permissions.md), [MCP](./mcp.md) | +| Project trust and instructions | Zed and the trusted worktree | Project-local instructions and skills are loaded from trusted worktrees. External Agents and Terminal Threads may read their own instruction files. | [Worktree Trust](../worktree-trust.md), [Skills](./skills.md), [Instructions](./instructions.md) | ## Zed-Hosted Model Commitments {#data-retention-and-training} @@ -40,7 +40,7 @@ For Zed-hosted models, Zed has commitments from model providers that prohibit training on your prompts or code context and require zero data retention for inference requests, except for [provider-designated models with safety retention](#provider-safety-retention), -such as Anthropic's Mythos-class models. The public provider documents linked below describe provider programs or default +such as Anthropic's Covered Models. The public provider documents linked below describe provider programs or default API terms; Zed-hosted model requests are governed by Zed's provider agreements. | Provider | No training reference | Zero-data-retention reference | @@ -53,11 +53,10 @@ API terms; Zed-hosted model requests are governed by Zed's provider agreements. Some providers require limited data retention for specific models as a condition of offering them, on every platform where those models are available. Anthropic -retains prompts and outputs for models it designates as covered models (its -Mythos-class models, such as Claude Fable 5) for 30 days for trust and safety -purposes. Zed cannot opt out of this retention; it applies wherever these models -are served. See -[Anthropic's data retention practices for Mythos-class models](https://support.claude.com/en/articles/15425996-data-retention-practices-for-mythos-class-models). +retains prompts and outputs for models it designates as Covered Models, including +Claude Fable 5, for at least 30 days for trust and safety purposes. Zed cannot +opt out of this retention; it applies wherever these models are served. See +[Anthropic's data retention practices for Covered Models](https://support.claude.com/en/articles/15425996-data-retention-practices-for-covered-models). For these models: diff --git a/docs/src/ai/sandboxing.md b/docs/src/ai/sandboxing.md index 715e331da7f..5603d3c307f 100644 --- a/docs/src/ai/sandboxing.md +++ b/docs/src/ai/sandboxing.md @@ -9,9 +9,11 @@ You can restrict what operations the [Zed Agent](./zed-agent.md) can run in mult [Tool Permissions](./tool-permissions.md), but these are of limited use when the agent wants to do things like run a complicated script in a terminal. -Sandboxing runs certain tool actions in an OS-level sandbox which limits filesystem access and network access, while -protecting Git metadata such as `.git` directories. This way, even if the agent wants to run an arbitrary script, that -script will only be able to write to the files and folders you have allowed it to. +Sandboxing instead uses OS features to forcibly restrict which resources a tool +call has access to. This does _not_ rely on an agent following a particular set +of instructions. If the agent attempts to access a resource that is restricted +by the sandbox, the OS will block it. See [How much can I trust the +sandbox?](#trust) for more details. [Tool Permissions](./tool-permissions.md) can be used in addition to sandboxing: @@ -23,27 +25,104 @@ terminal tabs, [External Agents](./external-agents.md), or [Terminal Threads](./ ## Sandboxed Tools {#sandboxed-tools} -Zed Agent sandboxing currently applies to the `terminal` tool. +Zed Agent sandboxing currently applies to the `terminal` and `fetch` tools. | Tool | What sandboxing limits | | ---------- | ----------------------------------------------------------------------------------------------------- | | `terminal` | Filesystem writes and outbound network access for commands the agent runs; Git metadata is protected. | +| `fetch` | Hosts which can be accessed. | -Other built-in tools, including `fetch`, are still governed by [Tool Permissions](./tool-permissions.md), -[Agent Profiles](./agent-profiles.md), and project trust, but they are not currently run inside this OS sandbox. +Tools are still governed by [Tool Permissions](./tool-permissions.md), [Agent +Profiles](./agent-profiles.md), and project trust, but they are not currently +run inside this OS sandbox. + +## Requirements {#requirements} + +Sandboxing is supported, in some form, on all platforms. In order to sandbox a +`terminal` tool call, the following is required: + +- On Linux, a runnable, non-setuid `bwrap` binary must be on the `$PATH`. See [Installing Bubblewrap](#installing-bubblewrap). +- On Windows, WSL must be available. + +There are no extra requirements on MacOS. + +The `fetch` tool has no extra requirements on any platform. ## Default Access {#default-access} By default, sandboxed Zed Agent tool actions have these restrictions: -| Access type | Default behavior | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Filesystem reads | Terminal commands can read most of the filesystem, including protected Git metadata. | -| Project writes | Terminal commands can write inside open project directories, except for protected Git metadata. | -| Git metadata | `.git` directories and linked worktree Git metadata remain readable but are not writable while sandboxed. | -| Temporary files | Terminal commands receive a writable temporary location. The exact behavior differs by platform. | -| Other writes | Writes outside the default writable locations are blocked unless you approve a broader sandbox request. | -| Outbound networking | Network access is blocked unless you approve a host-specific or unrestricted network sandbox request. Host-specific enforcement is not available on every platform. | +| Access type | Default behavior | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Filesystem reads | Terminal commands can read most of the filesystem, including protected Git metadata. | +| Project writes | Terminal commands can write inside open project directories, except for protected Git metadata. | +| Git metadata | `.git` directories and linked worktree Git metadata remain readable but are not writable while sandboxed. | +| Temporary files | Terminal commands receive a writable temporary location. The exact behavior differs by platform. | +| Other writes | Writes outside the default writable locations are blocked unless you approve a broader sandbox request. | +| Outbound networking | Network access is blocked unless you approve a host-specific or unrestricted network sandbox request. Host-specific enforcement is not available on every platform. | +| Local IPC sockets | Sandboxed commands cannot open Unix-domain sockets (for example, to the desktop session bus or a container daemon), which could otherwise be used to run commands outside the sandbox. | + +## How much can I trust the sandbox? {#trust} + +Enabling sandboxing dramatically reduces the risk of various kinds of attacks. +However, it does not fully eliminate them. + +Firstly, sandboxing relies on OS-level features, which may contain bugs. +Operating systems have historically had bugs in security features. And while we +have tested thoroughly, there may also be bugs in Zed's implementation. These +could allow privilege escalation - for example, allow an agent to write to a +file that it should only have read access to. + +Sandboxing also only applies the restrictions that the user requested. If an +agent requests write access to your home directory, sandboxing will (and +should!) do nothing to prevent an agent adding a malicious key to `$HOME/.ssh`. + +Be careful with what you grant the agent. At any point in time, you can view the +state of the sandbox by hovering the padlock icon in the top right of the +thread. If an agent is requesting an overly broad permission, deny it, and ask +it to use a smaller grant. When requesting elevated privileges, agents must +provide a `reason`, which is displayed in the prompt. Read it, and decide +whether it makes sense before approving. + +Also, sandboxing restricts **only** what the `terminal` and `fetch` tools in the +Zed agent can do. It has **no effect** on other parts of Zed, including: + +- Language servers +- The built-in git client +- The regular terminal +- And more... + +Even when sandboxing is enabled, you should remain vigilant. A malicious or +unaligned agent may use these side channels to escalate privileges. For example: + +- An agent may add a malicious Rust procedural macro to your codebase, which + will be automatically executed by `rust-analyzer` **outside the sandbox**. +- An agent may modify a `Makefile` to inject a malicious script, which is + executed **outside the sandbox** when you next run `make` in the built-in + terminal. +- The agent cannot write to your repository's protected `.git` directory, but it + can create a submodule under your project whose Git metadata (including config + such as `core.fsmonitor`) it fully controls. That metadata may then be executed + **outside the sandbox** when you subsequently run Git commands in a regular + terminal. Your shell prompt may even execute Git commands every time it + renders! + +There are steps you can take to mitigate these issues. For example: + +- disable language servers that execute user-defined code from the project (such + as Rust procedural macros). +- use a shell prompt that reports Git status without executing + repository-defined programs. +- review the diff before running `git commit` + +But none of this changes the fundamental principle: **a sandbox is not a +substitute for good security practices**. It is one layer in a defense-in-depth +strategy. + +Zed's default profile aims to strike a balance between security and convenience, +but we encourage you to tune your settings based on your own security +requirements and risk profile. A disabled sandbox is not a very effective +sandbox. ## Approval Prompts {#approval-prompts} @@ -88,7 +167,7 @@ The available options are: | `allow_all_hosts` | Allow sandboxed tools to reach any host without prompting. | | `write_paths` | Directory subtrees that sandboxed terminal commands may write to without prompting. Paths are absolute. | | `allow_fs_write_all` | Allow sandboxed terminal commands to write anywhere except protected Git metadata without prompting. | -| `allow_unsandboxed` | Allow terminal commands to run outside the sandbox without prompting when the agent explicitly requests it. | +| `allow_unsandboxed` | Turn sandboxing off entirely for Zed Agent terminal commands. The fetch tool will have no restrictions. | Prefer narrow grants, such as a specific host or write path, over `allow_all_hosts`, `allow_fs_write_all`, or `allow_unsandboxed`. @@ -99,10 +178,6 @@ Git metadata writes are not grantable while a terminal command is sandboxed. Thi linked worktree metadata, refs, the index, hooks, local Git config, and other Git-controlled metadata files. Approving a specific writable path or `allow_fs_write_all` does not make Git metadata writable. -When a Git command genuinely needs to update Git metadata, such as `git commit`, `git fetch`, `git checkout`, or `git -rebase`, approve unsandboxed execution instead. For read-only operations, prefer Git flags that avoid optional metadata -writes when possible. For example, use `git --no-optional-locks status` instead of `git status`. - ## Platform Support {#platform-support} Sandboxing uses different operating system mechanisms on each platform. The user-facing prompts are similar, but the @@ -121,6 +196,7 @@ Sandboxed terminal commands: - cannot write protected Git metadata, even if you approve broader write access - cannot write elsewhere unless you approve additional paths or broader write access - cannot reach the network unless you approve network access +- can reach only an allowlist of macOS system (Mach) services that developer tooling needs; services that could be abused to escape the sandbox (LaunchServices and launchd, which can launch processes outside it), read the clipboard (the pasteboard), or capture audio are not reachable When network access is approved on macOS, Zed uses an HTTP/HTTPS proxy so access can be limited to approved hosts. Tools that do not honor proxy environment variables, such as SSH, FTP, and raw socket clients, may not work even after host-specific network access is approved. @@ -128,7 +204,7 @@ For networked terminal commands, prefer HTTPS URLs over SSH URLs when possible. ### Linux {#linux} -On Linux, Zed uses Bubblewrap (`bwrap`) for sandboxing. +On Linux, Zed uses [Bubblewrap][bubblewrap] (`bwrap`) for sandboxing. Zed only uses a non-setuid `bwrap` binary. Its sandbox is built entirely on unprivileged user namespaces, so a setuid-root `bwrap` provides no extra functionality, and running one would mean executing root-privileged setup with arguments partly @@ -139,7 +215,7 @@ Sandboxed terminal commands: - can read the filesystem, including protected Git metadata contents - can write inside open project directories, except protected Git metadata -- can write to `/tmp`, which is backed by a fresh temporary filesystem and is cleared between terminal tool calls +- can write to `/tmp`, which is backed by a fresh temporary filesystem and is cleared between terminal tool calls (when you approve unrestricted filesystem writes, `/tmp` is instead your real host `/tmp` rather than a fresh temporary filesystem) - cannot write protected Git metadata - cannot write elsewhere unless you approve additional paths or broader write access - cannot reach the network unless you approve network access @@ -151,6 +227,54 @@ after host-specific network access is approved. If Bubblewrap is unavailable or cannot create a sandbox in the current environment, Zed may run the command without the OS sandbox and show a warning in the tool output. +#### Installing Bubblewrap {#installing-bubblewrap} + +Zed needs a runnable, non-setuid `bwrap` binary on your `$PATH`. Installing +`bubblewrap` from your distribution's package manager is usually all you need. + +You can test whether it's working with: + +```sh +bwrap --ro-bind / / -- echo "working" +``` + +"Non-setuid" here refers to the [setuid bit][setuid bit]. Historically, +bubblewrap has shipped both a setuid and non-setuid binary. The setuid binary is +being phased out for security concerns, and so Zed's sandbox _explicitly rejects +setuid `bwrap` binaries_. + +##### Ubuntu-specific requirements {#installing-bubblewrap-ubuntu} + +> **Note:** The following does not affect Ubuntu on WSL. + +Bubblewrap relies on a Linux kernel feature known as "namespaces". Unprivileged +users on many systems can create namespaces, but historically, this feature has +been used for a variety of attacks. + +In response to this, in Ubuntu 23.10, Canonical [added a security +measure][ubuntu blog] that restricts unprivileged user namespaces. These +restrictions are enforced by AppArmor. + +Because of this, you may also need to install an AppArmor profile for bubblewrap +after you install it. This is a configuration file that gives bubblewrap the +ability to create namespaces without needing `sudo`. + +```sh +sudo apt install bubblewrap + +# On Ubuntu 25.04 and later, `apparmor` ships with a profile for bubblewrap by default. +# Make sure you're up-to-date +sudo apt install --only-upgrade apparmor + +# On older versions, manually install the profile +sudo apt update +sudo apt install apparmor-profiles apparmor-utils +sudo install -m 0644 \ + /usr/share/apparmor/extra-profiles/bwrap-userns-restrict \ + /etc/apparmor.d/bwrap-userns-restrict +sudo apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict +``` + ### Windows {#windows} On Windows, Zed Agent sandboxing is supported only when the agent action runs inside WSL. @@ -167,8 +291,8 @@ When running inside WSL, the Linux sandboxing behavior applies, including the re - network access is all-or-nothing rather than host-specific, so host-specific network requests are rejected and the agent must request unrestricted network access when network access is needed If WSL is not installed, or if you choose to run a command without the sandbox, Zed falls back to the standard terminal -behavior of running in your native shell. It selects the shell using the usual preference order: Git Bash (or scoop's -bash) when one is installed, otherwise PowerShell, and finally `cmd.exe`. Because the command then runs against native +behavior of running in your native shell. It selects the shell using the usual preference order: a bash (scoop's bash or +Git Bash) when one is installed, otherwise PowerShell, and finally `cmd.exe`. Because the command then runs against native Windows paths instead of WSL's Linux filesystem, path conventions change accordingly (for example `C:\...` or `/c/...` rather than WSL's `/mnt/c/...`), so a command written for the sandboxed WSL shell may behave differently. @@ -184,3 +308,7 @@ When reviewing a sandbox prompt, prefer the narrowest permission that lets the t If a command fails because the sandbox blocked access, ask the agent why it needs that access before approving a broader request. + +[bubblewrap]: https://github.com/containers/bubblewrap +[setuid bit]: https://en.wikipedia.org/wiki/Setuid +[ubuntu blog]: https://ubuntu.com/blog/ubuntu-23-10-restricted-unprivileged-user-namespaces diff --git a/docs/src/ai/terminal-threads.md b/docs/src/ai/terminal-threads.md index dd4d454b706..a44759a2385 100644 --- a/docs/src/ai/terminal-threads.md +++ b/docs/src/ai/terminal-threads.md @@ -116,6 +116,8 @@ Create `.opencode/plugins/zed-bell.js` in your project, or `~/.config/opencode/p export const ZedBell = async () => { return { event: async ({ event }) => { + if (process.env.OPENCODE_CLIENT === "acp") return; + if (event.type === "session.idle" || event.type === "permission.asked") { process.stdout.write("\x07"); } diff --git a/docs/src/business/privacy.md b/docs/src/business/privacy.md index 5b2e7e6e2e9..aeaaf3aa088 100644 --- a/docs/src/business/privacy.md +++ b/docs/src/business/privacy.md @@ -36,7 +36,7 @@ Neither option is available to Zed Business members. ## What data still leaves the organization -These controls cover what Zed stores and trains on. They don't change how AI inference works: when members use Zed's hosted models, prompts and code context are still sent to the relevant provider (Anthropic, OpenAI, Google, etc.) to generate responses. Zed maintains no-training commitments with these providers, and zero-data-retention commitments for all models except [provider-designated models with safety retention](../ai/privacy-and-security.md#provider-safety-retention), such as Anthropic's Mythos-class models. See [AI Privacy](../ai/privacy-and-security.md#data-retention-and-training) for details. +These controls cover what Zed stores and trains on. They don't change how AI inference works: when members use Zed's hosted models, prompts and code context are still sent to the relevant provider (Anthropic, OpenAI, Google, etc.) to generate responses. Zed maintains no-training commitments with these providers, and zero-data-retention commitments for all models except [provider-designated models with safety retention](../ai/privacy-and-security.md#provider-safety-retention), such as Anthropic's Covered Models. See [AI Privacy](../ai/privacy-and-security.md#data-retention-and-training) for details. [Bring-your-own-key](../ai/llm-providers.md), [gateways](../ai/use-a-gateway.md), [local or self-hosted models](../ai/use-a-local-model.md), [External Agents](../ai/external-agents.md), and [Terminal Threads](../ai/terminal-threads.md) are subject to each provider, gateway, server, agent, or CLI's own terms. diff --git a/docs/src/languages/ruby.md b/docs/src/languages/ruby.md index 475c7e26cd0..585eee0b6e2 100644 --- a/docs/src/languages/ruby.md +++ b/docs/src/languages/ruby.md @@ -15,6 +15,8 @@ Ruby support is available through the [Ruby extension](https://github.com/zed-ex - [solargraph](https://github.com/castwide/solargraph) - [rubocop](https://github.com/rubocop/rubocop) - [Herb](https://herb-tools.dev) + - [kanayago](https://github.com/S-H-GAMELINKS/kanayago) + - [fuzzy-ruby-server](https://github.com/doompling/fuzzy_ruby_server) - Debug Adapter: [`rdbg`](https://github.com/ruby/debug) The Ruby extension also provides support for ERB files. @@ -34,6 +36,8 @@ In addition to these two language servers, Zed also supports: - [sorbet](https://sorbet.org/) which is a static type checker for Ruby with a custom gradual type system. - [steep](https://github.com/soutaro/steep) which is a static type checker for Ruby that uses Ruby Signature (RBS). - [Herb](https://herb-tools.dev) which is a language server for ERB files. +- [kanayago](https://github.com/S-H-GAMELINKS/kanayago) which is a Ruby language server that makes Ruby's parser available as a gem. +- [fuzzy-ruby-server](https://github.com/doompling/fuzzy_ruby_server) which is a Ruby language server designed for large codebases, using full-text search for fast, fuzzy search results that approximate Ruby's behavior. When configuring a language server, it helps to open the LSP Logs window using the 'dev: Open Language Server Logs' command. You can then choose the corresponding language instance to see any logged information. @@ -43,7 +47,7 @@ The [Ruby extension](https://github.com/zed-extensions/ruby) offers both `solarg ### Language Server Activation -For all supported Ruby language servers (`solargraph`, `ruby-lsp`, `rubocop`, `sorbet`, and `steep`), the Ruby extension follows this activation sequence: +For all supported Ruby language servers (`solargraph`, `ruby-lsp`, `rubocop`, `sorbet` and `steep`), the Ruby extension follows this activation sequence: 1. If the language server is found in your project's `Gemfile`, it will be used through `bundle exec`. 2. If not found in the `Gemfile`, the Ruby extension will look for the executable in your system `PATH`. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 1450b58d6fc..2ded300ce5d 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -1905,7 +1905,7 @@ While other options may be changed at a runtime and should be placed under `sett } ``` -## Format On Save +## Format On Save {#format-on-save} - Description: Whether or not to perform a buffer format before saving. - Setting: `format_on_save` @@ -1929,6 +1929,26 @@ While other options may be changed at a runtime and should be placed under `sett } ``` +3. `modifications`, formats only lines with unstaged changes: + +```json [settings] +{ + "format_on_save": "modifications" +} +``` + +This mode requires source control and LSP range formatting support. If no git diff is available or if the LSP doesn't support range formatting, formatting is skipped. This is useful for editing legacy codebases where you want to avoid formatting changes in unrelated code. + +4. `modifications_if_available`, formats only modified lines with fallback to full file formatting: + +```json [settings] +{ + "format_on_save": "modifications_if_available" +} +``` + +Similar to `modifications`, but behaves like `on` when range formatting cannot be applied: when no git diff is available (e.g., when source control is unavailable) or when the language server does not support range formatting. When a git diff is available but contains no unstaged changes, nothing is formatted. + ## Formatter - Description: How to perform a buffer format. @@ -3279,7 +3299,7 @@ Examples: - Description: Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \ - There are several ways to convert a preview tab into a regular tab: + There are several ways to convert a preview tab into a regular tab: - Double-clicking on the file - Double-clicking on the tab header diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index b58469d12e4..b3f9131ca9a 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -571,7 +571,7 @@ See [Terminal settings](./reference/all-settings.md#terminal) for additional non "default_width": 360, // Default width of the git panel. "status_style": "icon", // label_color, icon "sort_by": "path", // path, name - "group_by": "status", // none, status + "group_by": "status", // none, status, staging "scrollbar": { "show": null // Show/hide: (auto, system, always, never) } diff --git a/docs/theme/css/chrome.css b/docs/theme/css/chrome.css index 8f5b40cc19e..f0169fd7c87 100644 --- a/docs/theme/css/chrome.css +++ b/docs/theme/css/chrome.css @@ -434,6 +434,7 @@ ul#searchresults span.teaser em { .sidebar { position: relative; + order: 0; width: var(--sidebar-width); flex-shrink: 0; display: flex; diff --git a/docs/theme/css/general.css b/docs/theme/css/general.css index 9c8077bad52..d32edaa8d61 100644 --- a/docs/theme/css/general.css +++ b/docs/theme/css/general.css @@ -201,6 +201,7 @@ hr { .page { outline: 0; + order: 1; flex: 1; display: flex; flex-direction: column; diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs index 2c7786817aa..d5e0b620051 100644 --- a/docs/theme/index.hbs +++ b/docs/theme/index.hbs @@ -27,6 +27,7 @@ })(); {{ title }} + {{#if is_print }} {{/if}} @@ -86,6 +87,9 @@ document.body.classList.remove('no-js'); document.body.classList.add('js'); +
+ Agent documentation index: llms.txt. Markdown versions are available for docs pages. +
@@ -155,6 +159,95 @@
{{/if}} +
+
+
+ {{{ content }}} + + +
+
+ +
+ + +
+
+