diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 6b2fa66147b656..60584cf43467ef 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -25,7 +25,8 @@ jobs: clean: false ref: ${{ inputs.branch }} token: ${{ steps.generate-token.outputs.token }} - - name: bump_patch_version::run_bump_patch_version::bump_patch_version + - id: bump-version + name: bump_patch_version::run_bump_patch_version::bump_version run: | channel="$(cat crates/zed/RELEASE_CHANNEL)" @@ -42,16 +43,28 @@ jobs: ;; esac which cargo-set-version > /dev/null || cargo install cargo-edit -f --no-default-features --features "set-version" - output="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')" - git commit -am "Bump to $output for @$GITHUB_ACTOR" - git tag "v${output}${tag_suffix}" - git push origin HEAD "v${output}${tag_suffix}" - env: - GIT_COMMITTER_NAME: Zed Zippy - GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com - GIT_AUTHOR_NAME: Zed Zippy - GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com - GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + version="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag_suffix=$tag_suffix" >> "$GITHUB_OUTPUT" + - id: commit + name: bump_patch_version::run_bump_patch_version::commit_changes + uses: IAreKyleW00t/verified-bot-commit@126a6a11889ab05bcff72ec2403c326cd249b84c + with: + message: Bump to ${{ steps.bump-version.outputs.version }} for @${{ github.actor }} + ref: refs/heads/${{ inputs.branch }} + files: '**' + token: ${{ steps.generate-token.outputs.token }} + - name: bump_patch_version::run_bump_patch_version::create_version_tag + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + with: + script: | + github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'refs/tags/v${{ steps.bump-version.outputs.version }}${{ steps.bump-version.outputs.tag_suffix }}', + sha: '${{ steps.commit.outputs.commit }}' + }) + github-token: ${{ steps.generate-token.outputs.token }} concurrency: group: ${{ github.workflow }}-${{ inputs.branch }} cancel-in-progress: true diff --git a/.github/workflows/compliance_check.yml b/.github/workflows/compliance_check.yml index 144185f95ba95b..f662dfd7073fbe 100644 --- a/.github/workflows/compliance_check.yml +++ b/.github/workflows/compliance_check.yml @@ -36,7 +36,7 @@ jobs: - id: run-compliance-check name: release::add_compliance_steps::run_compliance_check run: | - cargo xtask compliance "$LATEST_TAG" --branch main --report-path "compliance-report-${GITHUB_REF_NAME}.md" + cargo xtask compliance version "$LATEST_TAG" --branch main --report-path "compliance-report-${GITHUB_REF_NAME}.md" env: GITHUB_APP_ID: ${{ secrets.ZED_ZIPPY_APP_ID }} GITHUB_APP_KEY: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} diff --git a/.github/workflows/pr_labeler.yml b/.github/workflows/pr_labeler.yml index 2f09ad681698d0..9ea703854329fc 100644 --- a/.github/workflows/pr_labeler.yml +++ b/.github/workflows/pr_labeler.yml @@ -37,6 +37,7 @@ jobs: '11happy', 'AidanV', 'AmaanBilwar', + 'MostlyKIGuess', 'OmChillure', 'Palanikannan1437', 'Shivansh-25', @@ -48,7 +49,6 @@ jobs: 'arjunkomath', 'austincummings', 'ayushk-1801', - 'claiwe', 'criticic', 'dongdong867', 'emamulandalib', @@ -61,7 +61,6 @@ jobs: 'loadingalias', 'marcocondrache', 'mchisolm0', - 'mostlyKIGuess', 'nairadithya', 'nihalxkumar', 'notJoon', @@ -72,6 +71,7 @@ jobs: 'seanstrom', 'th0jensen', 'tommyming', + 'transitoryangel', 'virajbhartiya', ]; @@ -114,7 +114,11 @@ jobs: return; } - if (GUILD_MEMBERS.includes(author)) { + const authorLower = author.toLowerCase(); + const isGuildMember = GUILD_MEMBERS.some( + (member) => member.toLowerCase() === authorLower + ); + if (isGuildMember) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8081955920823e..17e121b9958db6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -309,7 +309,7 @@ jobs: - id: run-compliance-check name: release::add_compliance_steps::run_compliance_check run: | - cargo xtask compliance "$GITHUB_REF_NAME" --report-path "compliance-report-${GITHUB_REF_NAME}.md" + cargo xtask compliance version "$GITHUB_REF_NAME" --report-path "compliance-report-${GITHUB_REF_NAME}.md" env: GITHUB_APP_ID: ${{ secrets.ZED_ZIPPY_APP_ID }} GITHUB_APP_KEY: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} @@ -678,7 +678,7 @@ jobs: - id: run-compliance-check name: release::add_compliance_steps::run_compliance_check run: | - cargo xtask compliance "$GITHUB_REF_NAME" --report-path "compliance-report-${GITHUB_REF_NAME}.md" + cargo xtask compliance version "$GITHUB_REF_NAME" --report-path "compliance-report-${GITHUB_REF_NAME}.md" env: GITHUB_APP_ID: ${{ secrets.ZED_ZIPPY_APP_ID }} GITHUB_APP_KEY: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} diff --git a/.zed/settings.json b/.zed/settings.json index 2ecbd5623d26bd..eec687955d9b40 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -49,7 +49,7 @@ }, "file_types": { "Dockerfile": ["Dockerfile*[!dockerignore]"], - "JSONC": ["**/assets/**/*.json", "renovate.json"], + "JSONC": ["**/assets/**/*.json"], "Git Ignore": ["dockerignore"], }, "hard_tabs": false, diff --git a/Cargo.lock b/Cargo.lock index 72f7970822f0c2..d9cd9ce518014a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,11 +55,13 @@ dependencies = [ "collections", "gpui", "language", + "log", "markdown", "project", "serde", "serde_json", "settings", + "smol", "theme_settings", "ui", "util", @@ -190,7 +192,7 @@ dependencies = [ "regex", "reqwest_client", "rust-embed", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -220,33 +222,53 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.10.2" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c56a59cf6315e99f874d2c1f96c69d2da5ffe0087d211297fc4a41f849770a2" +checksum = "2af62fb84df2af0f933d8f5fd78b843fa5eb0ec5a48fa1b528c41951d0bbe36c" dependencies = [ + "agent-client-protocol-derive", "agent-client-protocol-schema", "anyhow", - "async-broadcast", - "async-trait", - "derive_more", "futures 0.3.32", - "log", + "futures-concurrency", + "jsonrpcmsg", + "rmcp", + "rustc-hash 2.1.1", + "schemars 1.0.4", "serde", "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce42c2d3c048c12897eef2e577dfff1e3355c632c9f1625cc953b9df48b44631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "agent-client-protocol-schema" -version = "0.11.2" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0497b9a95a404e35799904835c57c6f8c69b9d08ccfd3cb5b7d746425cd6789" +checksum = "49bae57dad1c28a362fbdcf7bab0583316a02b45a70792109fced55780a3b63c" dependencies = [ "anyhow", "derive_more", - "schemars", + "schemars 1.0.4", "serde", "serde_json", + "serde_with", "strum 0.28.0", + "tracing", ] [[package]] @@ -258,8 +280,6 @@ dependencies = [ "action_log", "agent-client-protocol", "anyhow", - "async-pipe", - "async-trait", "chrono", "client", "collections", @@ -311,7 +331,7 @@ dependencies = [ "paths", "project", "regex", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -353,6 +373,7 @@ dependencies = [ "futures 0.3.32", "fuzzy", "git", + "git_ui", "gpui", "gpui_tokio", "heapless", @@ -390,7 +411,7 @@ dependencies = [ "reqwest_client", "rope", "rules_library", - "schemars", + "schemars 1.0.4", "search", "semver", "serde", @@ -408,7 +429,6 @@ dependencies = [ "theme", "theme_settings", "time", - "time_format", "tree-sitter-md", "ui", "ui_input", @@ -656,12 +676,11 @@ dependencies = [ "http_client", "language_model_core", "log", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "strum 0.27.2", "thiserror 2.0.17", - "tiktoken-rs", ] [[package]] @@ -672,9 +691,9 @@ checksum = "34cd60c5e3152cef0a592f1b296f1cc93715d89d2551d85315828c3a09575ff4" [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "approx" @@ -1305,7 +1324,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.17", "v_frame", @@ -1933,7 +1952,7 @@ dependencies = [ "aws-sdk-bedrockruntime", "aws-smithy-types", "futures 0.3.32", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "strum 0.27.2", @@ -1972,7 +1991,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.11.0", "log", "prettyplease", "proc-macro2", @@ -1992,7 +2011,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", "regex", @@ -2145,7 +2164,7 @@ version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -2209,9 +2228,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "regex-automata", @@ -2673,7 +2692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" dependencies = [ "heck 0.4.1", - "indexmap", + "indexmap 2.11.4", "log", "proc-macro2", "quote", @@ -3293,7 +3312,7 @@ dependencies = [ name = "collections" version = "0.1.0" dependencies = [ - "indexmap", + "indexmap 2.11.4", "rustc-hash 2.1.1", ] @@ -3339,7 +3358,7 @@ dependencies = [ "command_palette_hooks", "db", "editor", - "fuzzy", + "fuzzy_nucleo", "go_to_line", "gpui", "language", @@ -3549,7 +3568,7 @@ dependencies = [ "parking_lot", "postage", "rand 0.9.3", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -3557,7 +3576,6 @@ dependencies = [ "slotmap", "smol", "tempfile", - "terminal", "tiny_http", "url", "util", @@ -4379,7 +4397,7 @@ checksum = "d74b6bcf49ebbd91f1b1875b706ea46545032a14003b5557b7dfa4bbeba6766e" dependencies = [ "cc", "codespan-reporting", - "indexmap", + "indexmap 2.11.4", "proc-macro2", "quote", "scratch", @@ -4394,7 +4412,7 @@ checksum = "94ca2ad69673c4b35585edfa379617ac364bccd0ba0adf319811ba3a74ffa48a" dependencies = [ "clap", "codespan-reporting", - "indexmap", + "indexmap 2.11.4", "proc-macro2", "quote", "syn 2.0.117", @@ -4412,7 +4430,7 @@ version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8ebf0b6138325af3ec73324cb3a48b64d57721f17291b151206782e61f66cd" dependencies = [ - "indexmap", + "indexmap 2.11.4", "proc-macro2", "quote", "syn 2.0.117", @@ -4441,7 +4459,7 @@ dependencies = [ "parking_lot", "paths", "proto", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -4458,7 +4476,7 @@ name = "dap-types" version = "0.0.1" source = "git+https://github.com/zed-industries/dap-types?rev=1b461b310481d01e02b2603c16d7144b926339f8#1b461b310481d01e02b2603c16d7144b926339f8" dependencies = [ - "schemars", + "schemars 1.0.4", "serde", "serde_json", ] @@ -4509,6 +4527,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -4537,6 +4565,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -4559,6 +4600,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -4688,7 +4740,7 @@ dependencies = [ "pretty_assertions", "project", "rpc", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -4728,7 +4780,7 @@ dependencies = [ "anyhow", "futures 0.3.32", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", ] @@ -4842,6 +4894,7 @@ dependencies = [ name = "dev_container" version = "0.1.0" dependencies = [ + "anyhow", "async-tar", "async-trait", "env_logger 0.11.8", @@ -4866,6 +4919,7 @@ dependencies = [ "walkdir", "workspace", "worktree", + "yaml-rust2", ] [[package]] @@ -5007,7 +5061,7 @@ dependencies = [ "jsonschema", "mdbook", "regex", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -5337,6 +5391,7 @@ dependencies = [ "language", "pretty_assertions", "serde", + "serde_json", "similar", "tree-sitter", "zeta_prompt", @@ -5442,7 +5497,7 @@ dependencies = [ "release_channel", "rope", "rpc", - "schemars", + "schemars 1.0.4", "semver", "serde", "serde_json", @@ -6186,7 +6241,7 @@ dependencies = [ "fs", "gpui", "inventory", - "schemars", + "schemars 1.0.4", "serde_json", "settings", ] @@ -7139,7 +7194,7 @@ dependencies = [ "derive_more", "derive_setters", "gh-workflow-macros", - "indexmap", + "indexmap 2.11.4", "merge", "serde", "serde_json", @@ -7184,7 +7239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" dependencies = [ "fallible-iterator", - "indexmap", + "indexmap 2.11.4", "stable_deref_trait", ] @@ -7221,7 +7276,7 @@ dependencies = [ "rand 0.9.3", "regex", "rope", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "smallvec", @@ -7318,8 +7373,10 @@ dependencies = [ "db", "editor", "file_icons", + "fs", "futures 0.3.32", "fuzzy", + "fuzzy_nucleo", "git", "gpui", "indoc", @@ -7341,7 +7398,7 @@ dependencies = [ "rand 0.9.3", "remote", "remote_connection", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -7527,11 +7584,10 @@ dependencies = [ "http_client", "language_model_core", "log", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "strum 0.27.2", - "tiktoken-rs", ] [[package]] @@ -7633,7 +7689,7 @@ dependencies = [ "reqwest_client", "resvg", "scheduler", - "schemars", + "schemars 1.0.4", "seahash", "serde", "serde_json", @@ -7778,7 +7834,7 @@ version = "0.1.0" dependencies = [ "derive_more", "gpui_util", - "schemars", + "schemars 1.0.4", "serde", ] @@ -7933,7 +7989,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -7952,7 +8008,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -8779,6 +8835,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -8786,7 +8853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -9189,7 +9256,7 @@ dependencies = [ "parking_lot", "paths", "project", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -9199,6 +9266,16 @@ dependencies = [ "util", ] +[[package]] +name = "jsonrpcmsg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "jsonschema" version = "0.37.4" @@ -9262,13 +9339,14 @@ dependencies = [ [[package]] name = "jupyter-websocket-client" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ef5a543b517583059b5b11daceb37690d6ac206f9321075993cd82ab1541c28" +checksum = "7c2ae4d8d5344f69bf9734b264e969c2139e30e932ce9b6455de9f65663ed614" dependencies = [ "anyhow", "async-trait", "async-tungstenite", + "bytes 1.11.1", "futures 0.3.32", "jupyter-protocol", "serde", @@ -9450,7 +9528,7 @@ dependencies = [ "lsp", "parking_lot", "regex", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "toml 0.8.23", @@ -9513,7 +9591,7 @@ dependencies = [ "gpui_shared_string", "http_client", "partial-json-fixer", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "smol", @@ -9565,13 +9643,12 @@ dependencies = [ "opencode", "pretty_assertions", "release_channel", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", "smol", "strum 0.27.2", - "tiktoken-rs", "tokio", "ui", "ui_input", @@ -9593,13 +9670,12 @@ dependencies = [ "http_client", "language_model", "open_ai", - "schemars", + "schemars 1.0.4", "semver", "serde", "serde_json", "smol", "thiserror 2.0.17", - "x_ai", ] [[package]] @@ -10113,7 +10189,7 @@ dependencies = [ "anyhow", "futures 0.3.32", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", ] @@ -10190,7 +10266,7 @@ dependencies = [ "parking_lot", "postage", "release_channel", - "schemars", + "schemars 1.0.4", "semver", "serde", "serde_json", @@ -10772,7 +10848,7 @@ dependencies = [ "anyhow", "futures 0.3.32", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "strum 0.27.2", @@ -10869,7 +10945,7 @@ dependencies = [ "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap", + "indexmap 2.11.4", "libm", "log", "num-traits", @@ -10893,7 +10969,7 @@ dependencies = [ "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap", + "indexmap 2.11.4", "libm", "log", "num-traits", @@ -11591,7 +11667,7 @@ checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "crc32fast", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.11.4", "memchr", ] @@ -11644,7 +11720,7 @@ dependencies = [ "anyhow", "futures 0.3.32", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -11668,7 +11744,7 @@ dependencies = [ "notifications", "picker", "project", - "schemars", + "schemars 1.0.4", "serde", "settings", "telemetry", @@ -11758,12 +11834,11 @@ dependencies = [ "log", "pretty_assertions", "rand 0.9.3", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "strum 0.27.2", "thiserror 2.0.17", - "tiktoken-rs", ] [[package]] @@ -11777,7 +11852,7 @@ dependencies = [ "gpui", "picker", "project", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -11796,7 +11871,7 @@ dependencies = [ "futures 0.3.32", "http_client", "language_model_core", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -11812,7 +11887,7 @@ dependencies = [ "futures 0.3.32", "google_ai", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "strum 0.27.2", @@ -12129,6 +12204,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -12749,7 +12830,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap", + "indexmap 2.11.4", ] [[package]] @@ -12863,7 +12944,7 @@ dependencies = [ "editor", "gpui", "menu", - "schemars", + "schemars 1.0.4", "serde", "settings", "theme", @@ -12989,7 +13070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap", + "indexmap 2.11.4", "quick-xml 0.38.3", "serde", "time", @@ -13371,7 +13452,7 @@ dependencies = [ "gpui", "http_client", "image", - "indexmap", + "indexmap 2.11.4", "itertools 0.14.0", "language", "log", @@ -13390,7 +13471,7 @@ dependencies = [ "release_channel", "remote", "rpc", - "schemars", + "schemars 1.0.4", "semver", "serde", "serde_json", @@ -13469,7 +13550,7 @@ dependencies = [ "project", "rayon", "remote_connection", - "schemars", + "schemars 1.0.4", "search", "serde", "serde_json", @@ -13640,8 +13721,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes 1.11.1", - "heck 0.4.1", - "itertools 0.10.5", + "heck 0.5.0", + "itertools 0.11.0", "log", "multimap", "once_cell", @@ -13674,7 +13755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", "syn 2.0.117", @@ -14290,7 +14371,7 @@ dependencies = [ "extension_host", "fs", "futures 0.3.32", - "fuzzy", + "fuzzy_nucleo", "gpui", "http_client", "indoc", @@ -14411,9 +14492,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -14470,7 +14551,7 @@ dependencies = [ "prost 0.9.0", "release_channel", "rpc", - "schemars", + "schemars 1.0.4", "semver", "serde", "serde_json", @@ -14826,6 +14907,41 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmcp" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures 0.3.32", + "pastey 0.2.1", + "pin-project-lite", + "rmcp-macros", + "schemars 1.0.4", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "rmp" version = "0.8.14" @@ -15387,7 +15503,7 @@ dependencies = [ "anyhow", "clap", "env_logger 0.11.8", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -15395,14 +15511,27 @@ dependencies = [ "theme_settings", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ + "chrono", "dyn-clone", - "indexmap", + "indexmap 2.11.4", "ref-cast", "schemars_derive", "serde", @@ -15774,7 +15903,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap", + "indexmap 2.11.4", "itoa", "memchr", "ryu", @@ -15788,7 +15917,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" dependencies = [ - "indexmap", + "indexmap 2.11.4", "itoa", "memchr", "ryu", @@ -15847,13 +15976,44 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.0.4", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.11.4", "itoa", "ryu", "serde", @@ -15900,7 +16060,7 @@ dependencies = [ "pretty_assertions", "release_channel", "rust-embed", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -15923,7 +16083,7 @@ dependencies = [ "gpui", "language_model_core", "log", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -16011,7 +16171,7 @@ dependencies = [ "regex", "release_channel", "rodio", - "schemars", + "schemars 1.0.4", "search", "serde", "serde_json", @@ -16167,7 +16327,6 @@ dependencies = [ "theme_settings", "ui", "util", - "vim_mode_setting", "workspace", "zed_actions", ] @@ -16375,7 +16534,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -16402,7 +16561,7 @@ dependencies = [ "indoc", "parking_lot", "paths", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -16573,7 +16732,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap", + "indexmap 2.11.4", "log", "memchr", "once_cell", @@ -17426,16 +17585,15 @@ dependencies = [ "collections", "ctor", "editor", - "fuzzy", + "fuzzy_nucleo", "gpui", "menu", "picker", "project", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", - "smol", "theme", "theme_settings", "ui", @@ -17505,7 +17663,7 @@ dependencies = [ "parking_lot", "pretty_assertions", "proto", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -17609,7 +17767,7 @@ dependencies = [ "rand 0.9.3", "regex", "release_channel", - "schemars", + "schemars 1.0.4", "serde", "settings", "smol", @@ -17655,7 +17813,11 @@ dependencies = [ "pretty_assertions", "project", "regex", - "schemars", + "release_channel", + "remote", + "rpc", + "schemars 1.0.4", + "semver", "serde", "serde_json", "settings", @@ -17701,7 +17863,7 @@ dependencies = [ "palette", "parking_lot", "refineable", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -17731,7 +17893,7 @@ dependencies = [ "clap", "collections", "gpui", - "indexmap", + "indexmap 2.11.4", "log", "palette", "serde", @@ -17778,7 +17940,7 @@ dependencies = [ "log", "palette", "refineable", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -17850,20 +18012,6 @@ dependencies = [ "zune-jpeg 0.5.15", ] -[[package]] -name = "tiktoken-rs" -version = "0.9.1" -source = "git+https://github.com/zed-industries/tiktoken-rs?rev=2570c4387a8505fb8f1d3f3557454b474f1e8271#2570c4387a8505fb8f1d3f3557454b474f1e8271" -dependencies = [ - "anyhow", - "base64 0.22.1", - "bstr", - "fancy-regex 0.16.2", - "lazy_static", - "regex", - "rustc-hash 1.1.0", -] - [[package]] name = "time" version = "0.3.47" @@ -17995,6 +18143,7 @@ name = "title_bar" version = "0.1.0" dependencies = [ "anyhow", + "arrayvec", "auto_update", "call", "channel", @@ -18014,7 +18163,7 @@ dependencies = [ "remote", "remote_connection", "rpc", - "schemars", + "schemars 1.0.4", "semver", "serde", "settings", @@ -18202,7 +18351,7 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap", + "indexmap 2.11.4", "serde_core", "serde_spanned 1.0.3", "toml_datetime 0.7.3", @@ -18235,7 +18384,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.11.4", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -18249,7 +18398,7 @@ version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ - "indexmap", + "indexmap 2.11.4", "toml_datetime 0.7.3", "toml_parser", "winnow", @@ -18932,7 +19081,7 @@ dependencies = [ "icons", "itertools 0.14.0", "menu", - "schemars", + "schemars 1.0.4", "serde", "smallvec", "strum 0.27.2", @@ -19194,7 +19343,7 @@ dependencies = [ "rand 0.9.3", "regex", "rust-embed", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -19306,7 +19455,7 @@ name = "vercel" version = "0.1.0" dependencies = [ "anyhow", - "schemars", + "schemars 1.0.4", "serde", "strum 0.27.2", ] @@ -19357,7 +19506,7 @@ dependencies = [ "project_panel", "regex", "release_channel", - "schemars", + "schemars 1.0.4", "search", "semver", "serde", @@ -19641,7 +19790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fd83062c17b9f4985d438603cde0a5e8c5c8198201a6937f778b607924c7da2" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.11.4", "serde", "serde_derive", "serde_json", @@ -19659,7 +19808,7 @@ dependencies = [ "anyhow", "auditable-serde", "flate2", - "indexmap", + "indexmap 2.11.4", "serde", "serde_derive", "serde_json", @@ -19676,7 +19825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.11.4", "wasm-encoder 0.244.0", "wasmparser 0.244.0", ] @@ -19713,7 +19862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" dependencies = [ "bitflags 2.10.0", - "indexmap", + "indexmap 2.11.4", "semver", ] @@ -19725,7 +19874,7 @@ checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.11.4", "semver", "serde", ] @@ -19738,7 +19887,7 @@ checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.11.4", "semver", ] @@ -19750,7 +19899,7 @@ checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.11.4", "semver", "serde", ] @@ -19763,7 +19912,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.11.4", "semver", ] @@ -19793,7 +19942,7 @@ dependencies = [ "cfg-if", "encoding_rs", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.11.4", "libc", "log", "mach2 0.4.3", @@ -19850,7 +19999,7 @@ dependencies = [ "cranelift-bitset", "cranelift-entity", "gimli", - "indexmap", + "indexmap 2.11.4", "log", "object", "postcard", @@ -20036,7 +20185,7 @@ dependencies = [ "anyhow", "bitflags 2.10.0", "heck 0.5.0", - "indexmap", + "indexmap 2.11.4", "wit-parser 0.236.1", ] @@ -20373,7 +20522,7 @@ dependencies = [ "cfg_aliases 0.2.1", "document-features", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.11.4", "log", "naga 29.0.0 (git+https://github.com/zed-industries/wgpu.git?branch=v29)", "once_cell", @@ -21520,7 +21669,7 @@ checksum = "d8a39a15d1ae2077688213611209849cad40e9e5cccf6e61951a425850677ff3" dependencies = [ "anyhow", "heck 0.4.1", - "indexmap", + "indexmap 2.11.4", "wasm-metadata 0.201.0", "wit-bindgen-core 0.22.0", "wit-component 0.201.0", @@ -21534,7 +21683,7 @@ checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.11.4", "prettyplease", "syn 2.0.117", "wasm-metadata 0.227.1", @@ -21550,7 +21699,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.11.4", "prettyplease", "syn 2.0.117", "wasm-metadata 0.244.0", @@ -21610,7 +21759,7 @@ checksum = "421c0c848a0660a8c22e2fd217929a0191f14476b68962afd2af89fd22e39825" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap", + "indexmap 2.11.4", "log", "serde", "serde_derive", @@ -21629,7 +21778,7 @@ checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap", + "indexmap 2.11.4", "log", "serde", "serde_derive", @@ -21648,7 +21797,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap", + "indexmap 2.11.4", "log", "serde", "serde_derive", @@ -21667,7 +21816,7 @@ checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.11.4", "log", "semver", "serde", @@ -21685,7 +21834,7 @@ checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.11.4", "log", "semver", "serde", @@ -21703,7 +21852,7 @@ checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.11.4", "log", "semver", "serde", @@ -21721,7 +21870,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.11.4", "log", "semver", "serde", @@ -21772,9 +21921,8 @@ dependencies = [ "postage", "pretty_assertions", "project", - "release_channel", "remote", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "session", @@ -21790,7 +21938,6 @@ dependencies = [ "ui", "util", "uuid", - "vim_mode_setting", "windows 0.61.3", "zed_actions", "zlog", @@ -21915,11 +22062,9 @@ name = "x_ai" version = "0.1.0" dependencies = [ "anyhow", - "language_model_core", - "schemars", + "schemars 1.0.4", "serde", "strum 0.27.2", - "tiktoken-rs", ] [[package]] @@ -22024,7 +22169,7 @@ dependencies = [ "clap", "compliance", "gh-workflow", - "indexmap", + "indexmap 2.11.4", "indoc", "itertools 0.14.0", "regex", @@ -22217,7 +22362,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.234.0" +version = "0.235.0" dependencies = [ "acp_thread", "acp_tools", @@ -22500,7 +22645,7 @@ name = "zed_actions" version = "0.1.0" dependencies = [ "gpui", - "schemars", + "schemars 1.0.4", "serde", "util", "uuid", @@ -22751,7 +22896,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "displaydoc", - "indexmap", + "indexmap 2.11.4", "num_enum", "thiserror 1.0.69", ] diff --git a/Cargo.toml b/Cargo.toml index 514226cdc3ba1b..5d403c48c6fc97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -496,7 +496,7 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -agent-client-protocol = { version = "=0.10.2", features = ["unstable"] } +agent-client-protocol = { version = "=0.11.1", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } any_vec = "0.14" @@ -606,7 +606,7 @@ json_dotpath = "1.1" jsonschema = "0.37.0" jsonwebtoken = "10.0" jupyter-protocol = "1.4.0" -jupyter-websocket-client = "1.0.0" +jupyter-websocket-client = "1.1.0" libc = "0.2" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } linkify = "0.10.0" @@ -733,7 +733,6 @@ sysinfo = "0.37.0" take-until = "0.2.0" tempfile = "3.20.0" thiserror = "2.0.12" -tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "2570c4387a8505fb8f1d3f3557454b474f1e8271" } time = { version = "0.3", features = [ "macros", "parsing", @@ -807,6 +806,7 @@ web-time = "1.1.0" webrtc-sys = "0.3.23" wgpu = { git = "https://github.com/zed-industries/wgpu.git", branch = "v29" } windows-core = "0.61" +yaml-rust2 = "0.8" yawc = "0.2.5" zeroize = "1.8" zstd = "0.11" diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 97a84303b4f5d6..af1fb028436417 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -224,7 +224,6 @@ "context": "AgentPanel", "bindings": { "ctrl-n": "agent::NewThread", - "ctrl-shift-h": "agent::OpenHistory", "ctrl-alt-c": "agent::OpenSettings", "ctrl-alt-p": "agent::ManageProfiles", "ctrl-alt-l": "agent::OpenRulesLibrary", @@ -234,10 +233,8 @@ "alt-tab": "agent::CycleFavoriteModels", // `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Linux under the `AgentPanel` context "alt-l": "agent::CycleFavoriteModels", - "shift-alt-j": "agent::ToggleNavigationMenu", "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", - "ctrl-shift-t": "agent::ToggleWorktreeSelector", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl->": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", @@ -630,6 +627,7 @@ // "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]", "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "alt-ctrl-shift-b": "branches::OpenRecent", + "alt-ctrl-shift-w": "git::Worktree", "alt-shift-enter": "toast::RunAction", "ctrl-~": "workspace::NewTerminal", "save": "workspace::Save", @@ -727,7 +725,8 @@ "enter": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", "ctrl-g": "agents_sidebar::ToggleThreadHistory", - "shift-backspace": "agent::RemoveSelectedThread", + "shift-backspace": "agent::ArchiveSelectedThread", + "ctrl-backspace": "agent::RemoveSelectedThread", "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, @@ -1210,7 +1209,7 @@ "alt-b": ["terminal::SendText", "\u001bb"], "alt-f": ["terminal::SendText", "\u001bf"], "alt-.": ["terminal::SendText", "\u001b."], - "ctrl-delete": ["terminal::SendText", "\u001bd"], + "ctrl-delete": ["terminal::SendText", "\u001b[3;5~"], // Overrides for conflicting keybindings "ctrl-b": ["terminal::SendKeystroke", "ctrl-b"], "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"], @@ -1368,6 +1367,7 @@ "context": "Welcome", "use_key_equivalents": true, "bindings": { + "ctrl-n": "workspace::NewFile", "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], @@ -1386,15 +1386,6 @@ "ctrl-shift-enter": "workspace::OpenWithSystem", }, }, - { - "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", - "use_key_equivalents": true, - "bindings": { - "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault", - "ctrl-shift-backspace": "git::DeleteWorktree", - }, - }, { // Handled under a more specific context to avoid conflicts with the // `OpenCurrentFile` keybind from the settings UI @@ -1530,9 +1521,15 @@ { "context": "GitPicker", "bindings": { - "alt-1": "git_picker::ActivateWorktreesTab", - "alt-2": "git_picker::ActivateBranchesTab", - "alt-3": "git_picker::ActivateStashTab", + "alt-1": "git_picker::ActivateBranchesTab", + "alt-2": "git_picker::ActivateStashTab", + }, + }, + { + "context": "WorktreePicker || (WorktreePicker > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-backspace": "worktree_picker::DeleteWorktree", }, }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 38207365ef36df..7edfd53a73bbf9 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -171,7 +171,7 @@ "cmd-f": "buffer_search::Deploy", "cmd-alt-f": "buffer_search::DeployReplace", "cmd-alt-l": ["buffer_search::Deploy", { "selection_search_enabled": true }], - "cmd-e": ["buffer_search::Deploy", { "focus": false }], + "cmd-e": "buffer_search::UseSelectionForFind", "cmd->": "agent::AddSelectionToThread", "cmd-alt-e": "editor::SelectEnclosingSymbol", "alt-enter": "editor::OpenSelectionsInMultibuffer", @@ -264,7 +264,6 @@ "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewThread", - "cmd-shift-h": "agent::OpenHistory", "cmd-alt-c": "agent::OpenSettings", "cmd-alt-l": "agent::OpenRulesLibrary", "cmd-alt-p": "agent::ManageProfiles", @@ -272,10 +271,8 @@ "shift-tab": "agent::CycleModeSelector", "cmd-alt-/": "agent::ToggleModelSelector", "alt-tab": "agent::CycleFavoriteModels", - "cmd-shift-j": "agent::ToggleNavigationMenu", "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", - "cmd-shift-t": "agent::ToggleWorktreeSelector", "shift-alt-escape": "agent::ExpandMessageEditor", "cmd->": "agent::AddSelectionToThread", "cmd-shift-e": "project_panel::ToggleFocus", @@ -691,6 +688,7 @@ "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }], "cmd-ctrl-b": "branches::OpenRecent", + "cmd-ctrl-w": "git::Worktree", "ctrl-~": "workspace::NewTerminal", "cmd-s": "workspace::Save", "cmd-k s": "workspace::SaveWithoutFormat", @@ -783,7 +781,8 @@ "enter": "menu::Confirm", "cmd-f": "agents_sidebar::FocusSidebarFilter", "cmd-g": "agents_sidebar::ToggleThreadHistory", - "shift-backspace": "agent::RemoveSelectedThread", + "shift-backspace": "agent::ArchiveSelectedThread", + "cmd-shift-backspace": "agent::RemoveSelectedThread", "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, @@ -1269,17 +1268,17 @@ "ctrl-enter": "assistant::InlineAssist", "ctrl-_": null, // emacs undo // Some nice conveniences - "cmd-backspace": ["terminal::SendText", "\u0015"], // ctrl-u: clear line + "cmd-backspace": ["terminal::SendKeystroke", "ctrl-u"], // clear line "alt-delete": ["terminal::SendText", "\u001bd"], // alt-d: delete word forward - "cmd-delete": ["terminal::SendText", "\u000b"], // ctrl-k: delete to end of line - "cmd-right": ["terminal::SendText", "\u0005"], - "cmd-left": ["terminal::SendText", "\u0001"], + "cmd-delete": ["terminal::SendKeystroke", "ctrl-k"], // delete to end of line + "cmd-right": ["terminal::SendKeystroke", "ctrl-e"], + "cmd-left": ["terminal::SendKeystroke", "ctrl-a"], // Terminal.app compatibility "alt-left": ["terminal::SendText", "\u001bb"], "alt-right": ["terminal::SendText", "\u001bf"], "alt-b": ["terminal::SendText", "\u001bb"], "alt-f": ["terminal::SendText", "\u001bf"], - "ctrl-delete": ["terminal::SendText", "\u001bd"], + "ctrl-delete": ["terminal::SendText", "\u001b[3;5~"], // There are conflicting bindings for these keys in the global context. // these bindings override them, remove at your own risk: "up": ["terminal::SendKeystroke", "up"], @@ -1458,6 +1457,7 @@ "context": "Welcome", "use_key_equivalents": true, "bindings": { + "cmd-n": "workspace::NewFile", "cmd-=": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd-+": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd--": ["zed::DecreaseUiFontSize", { "persist": false }], @@ -1476,15 +1476,6 @@ "ctrl-shift-enter": "workspace::OpenWithSystem", }, }, - { - "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", - "use_key_equivalents": true, - "bindings": { - "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault", - "cmd-shift-backspace": "git::DeleteWorktree", - }, - }, { // Handled under a more specific context to avoid conflicts with the // `OpenCurrentFile` keybind from the settings UI @@ -1584,9 +1575,15 @@ { "context": "GitPicker", "bindings": { - "cmd-1": "git_picker::ActivateWorktreesTab", - "cmd-2": "git_picker::ActivateBranchesTab", - "cmd-3": "git_picker::ActivateStashTab", + "cmd-1": "git_picker::ActivateBranchesTab", + "cmd-2": "git_picker::ActivateStashTab", + }, + }, + { + "context": "WorktreePicker || (WorktreePicker > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-backspace": "worktree_picker::DeleteWorktree", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 03225bb253fbe7..a4196bd8e0cf01 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -225,7 +225,6 @@ "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewThread", - "ctrl-shift-h": "agent::OpenHistory", "shift-alt-c": "agent::OpenSettings", "shift-alt-l": "agent::OpenRulesLibrary", "shift-alt-p": "agent::ManageProfiles", @@ -235,10 +234,8 @@ // `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Windows under the `AgentPanel` context "alt-l": "agent::CycleFavoriteModels", "shift-alt-/": "agent::ToggleModelSelector", - "shift-alt-j": "agent::ToggleNavigationMenu", "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", - "ctrl-shift-t": "agent::ToggleWorktreeSelector", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", @@ -626,6 +623,7 @@ // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]", "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "shift-alt-b": "branches::OpenRecent", + "shift-alt-w": "git::Worktree", "shift-alt-enter": "toast::RunAction", "ctrl-shift-`": "workspace::NewTerminal", "ctrl-s": "workspace::Save", @@ -728,7 +726,8 @@ "enter": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", "ctrl-g": "agents_sidebar::ToggleThreadHistory", - "shift-backspace": "agent::RemoveSelectedThread", + "shift-backspace": "agent::ArchiveSelectedThread", + "ctrl-backspace": "agent::RemoveSelectedThread", "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, @@ -1390,6 +1389,7 @@ "context": "Welcome", "use_key_equivalents": true, "bindings": { + "ctrl-n": "workspace::NewFile", "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], @@ -1401,15 +1401,6 @@ "ctrl-5": ["welcome::OpenRecentProject", 4], }, }, - { - "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", - "use_key_equivalents": true, - "bindings": { - "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault", - "ctrl-shift-backspace": "git::DeleteWorktree", - }, - }, { // Handled under a more specific context to avoid conflicts with the // `OpenCurrentFile` keybind from the settings UI @@ -1508,9 +1499,15 @@ { "context": "GitPicker", "bindings": { - "alt-1": "git_picker::ActivateWorktreesTab", - "alt-2": "git_picker::ActivateBranchesTab", - "alt-3": "git_picker::ActivateStashTab", + "alt-1": "git_picker::ActivateBranchesTab", + "alt-2": "git_picker::ActivateStashTab", + }, + }, + { + "context": "WorktreePicker || (WorktreePicker > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-backspace": "worktree_picker::DeleteWorktree", }, }, { diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 464270274af775..d5bc26b9417daa 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -418,7 +418,7 @@ }, }, { - "context": "VimControl && vim_mode == helix_normal && !menu", + "context": "VimControl && vim_mode == helix_normal && !menu && !BufferSearchBar", "bindings": { "j": ["vim::Down", { "display_lines": true }], "down": ["vim::Down", { "display_lines": true }], @@ -436,7 +436,7 @@ }, }, { - "context": "vim_mode == helix_select && !menu", + "context": "vim_mode == helix_select && !menu && !BufferSearchBar", "bindings": { "escape": "vim::SwitchToHelixNormalMode", }, @@ -934,6 +934,14 @@ "[ b": "pane::ActivatePreviousItem", "] shift-b": "pane::ActivateLastItem", "[ shift-b": ["pane::ActivateItem", 0], + "space f": "file_finder::Toggle", + "space /": "pane::DeploySearch", + "space shift-s": "project_symbols::Toggle", + "space w h": "workspace::ActivatePaneLeft", + "space w j": "workspace::ActivatePaneDown", + "space w k": "workspace::ActivatePaneUp", + "space w l": "workspace::ActivatePaneRight", + "space w q": "pane::CloseActiveItem", }, }, { diff --git a/assets/settings/default.json b/assets/settings/default.json index ddf59a9eeaf19e..098586ee4fcb1d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1,8 +1,5 @@ { "$schema": "zed://schemas/settings", - /// The displayed name of this project. If not set or null, the root directory name - /// will be displayed. - "project_name": null, // The name of the Zed theme to use for the UI. // // `mode` is one of: @@ -342,6 +339,17 @@ // The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. "delay": 300, }, + // Whether and how to display code lenses from language servers. + // + // Possible values: + // + // 1. Do not display code lenses. + // "code_lens": "off", + // 2. Display code lenses from language servers above code elements. + // "code_lens": "on", + // 3. Display code lenses in the code action menu. + // "code_lens": "menu", + "code_lens": "off", // What to do when go to definition yields no results. // // 1. Do nothing: `none` @@ -473,8 +481,8 @@ "use_system_window_tabs": false, // Titlebar related settings "title_bar": { - // Whether to show the branch icon beside branch switcher in the titlebar. - "show_branch_icon": false, + // Whether to show git status indicators on the branch icon in the titlebar. + "show_branch_status_icon": false, // Whether to show the branch name button in the titlebar. "show_branch_name": true, // Whether to show the project host and name in the titlebar. @@ -992,7 +1000,11 @@ "default_width": 640, // Default height when the agent panel is docked to the bottom. "default_height": 320, - // Maximum content width when the agent panel is wider than this value. + // Whether to limit the content width in the agent panel. When enabled, + // content will be constrained to `max_content_width` and centered when + // the panel is wider, for optimal readability. + "limit_content_width": true, + // Maximum content width in pixels when limit_content_width is enabled. // Content will be centered within the panel. "max_content_width": 850, // The default model to use when creating new threads. @@ -1359,6 +1371,24 @@ // Removes any lines containing only whitespace at the end of the file and // ensures just one newline at the end. "ensure_final_newline_on_save": true, + // How line endings should be handled for new files and during format and save. + // This setting can take five values: + // + // 1. Detect existing line endings and otherwise use the platform default + // (`lf` on Unix, `crlf` on Windows): + // "line_ending": "detect" + // 2. Prefer LF (`\n`) for new files and files with no existing line ending: + // "line_ending": "prefer_lf" + // 3. Prefer CRLF (`\r\n`) for new files and files with no existing line ending: + // "line_ending": "prefer_crlf" + // 4. Enforce LF (`\n`) during format and save: + // "line_ending": "enforce_lf" + // 5. Enforce CRLF (`\r\n`) during format and save: + // "line_ending": "enforce_crlf" + // + // 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] // Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored "format_on_save": "on", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 279ec6bf66802a..cf4693beba7d42 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3,7 +3,7 @@ mod diff; mod mention; mod terminal; use action_log::{ActionLog, ActionLogTelemetry}; -use agent_client_protocol::{self as acp}; +use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result, anyhow}; use collections::HashSet; pub use connection::*; diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 58e66a7685d524..4bbf13bdb5ddcf 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -1,5 +1,5 @@ use crate::AcpThread; -use agent_client_protocol::{self as acp}; +use agent_client_protocol::schema as acp; use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, IndexMap}; @@ -954,7 +954,7 @@ mod test_support { fn truncate( &self, - _session_id: &agent_client_protocol::SessionId, + _session_id: &acp::SessionId, _cx: &App, ) -> Option> { Some(Rc::new(StubAgentSessionEditor)) diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 28038ecbc04c59..ac7b2d23cb7966 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -1,4 +1,4 @@ -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result, bail}; use file_icons::FileIcons; use prompt_store::{PromptId, UserPromptId}; diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index fceb816f7f1471..2fe769cb737b71 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -1,4 +1,4 @@ -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::Result; use futures::{FutureExt as _, future::Shared}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task}; diff --git a/crates/acp_tools/Cargo.toml b/crates/acp_tools/Cargo.toml index 8f14b1f93b32c6..2d7162b9dec538 100644 --- a/crates/acp_tools/Cargo.toml +++ b/crates/acp_tools/Cargo.toml @@ -13,15 +13,20 @@ workspace = true path = "src/acp_tools.rs" doctest = false +[features] +test-support = ["workspace/test-support"] + [dependencies] agent-client-protocol.workspace = true collections.workspace = true gpui.workspace = true language.workspace= true +log.workspace = true markdown.workspace = true project.workspace = true serde.workspace = true serde_json.workspace = true +smol.workspace = true settings.workspace = true theme_settings.workspace = true ui.workspace = true diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index ae8a39c8df4f73..ea6de9f7d606be 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -1,12 +1,13 @@ use std::{ - cell::RefCell, - collections::HashSet, + collections::{HashSet, VecDeque}, fmt::Display, - rc::{Rc, Weak}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use collections::HashMap; use gpui::{ App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState, @@ -23,6 +24,111 @@ use workspace::{ Item, ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, }; +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum StreamMessageDirection { + Incoming, + Outgoing, + /// Lines captured from the agent's stderr. These are not part of the + /// JSON-RPC protocol, but agents often emit useful diagnostics there. + Stderr, +} + +#[derive(Clone)] +pub enum StreamMessageContent { + Request { + id: acp::RequestId, + method: Arc, + params: Option, + }, + Response { + id: acp::RequestId, + result: Result, acp::Error>, + }, + Notification { + method: Arc, + params: Option, + }, + /// A raw stderr line from the agent process. + Stderr { line: Arc }, +} + +#[derive(Clone)] +pub struct StreamMessage { + pub direction: StreamMessageDirection, + pub message: StreamMessageContent, +} + +impl StreamMessage { + /// Build a `StreamMessage` from a raw line captured off the transport. + /// + /// For `Stderr`, the line is wrapped as-is (no JSON parsing). For + /// `Incoming`/`Outgoing`, the line is parsed as JSON-RPC; returns `None` + /// if it doesn't look like a valid JSON-RPC message. + pub fn from_raw_line(direction: StreamMessageDirection, line: &str) -> Option { + if direction == StreamMessageDirection::Stderr { + return Some(StreamMessage { + direction, + message: StreamMessageContent::Stderr { + line: Arc::from(line), + }, + }); + } + + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let obj = value.as_object()?; + + let parsed_id = obj + .get("id") + .map(|raw| serde_json::from_value::(raw.clone())); + + let message = if let Some(method) = obj.get("method").and_then(|m| m.as_str()) { + match parsed_id { + Some(Ok(id)) => StreamMessageContent::Request { + id, + method: method.into(), + params: obj.get("params").cloned(), + }, + Some(Err(err)) => { + log::warn!("Skipping JSON-RPC message with unparsable id: {err}"); + return None; + } + None => StreamMessageContent::Notification { + method: method.into(), + params: obj.get("params").cloned(), + }, + } + } else if let Some(parsed_id) = parsed_id { + let id = match parsed_id { + Ok(id) => id, + Err(err) => { + log::warn!("Skipping JSON-RPC response with unparsable id: {err}"); + return None; + } + }; + if let Some(error) = obj.get("error") { + let acp_err = + serde_json::from_value::(error.clone()).unwrap_or_else(|err| { + log::warn!("Failed to deserialize ACP error: {err}"); + acp::Error::internal_error().data(error.to_string()) + }); + StreamMessageContent::Response { + id, + result: Err(acp_err), + } + } else { + StreamMessageContent::Response { + id, + result: Ok(obj.get("result").cloned()), + } + } + } else { + return None; + }; + + Some(StreamMessage { direction, message }) + } +} + actions!(dev, [OpenAcpLogs]); pub fn init(cx: &mut App) { @@ -42,14 +148,87 @@ struct GlobalAcpConnectionRegistry(Entity); impl Global for GlobalAcpConnectionRegistry {} -#[derive(Default)] -pub struct AcpConnectionRegistry { - active_connection: RefCell>, +/// A raw line captured from the transport (or from stderr), tagged with +/// direction. Deserialization into [`StreamMessage`] happens on the +/// registry's foreground task so the ring buffer can be replayed to late +/// subscribers. +struct RawStreamLine { + direction: StreamMessageDirection, + line: Arc, } -struct ActiveConnection { - agent_id: AgentId, - connection: Weak, +/// Handle to an ACP connection's log tap. Passed back by +/// [`AcpConnectionRegistry::set_active_connection`] so that the connection +/// can publish transport and stderr lines without knowing anything about +/// the logs panel's channel. +/// +/// The tap carries a shared `enabled` flag that the registry flips on when +/// the first observer subscribes. Until then, `emit_*` methods are +/// effectively free: they check an atomic and return. This keeps the +/// logs panel's memory footprint opt-in — if no one ever opens it, the +/// transport never allocates a line or pushes a channel item. +#[derive(Clone)] +pub struct AcpLogTap { + enabled: Arc, + sender: smol::channel::Sender, +} + +impl AcpLogTap { + fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } + + fn enable(&self) { + self.enabled.store(true, Ordering::Relaxed); + } + + fn emit(&self, direction: StreamMessageDirection, line: &str) { + if !self.is_enabled() { + return; + } + self.sender + .try_send(RawStreamLine { + direction, + line: Arc::from(line), + }) + .log_err(); + } + + /// Record a line read from the agent's stdout. + pub fn emit_incoming(&self, line: &str) { + self.emit(StreamMessageDirection::Incoming, line); + } + + /// Record a line written to the agent's stdin. + pub fn emit_outgoing(&self, line: &str) { + self.emit(StreamMessageDirection::Outgoing, line); + } + + /// Record a line read from the agent's stderr. + pub fn emit_stderr(&self, line: &str) { + self.emit(StreamMessageDirection::Stderr, line); + } +} + +/// Maximum number of messages retained in the registry's backlog. +/// +/// Mirrors `MAX_STORED_LOG_ENTRIES` in the LSP log store, so that opening the +/// ACP logs panel after a session has been running for a while still shows +/// meaningful history. +const MAX_BACKLOG_MESSAGES: usize = 2000; + +#[derive(Default)] +pub struct AcpConnectionRegistry { + active_agent_id: Option, + generation: u64, + /// Bounded ring buffer of every message observed on the current connection. + /// When a new connection is set, this is cleared. + backlog: VecDeque, + subscribers: Vec>, + /// The tap handed to the currently active connection, so the registry + /// can flip its `enabled` flag the first time someone subscribes. + active_tap: Option, + _broadcast_task: Option>, } impl AcpConnectionRegistry { @@ -63,17 +242,94 @@ impl AcpConnectionRegistry { } } + /// Register a new active connection and return an [`AcpLogTap`] that + /// the connection should hand to its transport + stderr readers. + /// + /// The tap starts out disabled: transport lines are dropped cheaply + /// until someone subscribes via [`Self::subscribe`], at which point + /// the tap is flipped on and subsequent lines are broadcast to all + /// current and future subscribers. pub fn set_active_connection( - &self, + &mut self, agent_id: AgentId, - connection: &Rc, cx: &mut Context, - ) { - self.active_connection.replace(Some(ActiveConnection { - agent_id, - connection: Rc::downgrade(connection), + ) -> AcpLogTap { + let (sender, raw_rx) = smol::channel::unbounded::(); + let tap = AcpLogTap { + enabled: Arc::new(AtomicBool::new(false)), + sender, + }; + + self.active_agent_id = Some(agent_id); + self.generation += 1; + self.backlog.clear(); + self.subscribers.clear(); + self.active_tap = Some(tap.clone()); + + self._broadcast_task = Some(cx.spawn(async move |this, cx| { + while let Ok(raw) = raw_rx.recv().await { + this.update(cx, |this, _cx| { + let Some(message) = StreamMessage::from_raw_line(raw.direction, &raw.line) + else { + return; + }; + + if this.backlog.len() == MAX_BACKLOG_MESSAGES { + this.backlog.pop_front(); + } + this.backlog.push_back(message.clone()); + + this.subscribers.retain(|sender| !sender.is_closed()); + for sender in &this.subscribers { + sender.try_send(message.clone()).log_err(); + } + }) + .log_err(); + } + + // The transport closed — clear state so observers (e.g. the ACP + // logs tab) can transition back to the disconnected state. + this.update(cx, |this, cx| { + this.active_agent_id = None; + this.subscribers.clear(); + this.active_tap = None; + cx.notify(); + }) + .log_err(); })); + cx.notify(); + tap + } + + /// Clear the retained message history for the current connection and force + /// watchers to resubscribe so their local correlation state is reset too. + pub fn clear_messages(&mut self, cx: &mut Context) { + self.backlog.clear(); + self.generation += 1; + self.subscribers.clear(); + cx.notify(); + } + + /// Subscribe to messages on the current connection. + /// + /// Returns the existing backlog (already-observed messages) together with + /// a receiver for new messages. The caller is responsible for flushing the + /// backlog into its local state before draining the receiver, so that no + /// messages are dropped between the snapshot and live subscription. + /// + /// The first subscription enables the connection's log tap; prior + /// messages are therefore not available. This is intentional: the tap + /// is opt-in so that the default case (no one ever opens the ACP logs + /// panel) performs zero per-message bookkeeping. + pub fn subscribe(&mut self) -> (Vec, smol::channel::Receiver) { + if let Some(tap) = &self.active_tap { + tap.enable(); + } + let backlog = self.backlog.iter().cloned().collect(); + let (sender, receiver) = smol::channel::unbounded(); + self.subscribers.push(sender); + (backlog, receiver) } } @@ -88,9 +344,9 @@ struct AcpTools { struct WatchedConnection { agent_id: AgentId, + generation: u64, messages: Vec, list_state: ListState, - connection: Weak, incoming_request_methods: HashMap>, outgoing_request_methods: HashMap>, _task: Task<()>, @@ -118,44 +374,54 @@ impl AcpTools { } fn update_connection(&mut self, cx: &mut Context) { - let active_connection = self.connection_registry.read(cx).active_connection.borrow(); - let Some(active_connection) = active_connection.as_ref() else { + let (generation, agent_id) = { + let registry = self.connection_registry.read(cx); + (registry.generation, registry.active_agent_id.clone()) + }; + + let Some(agent_id) = agent_id else { + self.watched_connection = None; + self.expanded.clear(); return; }; - if let Some(watched_connection) = self.watched_connection.as_ref() { - if Weak::ptr_eq( - &watched_connection.connection, - &active_connection.connection, - ) { + if let Some(watched) = self.watched_connection.as_ref() { + if watched.generation == generation { return; } } - if let Some(connection) = active_connection.connection.upgrade() { - let mut receiver = connection.subscribe(); - let task = cx.spawn(async move |this, cx| { - while let Ok(message) = receiver.recv().await { - this.update(cx, |this, cx| { - this.push_stream_message(message, cx); - }) - .ok(); - } - }); + self.expanded.clear(); - self.watched_connection = Some(WatchedConnection { - agent_id: active_connection.agent_id.clone(), - messages: vec![], - list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)), - connection: active_connection.connection.clone(), - incoming_request_methods: HashMap::default(), - outgoing_request_methods: HashMap::default(), - _task: task, - }); + let (backlog, messages_rx) = self + .connection_registry + .update(cx, |registry, _cx| registry.subscribe()); + + let task = cx.spawn(async move |this, cx| { + while let Ok(message) = messages_rx.recv().await { + this.update(cx, |this, cx| { + this.push_stream_message(message, cx); + }) + .log_err(); + } + }); + + self.watched_connection = Some(WatchedConnection { + agent_id, + generation, + messages: vec![], + list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)), + incoming_request_methods: HashMap::default(), + outgoing_request_methods: HashMap::default(), + _task: task, + }); + + for message in backlog { + self.push_stream_message(message, cx); } } - fn push_stream_message(&mut self, stream_message: acp::StreamMessage, cx: &mut Context) { + fn push_stream_message(&mut self, stream_message: StreamMessage, cx: &mut Context) { let Some(connection) = self.watched_connection.as_mut() else { return; }; @@ -163,27 +429,22 @@ impl AcpTools { let index = connection.messages.len(); let (request_id, method, message_type, params) = match stream_message.message { - acp::StreamMessageContent::Request { id, method, params } => { + StreamMessageContent::Request { id, method, params } => { let method_map = match stream_message.direction { - acp::StreamMessageDirection::Incoming => { - &mut connection.incoming_request_methods - } - acp::StreamMessageDirection::Outgoing => { - &mut connection.outgoing_request_methods - } + StreamMessageDirection::Incoming => &mut connection.incoming_request_methods, + StreamMessageDirection::Outgoing => &mut connection.outgoing_request_methods, + // Stderr lines never carry request/response correlation. + StreamMessageDirection::Stderr => return, }; method_map.insert(id.clone(), method.clone()); (Some(id), method.into(), MessageType::Request, Ok(params)) } - acp::StreamMessageContent::Response { id, result } => { + StreamMessageContent::Response { id, result } => { let method_map = match stream_message.direction { - acp::StreamMessageDirection::Incoming => { - &mut connection.outgoing_request_methods - } - acp::StreamMessageDirection::Outgoing => { - &mut connection.incoming_request_methods - } + StreamMessageDirection::Incoming => &mut connection.outgoing_request_methods, + StreamMessageDirection::Outgoing => &mut connection.incoming_request_methods, + StreamMessageDirection::Stderr => return, }; if let Some(method) = method_map.remove(&id) { @@ -197,9 +458,20 @@ impl AcpTools { ) } } - acp::StreamMessageContent::Notification { method, params } => { + StreamMessageContent::Notification { method, params } => { (None, method.into(), MessageType::Notification, Ok(params)) } + StreamMessageContent::Stderr { line } => { + // Stderr is rendered as plain text inline with JSON-RPC traffic, + // using `stderr` as the pseudo-method name so it shows up in the + // header the same way real methods do. + ( + None, + "stderr".into(), + MessageType::Stderr, + Ok(Some(serde_json::Value::String(line.to_string()))), + ) + } }; let message = WatchedConnectionMessage { @@ -243,8 +515,9 @@ impl AcpTools { }; Some(serde_json::json!({ "_direction": match message.direction { - acp::StreamMessageDirection::Incoming => "incoming", - acp::StreamMessageDirection::Outgoing => "outgoing", + StreamMessageDirection::Incoming => "incoming", + StreamMessageDirection::Outgoing => "outgoing", + StreamMessageDirection::Stderr => "stderr", }, "_type": message.message_type.to_string().to_lowercase(), "id": message.request_id, @@ -261,6 +534,8 @@ impl AcpTools { if let Some(connection) = self.watched_connection.as_mut() { connection.messages.clear(); connection.list_state.reset(0); + connection.incoming_request_methods.clear(); + connection.outgoing_request_methods.clear(); self.expanded.clear(); cx.notify(); } @@ -326,12 +601,15 @@ impl AcpTools { cx.notify() })) .child(match message.direction { - acp::StreamMessageDirection::Incoming => Icon::new(IconName::ArrowDown) + StreamMessageDirection::Incoming => Icon::new(IconName::ArrowDown) .color(Color::Error) .size(IconSize::Small), - acp::StreamMessageDirection::Outgoing => Icon::new(IconName::ArrowUp) + StreamMessageDirection::Outgoing => Icon::new(IconName::ArrowUp) .color(Color::Success) .size(IconSize::Small), + StreamMessageDirection::Stderr => Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::Small), }) .child( Label::new(message.name.clone()) @@ -403,7 +681,7 @@ impl AcpTools { struct WatchedConnectionMessage { name: SharedString, request_id: Option, - direction: acp::StreamMessageDirection, + direction: StreamMessageDirection, message_type: MessageType, params: Result, acp::Error>, collapsed_params_md: Option>, @@ -463,6 +741,7 @@ enum MessageType { Request, Response, Notification, + Stderr, } impl Display for MessageType { @@ -471,6 +750,7 @@ impl Display for MessageType { MessageType::Request => write!(f, "Request"), MessageType::Response => write!(f, "Response"), MessageType::Notification => write!(f, "Notification"), + MessageType::Stderr => write!(f, "Stderr"), } } } @@ -561,6 +841,7 @@ impl Render for AcpToolsToolbarItemView { }; let acp_tools = acp_tools.clone(); + let connection_registry = acp_tools.read(cx).connection_registry.clone(); let has_messages = acp_tools .read(cx) .watched_connection @@ -585,6 +866,9 @@ impl Render for AcpToolsToolbarItemView { .tooltip(Tooltip::text("Clear Messages")) .disabled(!has_messages) .on_click(cx.listener(move |_this, _, _window, cx| { + connection_registry.update(cx, |registry, cx| { + registry.clear_messages(cx); + }); acp_tools.update(cx, |acp_tools, cx| { acp_tools.clear_messages(cx); }); diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index d2d8b6505a080c..5f4e25b5ccd40c 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -758,7 +758,7 @@ impl Render for ActivityIndicator { }), ), ) - .anchor(gpui::Corner::BottomLeft) + .anchor(gpui::Anchor::BottomLeft) .menu(move |window, cx| { let strong_this = activity_indicator.upgrade()?; let mut has_work = false; diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 553858881a0144..9eb0f84b6fb315 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -28,7 +28,7 @@ use acp_thread::{ AcpThread, AgentModelSelector, AgentSessionInfo, AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, TokenUsageRatio, UserMessageId, }; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; @@ -47,7 +47,7 @@ use prompt_store::{ WorktreeContext, }; use serde::{Deserialize, Serialize}; -use settings::{LanguageModelSelection, update_settings_file}; +use settings::{LanguageModelSelection, Settings as _, update_settings_file}; use std::any::Any; use std::path::PathBuf; use std::rc::Rc; @@ -591,6 +591,7 @@ impl NativeAgent { let tree = worktree.read(cx); let root_name = tree.root_name_str().into(); let abs_path = tree.abs_path(); + let scan_complete = tree.as_local().map(|local| local.scan_complete()); let mut context = WorktreeContext { root_name, @@ -598,20 +599,24 @@ impl NativeAgent { rules_file: None, }; - let rules_task = Self::load_worktree_rules_file(worktree, project, cx); - let Some(rules_task) = rules_task else { - return Task::ready((context, None)); - }; + cx.spawn(async move |cx| { + if let Some(scan_complete) = scan_complete { + scan_complete.await; + } - cx.spawn(async move |_| { - let (rules_file, rules_file_error) = match rules_task.await { - Ok(rules_file) => (Some(rules_file), None), - Err(err) => ( - None, - Some(RulesLoadingError { - message: format!("{err}").into(), - }), - ), + let rules_task = cx.update(|cx| Self::load_worktree_rules_file(worktree, project, cx)); + + let (rules_file, rules_file_error) = match rules_task { + Some(rules_task) => match rules_task.await { + Ok(rules_file) => (Some(rules_file), None), + Err(err) => ( + None, + Some(RulesLoadingError { + message: format!("{err}").into(), + }), + ), + }, + None => (None, None), }; context.rules_file = rules_file; (context, rules_file_error) @@ -755,10 +760,9 @@ impl NativeAgent { for session in self.sessions.values_mut() { session.thread.update(cx, |thread, cx| { - let should_update_model = thread.model().is_none() - || (thread.is_empty() - && matches!(event, language_model::Event::DefaultModelChanged)); - if should_update_model && let Some(model) = default_model.clone() { + if thread.model().is_none() + && let Some(model) = default_model.clone() + { thread.set_model(model, cx); cx.notify(); } @@ -1423,16 +1427,29 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector { return Task::ready(Err(anyhow!("Invalid model ID {}", model_id))); }; - // We want to reset the effort level when switching models, as the currently-selected effort level may - // not be compatible. - let effort = model - .default_effort_level() - .map(|effort_level| effort_level.value.to_string()); + let favorite = agent_settings::AgentSettings::get_global(cx) + .favorite_models + .iter() + .find(|favorite| { + favorite.provider.0 == model.provider_id().0.as_ref() + && favorite.model == model.id().0.as_ref() + }) + .cloned(); + + let LanguageModelSelection { + enable_thinking, + effort, + speed, + .. + } = agent_settings::language_model_to_selection(&model, favorite.as_ref()); thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread.set_thinking_effort(effort.clone(), cx); - thread.set_thinking_enabled(model.supports_thinking(), cx); + thread.set_thinking_enabled(enable_thinking, cx); + if let Some(speed) = speed { + thread.set_speed(speed, cx); + } }); update_settings_file( diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index bde07a040869bf..0ed03ed51703b0 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -1,6 +1,6 @@ use crate::{AgentMessage, AgentMessageContent, UserMessage, UserMessageContent}; use acp_thread::UserMessageId; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_settings::AgentProfileId; use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 305c4f51952b3b..b79cd67b598bfa 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -1,12 +1,13 @@ use std::{any::Any, rc::Rc, sync::Arc}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_servers::{AgentServer, AgentServerDelegate}; -use agent_settings::AgentSettings; +use agent_settings::{AgentSettings, language_model_to_selection}; use anyhow::Result; use collections::HashSet; use fs::Fs; use gpui::{App, Entity, Task}; +use language_model::{LanguageModelId, LanguageModelProviderId, LanguageModelRegistry}; use project::{AgentId, Project}; use prompt_store::PromptStore; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; @@ -76,7 +77,7 @@ impl AgentServer for NativeAgentServer { fs: Arc, cx: &App, ) { - let selection = model_id_to_selection(&model_id); + let selection = model_id_to_selection(&model_id, cx); update_settings_file(fs, cx, move |settings, _| { let agent = settings.agent.get_or_insert_default(); if should_be_favorite { @@ -89,16 +90,41 @@ impl AgentServer for NativeAgentServer { } /// Convert a ModelId (e.g. "anthropic/claude-3-5-sonnet") to a LanguageModelSelection. -fn model_id_to_selection(model_id: &acp::ModelId) -> LanguageModelSelection { +fn model_id_to_selection(model_id: &acp::ModelId, cx: &App) -> LanguageModelSelection { let id = model_id.0.as_ref(); let (provider, model) = id.split_once('/').unwrap_or(("", id)); - LanguageModelSelection { - provider: provider.to_owned().into(), - model: model.to_owned(), - enable_thinking: false, - effort: None, - speed: None, - } + + let provider_id = LanguageModelProviderId(provider.to_string().into()); + let model_id_typed = LanguageModelId(model.to_string().into()); + let resolved = LanguageModelRegistry::global(cx) + .read(cx) + .provider(&provider_id) + .and_then(|p| { + p.provided_models(cx) + .into_iter() + .find(|m| m.id() == model_id_typed) + }); + + let Some(resolved) = resolved else { + return LanguageModelSelection { + provider: provider.to_owned().into(), + model: model.to_owned(), + enable_thinking: false, + effort: None, + speed: None, + }; + }; + + let current_user_selection = AgentSettings::get_global(cx) + .default_model + .as_ref() + .filter(|selection| { + selection.provider.0 == resolved.provider_id().0.as_ref() + && selection.model == resolved.id().0.as_ref() + }) + .cloned(); + + language_model_to_selection(&resolved, current_user_selection.as_ref()) } #[cfg(test)] diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 16952e178aff86..a6419b52a0ee77 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3,7 +3,7 @@ use acp_thread::{ AgentConnection, AgentModelGroupName, AgentModelList, PermissionOptions, ThreadStatus, UserMessageId, }; -use agent_client_protocol::{self as acp}; +use agent_client_protocol::schema as acp; use agent_settings::AgentProfileId; use anyhow::Result; use client::{Client, RefreshLlmTokenListener, UserStore}; @@ -5402,7 +5402,7 @@ async fn test_max_subagent_depth_prevents_tool_registration(cx: &mut TestAppCont cx, ); thread.set_subagent_context(SubagentContext { - parent_thread_id: agent_client_protocol::SessionId::new("parent-id"), + parent_thread_id: acp::SessionId::new("parent-id"), depth: MAX_SUBAGENT_DEPTH - 1, }); thread diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 432c8c74a143e1..da5602050de109 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -12,7 +12,7 @@ use feature_flags::{ FeatureFlagAppExt as _, StreamingEditFileToolFeatureFlag, UpdatePlanToolFeatureFlag, }; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_settings::{ AgentProfileId, AgentSettings, SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, }; @@ -3445,7 +3445,7 @@ where T::description() } - fn kind(&self) -> agent_client_protocol::ToolKind { + fn kind(&self) -> acp::ToolKind { T::kind() } diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index e62ff78871c653..f1367457d327d6 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -1,5 +1,5 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::{Result, anyhow}; use gpui::{App, Context, Entity, Global, Task, prelude::*}; use util::path_list::PathList; diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index ff9e735b6c4181..65cbcfb2c609cb 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -574,7 +574,7 @@ mod tests { flexible: true, default_width: px(300.), default_height: px(600.), - max_content_width: px(850.), + max_content_width: Some(px(850.)), default_model: None, inline_assistant_model: None, inline_assistant_use_streaming_tools: false, diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index df4cc313036b55..65b5df8abfe1c0 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -1,5 +1,5 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::ToolKind; +use agent_client_protocol::schema as acp; use anyhow::Result; use collections::{BTreeMap, HashMap}; use context_server::{ContextServerId, client::NotificationSubscription}; @@ -304,8 +304,8 @@ impl AnyAgentTool for ContextServerTool { self.tool.description.clone().unwrap_or_default().into() } - fn kind(&self) -> ToolKind { - ToolKind::Other + fn kind(&self) -> acp::ToolKind { + acp::ToolKind::Other } fn initial_title(&self, _input: serde_json::Value, _cx: &mut App) -> SharedString { diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index 06600f64874851..063742cef8b888 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -5,7 +5,7 @@ use super::tool_permissions::{ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_paths, }; -use agent_client_protocol::ToolKind; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; use gpui::{App, Entity, Task}; @@ -61,8 +61,8 @@ impl AgentTool for CopyPathTool { const NAME: &'static str = "copy_path"; - fn kind() -> ToolKind { - ToolKind::Move + fn kind() -> acp::ToolKind { + acp::ToolKind::Move } fn initial_title( @@ -198,7 +198,6 @@ impl AgentTool for CopyPathTool { #[cfg(test)] mod tests { use super::*; - use agent_client_protocol as acp; use fs::Fs as _; use gpui::TestAppContext; use project::{FakeFs, Project}; diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index 60bb44e39ee5ab..0e5261d0715907 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -2,7 +2,7 @@ use super::tool_permissions::{ SensitiveSettingsKind, authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape, sensitive_settings_kind, }; -use agent_client_protocol::ToolKind; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; use gpui::{App, Entity, SharedString, Task}; @@ -52,8 +52,8 @@ impl AgentTool for CreateDirectoryTool { const NAME: &'static str = "create_directory"; - fn kind() -> ToolKind { - ToolKind::Read + fn kind() -> acp::ToolKind { + acp::ToolKind::Read } fn initial_title( @@ -169,7 +169,6 @@ impl AgentTool for CreateDirectoryTool { #[cfg(test)] mod tests { use super::*; - use agent_client_protocol as acp; use fs::Fs as _; use gpui::TestAppContext; use project::{FakeFs, Project}; diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index 21b4674425d916..d790896425885e 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -6,7 +6,7 @@ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path, }; use action_log::ActionLog; -use agent_client_protocol::ToolKind; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use futures::{FutureExt as _, SinkExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext, Entity, SharedString, Task}; @@ -55,8 +55,8 @@ impl AgentTool for DeletePathTool { const NAME: &'static str = "delete_path"; - fn kind() -> ToolKind { - ToolKind::Delete + fn kind() -> acp::ToolKind { + acp::ToolKind::Delete } fn initial_title( @@ -228,7 +228,6 @@ impl AgentTool for DeletePathTool { #[cfg(test)] mod tests { use super::*; - use agent_client_protocol as acp; use fs::Fs as _; use gpui::TestAppContext; use project::{FakeFs, Project}; diff --git a/crates/agent/src/tools/diagnostics_tool.rs b/crates/agent/src/tools/diagnostics_tool.rs index 5889f66c2edbe0..a59f61ae97a187 100644 --- a/crates/agent/src/tools/diagnostics_tool.rs +++ b/crates/agent/src/tools/diagnostics_tool.rs @@ -1,5 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::Result; use futures::FutureExt as _; use gpui::{App, Entity, Task}; diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 9bcf164096b996..85c17c58e8f254 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -6,7 +6,7 @@ use crate::{ edit_agent::{EditAgent, EditAgentOutputEvent, EditFormat}, }; use acp_thread::Diff; -use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields}; +use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result}; use collections::HashSet; use futures::{FutureExt as _, StreamExt as _}; @@ -260,7 +260,7 @@ impl AgentTool for EditFileTool { let abs_path = project.read(cx).absolute_path(&project_path, cx); if let Some(abs_path) = abs_path.clone() { event_stream.update_fields( - ToolCallUpdateFields::new() + acp::ToolCallUpdateFields::new() .locations(vec![acp::ToolCallLocation::new(abs_path)]), ); } @@ -409,7 +409,7 @@ impl AgentTool for EditFileTool { range.start.to_point(&buffer.snapshot()).row })); if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)])); + event_stream.update_fields(acp::ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path).line(line)])); } emitted_location = true; } diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs index 75880801595ad0..8723a6d9882df2 100644 --- a/crates/agent/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use std::sync::Arc; use std::{borrow::Cow, cell::RefCell}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use anyhow::{Context as _, Result, bail}; use futures::{AsyncReadExt as _, FutureExt as _}; diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 9c654615032251..66d127e756ca83 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -1,5 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::{Result, anyhow}; use futures::FutureExt as _; use gpui::{App, AppContext, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index fbfdc18585b822..a56c793bb856b7 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -1,5 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::Result; use futures::{FutureExt as _, StreamExt}; use gpui::{App, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs index c88492bba40ee4..8431648b64a8a0 100644 --- a/crates/agent/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -3,7 +3,7 @@ use super::tool_permissions::{ resolve_project_path, }; use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::ToolKind; +use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result, anyhow}; use gpui::{App, Entity, SharedString, Task}; use project::{Project, ProjectPath, WorktreeSettings}; @@ -127,8 +127,8 @@ impl AgentTool for ListDirectoryTool { const NAME: &'static str = "list_directory"; - fn kind() -> ToolKind { - ToolKind::Read + fn kind() -> acp::ToolKind { + acp::ToolKind::Read } fn initial_title( @@ -267,7 +267,6 @@ impl AgentTool for ListDirectoryTool { #[cfg(test)] mod tests { use super::*; - use agent_client_protocol as acp; use fs::Fs as _; use gpui::{TestAppContext, UpdateGlobal}; use indoc::indoc; diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index eaea204d84d96a..4a8aad8455019e 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -5,7 +5,7 @@ use super::tool_permissions::{ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_paths, }; -use agent_client_protocol::ToolKind; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; use gpui::{App, Entity, SharedString, Task}; @@ -62,8 +62,8 @@ impl AgentTool for MovePathTool { const NAME: &'static str = "move_path"; - fn kind() -> ToolKind { - ToolKind::Move + fn kind() -> acp::ToolKind { + acp::ToolKind::Move } fn initial_title( @@ -205,7 +205,6 @@ impl AgentTool for MovePathTool { #[cfg(test)] mod tests { use super::*; - use agent_client_protocol as acp; use fs::Fs as _; use gpui::TestAppContext; use project::{FakeFs, Project}; diff --git a/crates/agent/src/tools/now_tool.rs b/crates/agent/src/tools/now_tool.rs index fe1cafe5881d14..04aba44ff3a1f4 100644 --- a/crates/agent/src/tools/now_tool.rs +++ b/crates/agent/src/tools/now_tool.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use chrono::{Local, Utc}; use gpui::{App, SharedString, Task}; use schemars::JsonSchema; diff --git a/crates/agent/src/tools/open_tool.rs b/crates/agent/src/tools/open_tool.rs index 344a513d10c2d6..dc72c758e36b04 100644 --- a/crates/agent/src/tools/open_tool.rs +++ b/crates/agent/src/tools/open_tool.rs @@ -3,7 +3,7 @@ use super::tool_permissions::{ resolve_project_path, }; use crate::{AgentTool, ToolInput}; -use agent_client_protocol::ToolKind; +use agent_client_protocol::schema as acp; use futures::FutureExt as _; use gpui::{App, AppContext as _, Entity, SharedString, Task}; use project::Project; @@ -43,8 +43,8 @@ impl AgentTool for OpenTool { const NAME: &'static str = "open"; - fn kind() -> ToolKind { - ToolKind::Execute + fn kind() -> acp::ToolKind { + acp::ToolKind::Execute } fn initial_title( diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index 9b013f111e7eaa..4fa27114c8e2ea 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -1,5 +1,5 @@ use action_log::ActionLog; -use agent_client_protocol::{self as acp, ToolCallUpdateFields}; +use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result, anyhow}; use futures::FutureExt as _; use gpui::{App, Entity, SharedString, Task}; @@ -200,7 +200,7 @@ impl AgentTool for ReadFileTool { let file_path = input.path.clone(); cx.update(|_cx| { - event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ + event_stream.update_fields(acp::ToolCallUpdateFields::new().locations(vec![ acp::ToolCallLocation::new(&abs_path) .line(input.start_line.map(|line| line.saturating_sub(1))), ])); @@ -228,7 +228,7 @@ impl AgentTool for ReadFileTool { .context("processing image") .map_err(tool_content_err)?; - event_stream.update_fields(ToolCallUpdateFields::new().content(vec![ + event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image( acp::ImageContent::new(language_model_image.source.clone(), "image/png"), ))), @@ -333,7 +333,7 @@ impl AgentTool for ReadFileTool { text, } .to_string(); - event_stream.update_fields(ToolCallUpdateFields::new().content(vec![ + event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ acp::ToolCallContent::Content(acp::Content::new(markdown)), ])); } @@ -347,7 +347,6 @@ impl AgentTool for ReadFileTool { #[cfg(test)] mod test { use super::*; - use agent_client_protocol as acp; use fs::Fs as _; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use project::{FakeFs, Project}; diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs index b808a966cf983c..6953e234e9574c 100644 --- a/crates/agent/src/tools/restore_file_from_disk_tool.rs +++ b/crates/agent/src/tools/restore_file_from_disk_tool.rs @@ -3,7 +3,7 @@ use super::tool_permissions::{ canonicalize_worktree_roots, path_has_symlink_escape, resolve_project_path, sensitive_settings_kind, }; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use collections::FxHashSet; use futures::FutureExt as _; diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs index 0cf9666a415f81..904e9ba8642f1b 100644 --- a/crates/agent/src/tools/save_file_tool.rs +++ b/crates/agent/src/tools/save_file_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use collections::FxHashSet; use futures::FutureExt as _; diff --git a/crates/agent/src/tools/spawn_agent_tool.rs b/crates/agent/src/tools/spawn_agent_tool.rs index 27afbbdc3ea05d..cdb36126f5763d 100644 --- a/crates/agent/src/tools/spawn_agent_tool.rs +++ b/crates/agent/src/tools/spawn_agent_tool.rs @@ -1,5 +1,5 @@ use acp_thread::{SUBAGENT_SESSION_INFO_META_KEY, SubagentSessionInfo}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::Result; use gpui::{App, SharedString, Task}; use language_model::LanguageModelToolResultContent; diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index c988fede454ff6..5f6d51ee2bb5c1 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -12,7 +12,7 @@ use crate::{ }; use acp_thread::Diff; use action_log::ActionLog; -use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields}; +use agent_client_protocol::schema::{self as acp, ToolCallLocation, ToolCallUpdateFields}; use anyhow::Result; use collections::HashSet; use futures::FutureExt as _; diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index f36bd0fe3d3fb0..33560f2cf7cc7d 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use anyhow::Result; use futures::FutureExt as _; diff --git a/crates/agent/src/tools/update_plan_tool.rs b/crates/agent/src/tools/update_plan_tool.rs index 8d45f8aad42a8c..39e88590b1872a 100644 --- a/crates/agent/src/tools/update_plan_tool.rs +++ b/crates/agent/src/tools/update_plan_tool.rs @@ -1,5 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use gpui::{App, SharedString, Task}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/agent/src/tools/web_search_tool.rs b/crates/agent/src/tools/web_search_tool.rs index 75d7689fd7c8e2..3b4b5a4563ca67 100644 --- a/crates/agent/src/tools/web_search_tool.rs +++ b/crates/agent/src/tools/web_search_tool.rs @@ -4,7 +4,7 @@ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_from_settings, }; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use anyhow::Result; use cloud_llm_client::WebSearchResponse; diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 0b547e8a0af797..c8970ec57a9050 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -6,7 +6,7 @@ publish.workspace = true license = "GPL-3.0-or-later" [features] -test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:async-pipe", "dep:env_logger", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"] +test-support = ["acp_tools/test-support", "acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"] e2e = [] [lints] @@ -22,8 +22,6 @@ acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true anyhow.workspace = true -async-pipe = { workspace = true, optional = true } -async-trait.workspace = true chrono.workspace = true client.workspace = true collections.workspace = true @@ -67,7 +65,6 @@ fs.workspace = true indoc.workspace = true acp_thread = { workspace = true, features = ["test-support"] } -async-pipe.workspace = true gpui = { workspace = true, features = ["test-support"] } gpui_tokio.workspace = true project = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index ce080b244dd4f9..28ec60e404314a 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -4,50 +4,232 @@ use acp_thread::{ }; use acp_tools::AcpConnectionRegistry; use action_log::ActionLog; -use agent_client_protocol::{self as acp, Agent as _, ErrorCode}; +use agent_client_protocol::schema::{self as acp, ErrorCode}; +use agent_client_protocol::{ + Agent, Client, ConnectionTo, JsonRpcResponse, Lines, Responder, SentRequest, +}; use anyhow::anyhow; use collections::HashMap; use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; -use futures::AsyncBufReadExt as _; -use futures::FutureExt as _; +use futures::channel::mpsc; use futures::future::Shared; use futures::io::BufReader; +use futures::{AsyncBufReadExt as _, Future, FutureExt as _, StreamExt as _}; use project::agent_server_store::{AgentServerCommand, AgentServerStore}; use project::{AgentId, Project}; use remote::remote_client::Interactive; use serde::Deserialize; -use settings::Settings as _; use std::path::PathBuf; use std::process::Stdio; use std::rc::Rc; +use std::sync::Arc; use std::{any::Any, cell::RefCell}; -use task::{ShellBuilder, SpawnInTerminal}; +use task::{Shell, ShellBuilder, SpawnInTerminal}; use thiserror::Error; use util::ResultExt as _; use util::path_list::PathList; use util::process::Child; -use std::sync::Arc; - use anyhow::{Context as _, Result}; use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntity}; use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; use terminal::TerminalBuilder; -use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings}; +use terminal::terminal_settings::{AlternateScroll, CursorShape}; use crate::GEMINI_ID; pub const GEMINI_TERMINAL_AUTH_METHOD_ID: &str = "spawn-gemini-cli"; +/// Awaits the response to an ACP request from a GPUI foreground task. +/// +/// The ACP SDK offers two ways to consume a [`SentRequest`]: +/// - [`SentRequest::block_task`]: linear `.await` inside a spawned task. +/// - [`SentRequest::on_receiving_result`]: a callback invoked when the +/// response arrives, with the guarantee that no other inbound messages +/// are processed while the callback runs. This is the recommended form +/// inside SDK handler callbacks, where [`block_task`] would deadlock. +/// +/// We use `on_receiving_result` with a oneshot bridge here (rather than +/// [`block_task`]) so that our handler-side code paths can share a single +/// request-awaiting helper. The SDK callback itself is trivial (one channel +/// send) so the extra ordering guarantee it imposes on the dispatch loop is +/// negligible. +fn into_foreground_future( + sent: SentRequest, +) -> impl Future> { + let (tx, rx) = futures::channel::oneshot::channel(); + let spawn_result = sent.on_receiving_result(async move |result| { + tx.send(result).ok(); + Ok(()) + }); + async move { + spawn_result?; + rx.await.map_err(|_| { + acp::Error::internal_error() + .data("response channel cancelled — connection may have dropped") + })? + } +} + #[derive(Debug, Error)] #[error("Unsupported version")] pub struct UnsupportedVersion; +/// Helper for flattening the nested `Result` shapes that come out of +/// `entity.update(cx, |_, cx| fallible_op(cx))` into a single `Result`. +/// +/// `anyhow::Error` values get converted via `acp::Error::from`, which +/// downcasts an `acp::Error` back out of `anyhow` when present, so typed +/// errors like auth-required survive the trip. +trait FlattenAcpResult { + fn flatten_acp(self) -> Result; +} + +impl FlattenAcpResult for Result, anyhow::Error> { + fn flatten_acp(self) -> Result { + match self { + Ok(Ok(value)) => Ok(value), + Ok(Err(err)) => Err(err.into()), + Err(err) => Err(err.into()), + } + } +} + +impl FlattenAcpResult for Result, anyhow::Error> { + fn flatten_acp(self) -> Result { + match self { + Ok(Ok(value)) => Ok(value), + Ok(Err(err)) => Err(err), + Err(err) => Err(err.into()), + } + } +} + +/// Holds state needed by foreground work dispatched from background handler closures. +struct ClientContext { + sessions: Rc>>, + session_list: Rc>>>, +} + +fn dispatch_queue_closed_error() -> acp::Error { + acp::Error::internal_error().data("ACP foreground dispatch queue closed") +} + +/// Work items sent from `Send` handler closures to the `!Send` foreground thread. +trait ForegroundWorkItem: Send { + fn run(self: Box, cx: &mut AsyncApp, ctx: &ClientContext); + fn reject(self: Box); +} + +type ForegroundWork = Box; + +struct RequestForegroundWork +where + Req: Send + 'static, + Res: JsonRpcResponse + Send + 'static, +{ + request: Req, + responder: Responder, + handler: fn(Req, Responder, &mut AsyncApp, &ClientContext), +} + +impl ForegroundWorkItem for RequestForegroundWork +where + Req: Send + 'static, + Res: JsonRpcResponse + Send + 'static, +{ + fn run(self: Box, cx: &mut AsyncApp, ctx: &ClientContext) { + let Self { + request, + responder, + handler, + } = *self; + handler(request, responder, cx, ctx); + } + + fn reject(self: Box) { + let Self { responder, .. } = *self; + log::error!("ACP foreground dispatch queue closed while handling inbound request"); + responder + .respond_with_error(dispatch_queue_closed_error()) + .log_err(); + } +} + +struct NotificationForegroundWork +where + Notif: Send + 'static, +{ + notification: Notif, + connection: ConnectionTo, + handler: fn(Notif, &mut AsyncApp, &ClientContext), +} + +impl ForegroundWorkItem for NotificationForegroundWork +where + Notif: Send + 'static, +{ + fn run(self: Box, cx: &mut AsyncApp, ctx: &ClientContext) { + let Self { + notification, + handler, + .. + } = *self; + handler(notification, cx, ctx); + } + + fn reject(self: Box) { + let Self { connection, .. } = *self; + log::error!("ACP foreground dispatch queue closed while handling inbound notification"); + connection + .send_error_notification(dispatch_queue_closed_error()) + .log_err(); + } +} + +fn enqueue_request( + dispatch_tx: &mpsc::UnboundedSender, + request: Req, + responder: Responder, + handler: fn(Req, Responder, &mut AsyncApp, &ClientContext), +) where + Req: Send + 'static, + Res: JsonRpcResponse + Send + 'static, +{ + let work: ForegroundWork = Box::new(RequestForegroundWork { + request, + responder, + handler, + }); + if let Err(err) = dispatch_tx.unbounded_send(work) { + err.into_inner().reject(); + } +} + +fn enqueue_notification( + dispatch_tx: &mpsc::UnboundedSender, + notification: Notif, + connection: ConnectionTo, + handler: fn(Notif, &mut AsyncApp, &ClientContext), +) where + Notif: Send + 'static, +{ + let work: ForegroundWork = Box::new(NotificationForegroundWork { + notification, + connection, + handler, + }); + if let Err(err) = dispatch_tx.unbounded_send(work) { + err.into_inner().reject(); + } +} + pub struct AcpConnection { id: AgentId, telemetry_id: SharedString, - connection: Rc, + connection: ConnectionTo, sessions: Rc>>, pending_sessions: Rc>>, auth_methods: Vec, @@ -58,7 +240,8 @@ pub struct AcpConnection { default_config_options: HashMap, child: Option, session_list: Option>, - _io_task: Task>, + _io_task: Task<()>, + _dispatch_task: Task<()>, _wait_task: Task>, _stderr_task: Task>, } @@ -102,13 +285,13 @@ pub struct AcpSession { } pub struct AcpSessionList { - connection: Rc, + connection: ConnectionTo, updates_tx: smol::channel::Sender, updates_rx: smol::channel::Receiver, } impl AcpSessionList { - fn new(connection: Rc) -> Self { + fn new(connection: ConnectionTo) -> Self { let (tx, rx) = smol::channel::unbounded(); Self { connection, @@ -141,7 +324,9 @@ impl AgentSessionList for AcpSessionList { let acp_request = acp::ListSessionsRequest::new() .cwd(request.cwd) .cursor(request.cursor); - let response = conn.list_sessions(acp_request).await?; + let response = into_foreground_future(conn.send_request(acp_request)) + .await + .map_err(map_acp_error)?; Ok(AgentSessionListResponse { sessions: response .sessions @@ -207,6 +392,97 @@ pub async fn connect( const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1; +/// Build a `Client` connection over `transport` with Zed's full +/// agent→client handler set wired up. +/// +/// All incoming requests and notifications are forwarded to the foreground +/// dispatch queue via `dispatch_tx`, where they are handled by the +/// `handle_*` functions on a GPUI context. The returned future drives the +/// connection and completes when the transport closes; callers are expected +/// to spawn it on a background executor and hold the task for the lifetime +/// of the connection. The `connection_tx` oneshot receives the +/// `ConnectionTo` handle as soon as the builder runs its `main_fn`. +fn connect_client_future( + name: &'static str, + transport: impl agent_client_protocol::ConnectTo + 'static, + dispatch_tx: mpsc::UnboundedSender, + connection_tx: futures::channel::oneshot::Sender>, +) -> impl Future> { + // Each handler forwards its inputs onto the foreground dispatch queue. + // The SDK requires the closure to be `Send`, so we move a clone of + // `dispatch_tx` into each one. + macro_rules! on_request { + ($handler:ident) => {{ + let dispatch_tx = dispatch_tx.clone(); + async move |req, responder, _connection| { + enqueue_request(&dispatch_tx, req, responder, $handler); + Ok(()) + } + }}; + } + macro_rules! on_notification { + ($handler:ident) => {{ + let dispatch_tx = dispatch_tx.clone(); + async move |notif, connection| { + enqueue_notification(&dispatch_tx, notif, connection, $handler); + Ok(()) + } + }}; + } + + Client + .builder() + .name(name) + // --- Request handlers (agent→client) --- + .on_receive_request( + on_request!(handle_request_permission), + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + on_request!(handle_write_text_file), + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + on_request!(handle_read_text_file), + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + on_request!(handle_create_terminal), + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + on_request!(handle_kill_terminal), + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + on_request!(handle_release_terminal), + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + on_request!(handle_terminal_output), + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + on_request!(handle_wait_for_terminal_exit), + agent_client_protocol::on_receive_request!(), + ) + // --- Notification handlers (agent→client) --- + .on_receive_notification( + on_notification!(handle_session_notification), + agent_client_protocol::on_receive_notification!(), + ) + .connect_with( + transport, + move |connection: ConnectionTo| async move { + if connection_tx.send(connection).is_err() { + log::error!("failed to send ACP connection handle — receiver was dropped"); + } + // Keep the connection alive until the transport closes. + futures::future::pending::>().await + }, + ) +} + impl AcpConnection { pub async fn stdio( agent_id: AgentId, @@ -251,8 +527,7 @@ impl AcpConnection { ) }); - let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone()); - let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive(); let mut child = builder.build_std_command(Some(path.clone()), &args); child.envs(env.clone()); if let Some(cwd) = project.read_with(cx, |project, _cx| { @@ -285,30 +560,93 @@ impl AcpConnection { let client_session_list: Rc>>> = Rc::new(RefCell::new(None)); - let client = ClientDelegate { + // Set up the foreground dispatch channel for bridging Send handler + // closures to the !Send foreground thread. + let (dispatch_tx, dispatch_rx) = mpsc::unbounded::(); + + // Register this connection with the logs panel registry. The + // returned tap is opt-in: until someone subscribes to the ACP logs + // panel, `emit_*` calls below are ~free (atomic load + return). + let log_tap = cx.update(|cx| { + AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { + registry.set_active_connection(agent_id.clone(), cx) + }) + }); + + let incoming_lines = futures::io::BufReader::new(stdout).lines(); + let tapped_incoming = incoming_lines.inspect({ + let log_tap = log_tap.clone(); + move |result| match result { + Ok(line) => log_tap.emit_incoming(line), + Err(err) => { + // I/O errors on the transport are fatal for the SDK, but + // without logging them the ACP logs panel shows no trace + // of why the connection died. + log::warn!("ACP transport read error: {err}"); + } + } + }); + + let tapped_outgoing = futures::sink::unfold( + (Box::pin(stdin), log_tap.clone()), + async move |(mut writer, log_tap), line: String| { + use futures::AsyncWriteExt; + log_tap.emit_outgoing(&line); + let mut bytes = line.into_bytes(); + bytes.push(b'\n'); + writer.write_all(&bytes).await?; + Ok::<_, std::io::Error>((writer, log_tap)) + }, + ); + + let transport = Lines::new(tapped_outgoing, tapped_incoming); + + // `connect_client_future` installs the production handler set and + // hands us back both the connection-future (to run on a background + // executor) and a oneshot receiver that produces the + // `ConnectionTo` once the transport handshake is ready. + let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); + let connection_future = + connect_client_future("zed", transport, dispatch_tx.clone(), connection_tx); + let io_task = cx.background_spawn(async move { + if let Err(err) = connection_future.await { + log::error!("ACP connection error: {err}"); + } + }); + + let connection: ConnectionTo = connection_rx + .await + .context("Failed to receive ACP connection handle")?; + + // Set up the foreground dispatch loop to process work items from handlers. + let dispatch_context = ClientContext { sessions: sessions.clone(), session_list: client_session_list.clone(), - cx: cx.clone(), }; - let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, { - let foreground_executor = cx.foreground_executor().clone(); - move |fut| { - foreground_executor.spawn(fut).detach(); + let dispatch_task = cx.spawn({ + let mut dispatch_rx = dispatch_rx; + async move |cx| { + while let Some(work) = dispatch_rx.next().await { + work.run(cx, &dispatch_context); + } } }); - let io_task = cx.background_spawn(io_task); - - let stderr_task = cx.background_spawn(async move { - let mut stderr = BufReader::new(stderr); - let mut line = String::new(); - while let Ok(n) = stderr.read_line(&mut line).await - && n > 0 - { - log::warn!("agent stderr: {}", line.trim()); - line.clear(); + let stderr_task = cx.background_spawn({ + let log_tap = log_tap.clone(); + async move { + let mut stderr = BufReader::new(stderr); + let mut line = String::new(); + while let Ok(n) = stderr.read_line(&mut line).await + && n > 0 + { + let trimmed = line.trim_end_matches(['\n', '\r']); + log::warn!("agent stderr: {trimmed}"); + log_tap.emit_stderr(trimmed); + line.clear(); + } + Ok(()) } - Ok(()) }); let wait_task = cx.spawn({ @@ -321,16 +659,8 @@ impl AcpConnection { } }); - let connection = Rc::new(connection); - - cx.update(|cx| { - AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { - registry.set_active_connection(agent_id.clone(), &connection, cx) - }); - }); - - let response = connection - .initialize( + let response = into_foreground_future( + connection.send_request( acp::InitializeRequest::new(acp::ProtocolVersion::V1) .client_capabilities( acp::ClientCapabilities::new() @@ -339,7 +669,6 @@ impl AcpConnection { .write_text_file(true)) .terminal(true) .auth(acp::AuthCapabilities::new().terminal(true)) - // Experimental: Allow for rendering terminal output from the agents .meta(acp::Meta::from_iter([ ("terminal_output".into(), true.into()), ("terminal-auth".into(), true.into()), @@ -349,8 +678,9 @@ impl AcpConnection { acp::Implementation::new("zed", version) .title(release_channel.map(ToOwned::to_owned)), ), - ) - .await?; + ), + ) + .await?; if response.protocol_version < MINIMUM_SUPPORTED_VERSION { return Err(UnsupportedVersion.into()); @@ -409,6 +739,7 @@ impl AcpConnection { default_config_options, session_list, _io_task: io_task, + _dispatch_task: dispatch_task, _wait_task: wait_task, _stderr_task: stderr_task, child: Some(child), @@ -421,11 +752,12 @@ impl AcpConnection { #[cfg(any(test, feature = "test-support"))] fn new_for_test( - connection: Rc, + connection: ConnectionTo, sessions: Rc>>, agent_capabilities: acp::AgentCapabilities, agent_server_store: WeakEntity, - io_task: Task>, + io_task: Task<()>, + dispatch_task: Task<()>, _cx: &mut App, ) -> Self { Self { @@ -443,6 +775,7 @@ impl AcpConnection { child: None, session_list: None, _io_task: io_task, + _dispatch_task: dispatch_task, _wait_task: Task::ready(Ok(())), _stderr_task: Task::ready(Ok(())), } @@ -455,7 +788,7 @@ impl AcpConnection { work_dirs: PathList, title: Option, rpc_call: impl FnOnce( - Rc, + ConnectionTo, acp::SessionId, PathBuf, ) @@ -463,13 +796,12 @@ impl AcpConnection { + 'static, cx: &mut App, ) -> Task>> { - if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { - session.ref_count += 1; - if let Some(thread) = session.thread.upgrade() { - return Task::ready(Ok(thread)); - } - } - + // Check `pending_sessions` before `sessions` because the session is now + // inserted into `sessions` before the load RPC completes (so that + // notifications dispatched during history replay can find the thread). + // Concurrent loads should still wait for the in-flight task so that + // ref-counting happens in one place and the caller sees a fully loaded + // session. if let Some(pending) = self.pending_sessions.borrow_mut().get_mut(&session_id) { pending.ref_count += 1; let task = pending.task.clone(); @@ -478,6 +810,13 @@ impl AcpConnection { .spawn(async move { task.await.map_err(|err| anyhow!(err)) }); } + if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { + session.ref_count += 1; + if let Some(thread) = session.thread.upgrade() { + return Task::ready(Ok(thread)); + } + } + // TODO: remove this once ACP supports multiple working directories let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { return Task::ready(Err(anyhow!("Working directory cannot be empty"))); @@ -505,10 +844,27 @@ impl AcpConnection { ) }); + // Register the session before awaiting the RPC so that any + // `session/update` notifications that arrive during the call + // (e.g. history replay during `session/load`) can find the thread. + // Modes/models/config are filled in once the response arrives. + this.sessions.borrow_mut().insert( + session_id.clone(), + AcpSession { + thread: thread.downgrade(), + suppress_abort_err: false, + session_modes: None, + models: None, + config_options: None, + ref_count: 1, + }, + ); + let response = match rpc_call(this.connection.clone(), session_id.clone(), cwd).await { Ok(response) => response, Err(err) => { + this.sessions.borrow_mut().remove(&session_id); this.pending_sessions.borrow_mut().remove(&session_id); return Err(Arc::new(err)); } @@ -527,17 +883,23 @@ impl AcpConnection { .remove(&session_id) .map_or(1, |pending| pending.ref_count); - this.sessions.borrow_mut().insert( - session_id, - AcpSession { - thread: thread.downgrade(), - suppress_abort_err: false, - session_modes: modes, - models, - config_options: config_options.map(ConfigOptions::new), - ref_count, - }, - ); + // If `close_session` ran to completion while the load RPC was in + // flight, it will have removed both the pending entry and the + // sessions entry (and dispatched the ACP close RPC). In that case + // the thread has no live session to attach to, so fail the load + // instead of handing back an orphaned thread. + { + let mut sessions = this.sessions.borrow_mut(); + let Some(session) = sessions.get_mut(&session_id) else { + return Err(Arc::new(anyhow!( + "session was closed before load completed" + ))); + }; + session.session_modes = modes; + session.models = models; + session.config_options = config_options.map(ConfigOptions::new); + session.ref_count = ref_count; + } Ok(thread) } @@ -620,14 +982,15 @@ impl AcpConnection { let config_opts = config_options.clone(); let conn = self.connection.clone(); async move |_| { - let result = conn - .set_session_config_option(acp::SetSessionConfigOptionRequest::new( + let result = into_foreground_future(conn.send_request( + acp::SetSessionConfigOptionRequest::new( session_id, config_id_clone.clone(), default_value_id, - )) - .await - .log_err(); + ), + )) + .await + .log_err(); if result.is_none() { if let Some(initial) = initial_value { @@ -754,17 +1117,23 @@ impl AgentConnection for AcpConnection { let mcp_servers = mcp_servers_for_project(&project, cx); cx.spawn(async move |cx| { - let response = self.connection - .new_session(acp::NewSessionRequest::new(cwd.clone()).mcp_servers(mcp_servers)) - .await - .map_err(map_acp_error)?; + let response = into_foreground_future( + self.connection + .send_request(acp::NewSessionRequest::new(cwd.clone()).mcp_servers(mcp_servers)), + ) + .await + .map_err(map_acp_error)?; - let (modes, models, config_options) = config_state(response.modes, response.models, response.config_options); + let (modes, models, config_options) = + config_state(response.modes, response.models, response.config_options); if let Some(default_mode) = self.default_mode.clone() { if let Some(modes) = modes.as_ref() { let mut modes_ref = modes.borrow_mut(); - let has_mode = modes_ref.available_modes.iter().any(|mode| mode.id == default_mode); + let has_mode = modes_ref + .available_modes + .iter() + .any(|mode| mode.id == default_mode); if has_mode { let initial_mode_id = modes_ref.current_mode_id.clone(); @@ -775,14 +1144,21 @@ impl AgentConnection for AcpConnection { let modes = modes.clone(); let conn = self.connection.clone(); async move |_| { - let result = conn.set_session_mode(acp::SetSessionModeRequest::new(session_id, default_mode)) - .await.log_err(); + let result = into_foreground_future( + conn.send_request(acp::SetSessionModeRequest::new( + session_id, + default_mode, + )), + ) + .await + .log_err(); if result.is_none() { modes.borrow_mut().current_mode_id = initial_mode_id; } } - }).detach(); + }) + .detach(); modes_ref.current_mode_id = default_mode; } else { @@ -803,7 +1179,10 @@ impl AgentConnection for AcpConnection { if let Some(default_model) = self.default_model.clone() { if let Some(models) = models.as_ref() { let mut models_ref = models.borrow_mut(); - let has_model = models_ref.available_models.iter().any(|model| model.model_id == default_model); + let has_model = models_ref + .available_models + .iter() + .any(|model| model.model_id == default_model); if has_model { let initial_model_id = models_ref.current_model_id.clone(); @@ -814,14 +1193,21 @@ impl AgentConnection for AcpConnection { let models = models.clone(); let conn = self.connection.clone(); async move |_| { - let result = conn.set_session_model(acp::SetSessionModelRequest::new(session_id, default_model)) - .await.log_err(); + let result = into_foreground_future( + conn.send_request(acp::SetSessionModelRequest::new( + session_id, + default_model, + )), + ) + .await + .log_err(); if result.is_none() { models.borrow_mut().current_model_id = initial_model_id; } } - }).detach(); + }) + .detach(); models_ref.current_model_id = default_model; } else { @@ -854,7 +1240,9 @@ impl AgentConnection for AcpConnection { action_log, response.session_id.clone(), // ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically. - watch::Receiver::constant(self.agent_capabilities.prompt_capabilities.clone()), + watch::Receiver::constant( + self.agent_capabilities.prompt_capabilities.clone(), + ), cx, ) }); @@ -908,12 +1296,14 @@ impl AgentConnection for AcpConnection { title, move |connection, session_id, cwd| { Box::pin(async move { - let response = connection - .load_session( - acp::LoadSessionRequest::new(session_id, cwd).mcp_servers(mcp_servers), - ) - .await - .map_err(map_acp_error)?; + let response = into_foreground_future( + connection.send_request( + acp::LoadSessionRequest::new(session_id.clone(), cwd) + .mcp_servers(mcp_servers), + ), + ) + .await + .map_err(map_acp_error)?; Ok(SessionConfigResponse { modes: response.modes, models: response.models, @@ -952,13 +1342,14 @@ impl AgentConnection for AcpConnection { title, move |connection, session_id, cwd| { Box::pin(async move { - let response = connection - .resume_session( - acp::ResumeSessionRequest::new(session_id, cwd) + let response = into_foreground_future( + connection.send_request( + acp::ResumeSessionRequest::new(session_id.clone(), cwd) .mcp_servers(mcp_servers), - ) - .await - .map_err(map_acp_error)?; + ), + ) + .await + .map_err(map_acp_error)?; Ok(SessionConfigResponse { modes: response.modes, models: response.models, @@ -985,12 +1376,45 @@ impl AgentConnection for AcpConnection { )))); } + // If a load is still in flight, decrement its ref count. The pending + // entry is the source of truth for how many handles exist during a + // load, so we must tick it down here as well as the `sessions` entry + // that was pre-registered to receive history-replay notifications. + // Only once the pending ref count hits zero do we actually close the + // session; the load task will observe the missing sessions entry and + // fail with "session was closed before load completed". + let pending_ref_count = { + let mut pending_sessions = self.pending_sessions.borrow_mut(); + pending_sessions.get_mut(session_id).map(|pending| { + pending.ref_count = pending.ref_count.saturating_sub(1); + pending.ref_count + }) + }; + match pending_ref_count { + Some(0) => { + self.pending_sessions.borrow_mut().remove(session_id); + self.sessions.borrow_mut().remove(session_id); + + let conn = self.connection.clone(); + let session_id = session_id.clone(); + return cx.foreground_executor().spawn(async move { + into_foreground_future( + conn.send_request(acp::CloseSessionRequest::new(session_id)), + ) + .await?; + Ok(()) + }); + } + Some(_) => return Task::ready(Ok(())), + None => {} + } + let mut sessions = self.sessions.borrow_mut(); let Some(session) = sessions.get_mut(session_id) else { return Task::ready(Ok(())); }; - session.ref_count -= 1; + session.ref_count = session.ref_count.saturating_sub(1); if session.ref_count > 0 { return Task::ready(Ok(())); } @@ -1001,8 +1425,10 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let session_id = session_id.clone(); cx.foreground_executor().spawn(async move { - conn.close_session(acp::CloseSessionRequest::new(session_id)) - .await?; + into_foreground_future( + conn.send_request(acp::CloseSessionRequest::new(session_id.clone())), + ) + .await?; Ok(()) }) } @@ -1051,7 +1477,7 @@ impl AgentConnection for AcpConnection { fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { let conn = self.connection.clone(); cx.foreground_executor().spawn(async move { - conn.authenticate(acp::AuthenticateRequest::new(method_id)) + into_foreground_future(conn.send_request(acp::AuthenticateRequest::new(method_id))) .await?; Ok(()) }) @@ -1067,7 +1493,7 @@ impl AgentConnection for AcpConnection { let sessions = self.sessions.clone(); let session_id = params.session_id.clone(); cx.foreground_executor().spawn(async move { - let result = conn.prompt(params).await; + let result = into_foreground_future(conn.send_request(params)).await; let mut suppress_abort_err = false; @@ -1118,15 +1544,12 @@ impl AgentConnection for AcpConnection { }) } - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { + fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { session.suppress_abort_err = true; } - let conn = self.connection.clone(); let params = acp::CancelNotification::new(session_id.clone()); - cx.foreground_executor() - .spawn(async move { conn.cancel(params).await }) - .detach(); + self.connection.send_notification(params).log_err(); } fn session_modes( @@ -1486,73 +1909,6 @@ pub mod test_support { } } - struct FakeAcpAgent { - load_session_count: Arc, - close_session_count: Arc, - fail_next_prompt: Arc, - } - - #[async_trait::async_trait(?Send)] - impl acp::Agent for FakeAcpAgent { - async fn initialize( - &self, - args: acp::InitializeRequest, - ) -> acp::Result { - Ok( - acp::InitializeResponse::new(args.protocol_version).agent_capabilities( - acp::AgentCapabilities::default() - .load_session(true) - .session_capabilities( - acp::SessionCapabilities::default() - .close(acp::SessionCloseCapabilities::new()), - ), - ), - ) - } - - async fn authenticate( - &self, - _: acp::AuthenticateRequest, - ) -> acp::Result { - Ok(Default::default()) - } - - async fn new_session( - &self, - _: acp::NewSessionRequest, - ) -> acp::Result { - Ok(acp::NewSessionResponse::new(acp::SessionId::new("unused"))) - } - - async fn prompt(&self, _: acp::PromptRequest) -> acp::Result { - if self.fail_next_prompt.swap(false, Ordering::SeqCst) { - Err(acp::ErrorCode::InternalError.into()) - } else { - Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) - } - } - - async fn cancel(&self, _: acp::CancelNotification) -> acp::Result<()> { - Ok(()) - } - - async fn load_session( - &self, - _: acp::LoadSessionRequest, - ) -> acp::Result { - self.load_session_count.fetch_add(1, Ordering::SeqCst); - Ok(acp::LoadSessionResponse::new()) - } - - async fn close_session( - &self, - _: acp::CloseSessionRequest, - ) -> acp::Result { - self.close_session_count.fetch_add(1, Ordering::SeqCst); - Ok(acp::CloseSessionResponse::new()) - } - } - async fn build_fake_acp_connection( project: Entity, load_session_count: Arc, @@ -1560,63 +1916,135 @@ pub mod test_support { fail_next_prompt: Arc, cx: &mut AsyncApp, ) -> Result { - let (c2a_writer, c2a_reader) = async_pipe::pipe(); - let (a2c_writer, a2c_reader) = async_pipe::pipe(); + let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); let sessions: Rc>> = Rc::new(RefCell::new(HashMap::default())); - let session_list_container: Rc>>> = + let client_session_list: Rc>>> = Rc::new(RefCell::new(None)); - let foreground = cx.foreground_executor().clone(); - - let client_delegate = ClientDelegate { - sessions: sessions.clone(), - session_list: session_list_container, - cx: cx.clone(), - }; + let agent_future = Agent + .builder() + .name("fake-agent") + .on_receive_request( + async move |req: acp::InitializeRequest, responder, _cx| { + responder.respond( + acp::InitializeResponse::new(req.protocol_version).agent_capabilities( + acp::AgentCapabilities::default() + .load_session(true) + .session_capabilities( + acp::SessionCapabilities::default() + .close(acp::SessionCloseCapabilities::new()), + ), + ), + ) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_req: acp::AuthenticateRequest, responder, _cx| { + responder.respond(Default::default()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_req: acp::NewSessionRequest, responder, _cx| { + responder.respond(acp::NewSessionResponse::new(acp::SessionId::new("unused"))) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let fail_next_prompt = fail_next_prompt.clone(); + async move |_req: acp::PromptRequest, responder, _cx| { + if fail_next_prompt.swap(false, Ordering::SeqCst) { + responder.respond_with_error(acp::ErrorCode::InternalError.into()) + } else { + responder.respond(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let load_session_count = load_session_count.clone(); + async move |_req: acp::LoadSessionRequest, responder, _cx| { + load_session_count.fetch_add(1, Ordering::SeqCst); + responder.respond(acp::LoadSessionResponse::new()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let close_session_count = close_session_count.clone(); + async move |_req: acp::CloseSessionRequest, responder, _cx| { + close_session_count.fetch_add(1, Ordering::SeqCst); + responder.respond(acp::CloseSessionResponse::new()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + async move |_notif: acp::CancelNotification, _cx| Ok(()), + agent_client_protocol::on_receive_notification!(), + ) + .connect_to(agent_transport); - let (client_conn, client_io_task) = - acp::ClientSideConnection::new(client_delegate, c2a_writer, a2c_reader, { - let foreground = foreground.clone(); - move |fut| { - foreground.spawn(fut).detach(); - } - }); + let agent_io_task = cx.background_spawn(agent_future); - let fake_agent = FakeAcpAgent { - load_session_count: load_session_count.clone(), - close_session_count: close_session_count.clone(), - fail_next_prompt, - }; + // Wire the production handler set into the fake client so inbound + // requests/notifications from the fake agent are dispatched the + // same way the real `stdio` path does. + let (dispatch_tx, dispatch_rx) = mpsc::unbounded::(); - let (_, agent_io_task) = - acp::AgentSideConnection::new(fake_agent, a2c_writer, c2a_reader, { - let foreground = foreground.clone(); - move |fut| { - foreground.spawn(fut).detach(); - } - }); + let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); + let client_future = connect_client_future( + "zed-test", + client_transport, + dispatch_tx.clone(), + connection_tx, + ); + let client_io_task = cx.background_spawn(async move { + client_future.await.ok(); + }); - let client_io_task = cx.background_spawn(client_io_task); - let agent_io_task = cx.background_spawn(agent_io_task); + let client_conn: ConnectionTo = connection_rx + .await + .context("failed to receive fake ACP connection handle")?; - let response = client_conn - .initialize(acp::InitializeRequest::new(acp::ProtocolVersion::V1)) - .await?; + let response = into_foreground_future( + client_conn.send_request(acp::InitializeRequest::new(acp::ProtocolVersion::V1)), + ) + .await?; let agent_capabilities = response.agent_capabilities; + let dispatch_context = ClientContext { + sessions: sessions.clone(), + session_list: client_session_list.clone(), + }; + let dispatch_task = cx.spawn({ + let mut dispatch_rx = dispatch_rx; + async move |cx| { + while let Some(work) = dispatch_rx.next().await { + work.run(cx, &dispatch_context); + } + } + }); + let agent_server_store = project.read_with(cx, |project, _| project.agent_server_store().downgrade()); let connection = cx.update(|cx| { AcpConnection::new_for_test( - Rc::new(client_conn), + client_conn, sessions, agent_capabilities, agent_server_store, client_io_task, + dispatch_task, cx, ) }); @@ -1788,81 +2216,21 @@ mod tests { assert_eq!(task.label, "Login"); } - struct FakeAcpAgent { - load_session_count: Arc, - close_session_count: Arc, - } - - #[async_trait::async_trait(?Send)] - impl acp::Agent for FakeAcpAgent { - async fn initialize( - &self, - args: acp::InitializeRequest, - ) -> acp::Result { - Ok( - acp::InitializeResponse::new(args.protocol_version).agent_capabilities( - acp::AgentCapabilities::default() - .load_session(true) - .session_capabilities( - acp::SessionCapabilities::default() - .close(acp::SessionCloseCapabilities::new()), - ), - ), - ) - } - - async fn authenticate( - &self, - _: acp::AuthenticateRequest, - ) -> acp::Result { - Ok(Default::default()) - } - - async fn new_session( - &self, - _: acp::NewSessionRequest, - ) -> acp::Result { - Ok(acp::NewSessionResponse::new(acp::SessionId::new("unused"))) - } - - async fn prompt(&self, _: acp::PromptRequest) -> acp::Result { - Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) - } - - async fn cancel(&self, _: acp::CancelNotification) -> acp::Result<()> { - Ok(()) - } - - async fn load_session( - &self, - _: acp::LoadSessionRequest, - ) -> acp::Result { - self.load_session_count.fetch_add(1, Ordering::SeqCst); - Ok(acp::LoadSessionResponse::new()) - } - - async fn close_session( - &self, - _: acp::CloseSessionRequest, - ) -> acp::Result { - self.close_session_count.fetch_add(1, Ordering::SeqCst); - Ok(acp::CloseSessionResponse::new()) - } - } - - async fn connect_fake_agent( - cx: &mut gpui::TestAppContext, - ) -> ( - Rc, - Entity, - Arc, - Arc, - Task>, - ) { - cx.update(|cx| { - let store = settings::SettingsStore::test(cx); - cx.set_global(store); - }); + async fn connect_fake_agent( + cx: &mut gpui::TestAppContext, + ) -> ( + Rc, + Entity, + Arc, + Arc, + Arc>>, + Arc>>>, + Task>, + ) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + }); let fs = fs::FakeFs::new(cx.executor()); fs.insert_tree("/", serde_json::json!({ "a": {} })).await; @@ -1870,64 +2238,170 @@ mod tests { let load_count = Arc::new(AtomicUsize::new(0)); let close_count = Arc::new(AtomicUsize::new(0)); + let load_session_updates: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let load_session_gate: Arc>>> = + Arc::new(std::sync::Mutex::new(None)); - let (c2a_writer, c2a_reader) = async_pipe::pipe(); - let (a2c_writer, a2c_reader) = async_pipe::pipe(); + let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); let sessions: Rc>> = Rc::new(RefCell::new(HashMap::default())); - let session_list_container: Rc>>> = + let client_session_list: Rc>>> = Rc::new(RefCell::new(None)); - let foreground = cx.foreground_executor().clone(); + // Build the fake agent side. It handles the requests issued by + // `AcpConnection` during the test and tracks load/close counts. + let agent_future = Agent + .builder() + .name("fake-agent") + .on_receive_request( + async move |req: acp::InitializeRequest, responder, _cx| { + responder.respond( + acp::InitializeResponse::new(req.protocol_version).agent_capabilities( + acp::AgentCapabilities::default() + .load_session(true) + .session_capabilities( + acp::SessionCapabilities::default() + .close(acp::SessionCloseCapabilities::new()), + ), + ), + ) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_req: acp::AuthenticateRequest, responder, _cx| { + responder.respond(Default::default()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_req: acp::NewSessionRequest, responder, _cx| { + responder.respond(acp::NewSessionResponse::new(acp::SessionId::new("unused"))) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_req: acp::PromptRequest, responder, _cx| { + responder.respond(acp::PromptResponse::new(acp::StopReason::EndTurn)) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let load_count = load_count.clone(); + let load_session_updates = load_session_updates.clone(); + let load_session_gate = load_session_gate.clone(); + async move |req: acp::LoadSessionRequest, responder, cx| { + load_count.fetch_add(1, Ordering::SeqCst); + + // Simulate spec-compliant history replay: send + // notifications to the client before responding to the + // load request. + let updates = std::mem::take( + &mut *load_session_updates + .lock() + .expect("load_session_updates mutex poisoned"), + ); + for update in updates { + cx.send_notification(acp::SessionNotification::new( + req.session_id.clone(), + update, + ))?; + } - let client_delegate = ClientDelegate { - sessions: sessions.clone(), - session_list: session_list_container, - cx: cx.to_async(), - }; + // If a gate was installed, park on it before responding + // so tests can interleave other work (e.g. + // `close_session`) with an in-flight load. + let gate = load_session_gate + .lock() + .expect("load_session_gate mutex poisoned") + .take(); + if let Some(gate) = gate { + gate.recv().await.ok(); + } - let (client_conn, client_io_task) = - acp::ClientSideConnection::new(client_delegate, c2a_writer, a2c_reader, { - let foreground = foreground.clone(); - move |fut| { - foreground.spawn(fut).detach(); - } - }); + responder.respond(acp::LoadSessionResponse::new()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let close_count = close_count.clone(); + async move |_req: acp::CloseSessionRequest, responder, _cx| { + close_count.fetch_add(1, Ordering::SeqCst); + responder.respond(acp::CloseSessionResponse::new()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + async move |_notif: acp::CancelNotification, _cx| Ok(()), + agent_client_protocol::on_receive_notification!(), + ) + .connect_to(agent_transport); - let fake_agent = FakeAcpAgent { - load_session_count: load_count.clone(), - close_session_count: close_count.clone(), - }; + let agent_io_task = cx.background_spawn(agent_future); - let (_, agent_io_task) = - acp::AgentSideConnection::new(fake_agent, a2c_writer, c2a_reader, { - let foreground = foreground.clone(); - move |fut| { - foreground.spawn(fut).detach(); - } - }); + // Wire the production handler set into the fake client so inbound + // requests/notifications from the fake agent reach the same + // dispatcher that the real `stdio` path uses. + let (dispatch_tx, dispatch_rx) = mpsc::unbounded::(); - let client_io_task = cx.background_spawn(client_io_task); - let agent_io_task = cx.background_spawn(agent_io_task); + let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); + let client_future = connect_client_future( + "zed-test", + client_transport, + dispatch_tx.clone(), + connection_tx, + ); + let client_io_task = cx.background_spawn(async move { + client_future.await.ok(); + }); - let response = client_conn - .initialize(acp::InitializeRequest::new(acp::ProtocolVersion::V1)) + let client_conn: ConnectionTo = connection_rx .await - .expect("failed to initialize ACP connection"); + .expect("failed to receive ACP connection handle"); + + let response = into_foreground_future( + client_conn.send_request(acp::InitializeRequest::new(acp::ProtocolVersion::V1)), + ) + .await + .expect("failed to initialize ACP connection"); let agent_capabilities = response.agent_capabilities; + let dispatch_context = ClientContext { + sessions: sessions.clone(), + session_list: client_session_list.clone(), + }; + // `TestAppContext::spawn` hands out an `AsyncApp` by value, whereas the + // production path uses `Context::spawn` which hands out `&mut AsyncApp`. + // Bind the value-form to a local and take `&mut` of it to reuse the + // same dispatch loop shape. + let dispatch_task = cx.spawn({ + let mut dispatch_rx = dispatch_rx; + move |cx| async move { + let mut cx = cx; + while let Some(work) = dispatch_rx.next().await { + work.run(&mut cx, &dispatch_context); + } + } + }); + let agent_server_store = project.read_with(cx, |project, _| project.agent_server_store().downgrade()); let connection = cx.update(|cx| { AcpConnection::new_for_test( - Rc::new(client_conn), + client_conn, sessions, agent_capabilities, agent_server_store, client_io_task, + dispatch_task, cx, ) }); @@ -1942,14 +2416,23 @@ mod tests { project, load_count, close_count, + load_session_updates, + load_session_gate, keep_agent_alive, ) } #[gpui::test] async fn test_loaded_sessions_keep_state_until_last_close(cx: &mut gpui::TestAppContext) { - let (connection, project, load_count, close_count, _keep_agent_alive) = - connect_fake_agent(cx).await; + let ( + connection, + project, + load_count, + close_count, + _load_session_updates, + _load_session_gate, + _keep_agent_alive, + ) = connect_fake_agent(cx).await; let session_id = acp::SessionId::new("session-1"); let work_dirs = util::path_list::PathList::new(&[std::path::Path::new("/a")]); @@ -2022,6 +2505,279 @@ mod tests { "session should be removed after final close" ); } + + // Regression test: per the ACP spec, an agent replays the entire conversation + // history as `session/update` notifications *before* responding to the + // `session/load` request. These notifications must be applied to the + // reconstructed thread, not dropped because the session hasn't been + // registered yet. + #[gpui::test] + async fn test_load_session_replays_notifications_sent_before_response( + cx: &mut gpui::TestAppContext, + ) { + let ( + connection, + project, + _load_count, + _close_count, + load_session_updates, + _load_session_gate, + _keep_agent_alive, + ) = connect_fake_agent(cx).await; + + // Queue up some history updates that the fake agent will stream to + // the client during the `load_session` call, before responding. + *load_session_updates + .lock() + .expect("load_session_updates mutex poisoned") = vec![ + acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text( + acp::TextContent::new(String::from("hello agent")), + ))), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text( + acp::TextContent::new(String::from("hi user")), + ))), + ]; + + let session_id = acp::SessionId::new("session-replay"); + let work_dirs = util::path_list::PathList::new(&[std::path::Path::new("/a")]); + + let thread = cx + .update(|cx| { + connection.clone().load_session( + session_id.clone(), + project.clone(), + work_dirs, + None, + cx, + ) + }) + .await + .expect("load_session failed"); + cx.run_until_parked(); + + let entries = thread.read_with(cx, |thread, _| { + thread + .entries() + .iter() + .map(|entry| match entry { + acp_thread::AgentThreadEntry::UserMessage(_) => "user", + acp_thread::AgentThreadEntry::AssistantMessage(_) => "assistant", + acp_thread::AgentThreadEntry::ToolCall(_) => "tool_call", + acp_thread::AgentThreadEntry::CompletedPlan(_) => "plan", + }) + .collect::>() + }); + + assert_eq!( + entries, + vec!["user", "assistant"], + "replayed notifications should be applied to the thread" + ); + } + + // Regression test: if `close_session` is issued while a `load_session` + // RPC is still in flight, the close must take effect cleanly — the load + // must fail with a recognizable error (not return an orphaned thread), + // no entry must remain in `sessions` or `pending_sessions`, and the ACP + // `close_session` RPC must be dispatched. + #[gpui::test] + async fn test_close_session_during_in_flight_load(cx: &mut gpui::TestAppContext) { + let ( + connection, + project, + load_count, + close_count, + _load_session_updates, + load_session_gate, + _keep_agent_alive, + ) = connect_fake_agent(cx).await; + + // Install a gate so the fake agent's `load_session` handler parks + // before sending its response. We'll close the session while the + // load is parked. + let (gate_tx, gate_rx) = smol::channel::bounded::<()>(1); + *load_session_gate + .lock() + .expect("load_session_gate mutex poisoned") = Some(gate_rx); + + let session_id = acp::SessionId::new("session-close-during-load"); + let work_dirs = util::path_list::PathList::new(&[std::path::Path::new("/a")]); + + let load_task = cx.update(|cx| { + connection.clone().load_session( + session_id.clone(), + project.clone(), + work_dirs, + None, + cx, + ) + }); + + // Let the load RPC reach the agent and park on the gate. + cx.run_until_parked(); + assert_eq!( + load_count.load(Ordering::SeqCst), + 1, + "load_session RPC should have been dispatched" + ); + assert!( + connection + .pending_sessions + .borrow() + .contains_key(&session_id), + "pending_sessions entry should exist while load is in flight" + ); + assert!( + connection.sessions.borrow().contains_key(&session_id), + "sessions entry should be pre-registered to receive replay notifications" + ); + + // Close the session while the load is still parked. This should take + // the pending path and dispatch the ACP close RPC. + let close_task = cx.update(|cx| connection.clone().close_session(&session_id, cx)); + + // Release the gate so the load RPC can finally respond. + gate_tx.send(()).await.expect("gate send failed"); + drop(gate_tx); + + let load_result = load_task.await; + close_task.await.expect("close failed"); + cx.run_until_parked(); + + let err = load_result.expect_err("load should fail after close-during-load"); + assert!( + err.to_string() + .contains("session was closed before load completed"), + "expected close-during-load error, got: {err}" + ); + + assert_eq!( + close_count.load(Ordering::SeqCst), + 1, + "ACP close_session should be sent exactly once" + ); + assert!( + !connection.sessions.borrow().contains_key(&session_id), + "sessions entry should be removed after close-during-load" + ); + assert!( + !connection + .pending_sessions + .borrow() + .contains_key(&session_id), + "pending_sessions entry should be removed after close-during-load" + ); + } + + // Regression test: when two concurrent `load_session` calls share a pending + // task and one of them issues `close_session` before the load RPC + // resolves, the remaining load must still succeed and the session must + // stay live. If `close_session` incorrectly short-circuits via the + // `sessions` path (removing the entry while a load is still in flight), + // the pending task will fail and both concurrent loaders will lose + // their handle. + #[gpui::test] + async fn test_close_during_load_preserves_other_concurrent_loader( + cx: &mut gpui::TestAppContext, + ) { + let ( + connection, + project, + load_count, + close_count, + _load_session_updates, + load_session_gate, + _keep_agent_alive, + ) = connect_fake_agent(cx).await; + + let (gate_tx, gate_rx) = smol::channel::bounded::<()>(1); + *load_session_gate + .lock() + .expect("load_session_gate mutex poisoned") = Some(gate_rx); + + let session_id = acp::SessionId::new("session-concurrent-close"); + let work_dirs = util::path_list::PathList::new(&[std::path::Path::new("/a")]); + + // Kick off two concurrent loads; the second must join the first's pending + // task rather than issuing a second RPC. + let first_load = cx.update(|cx| { + connection.clone().load_session( + session_id.clone(), + project.clone(), + work_dirs.clone(), + None, + cx, + ) + }); + let second_load = cx.update(|cx| { + connection.clone().load_session( + session_id.clone(), + project.clone(), + work_dirs.clone(), + None, + cx, + ) + }); + + cx.run_until_parked(); + assert_eq!( + load_count.load(Ordering::SeqCst), + 1, + "load_session RPC should only be dispatched once for concurrent loads" + ); + + // Close one of the two handles while the shared load is still parked. + // Because a second loader still holds a pending ref, this should be a + // no-op on the wire. + cx.update(|cx| connection.clone().close_session(&session_id, cx)) + .await + .expect("close during load failed"); + assert_eq!( + close_count.load(Ordering::SeqCst), + 0, + "close_session RPC must not be dispatched while another load handle remains" + ); + + // Release the gate so the load RPC can finally respond. + gate_tx.send(()).await.expect("gate send failed"); + drop(gate_tx); + + let first_thread = first_load.await.expect("first load should still succeed"); + let second_thread = second_load.await.expect("second load should still succeed"); + cx.run_until_parked(); + + assert_eq!( + first_thread.entity_id(), + second_thread.entity_id(), + "concurrent loads should share one AcpThread" + ); + assert!( + connection.sessions.borrow().contains_key(&session_id), + "session must remain tracked while a load handle is still outstanding" + ); + assert!( + !connection + .pending_sessions + .borrow() + .contains_key(&session_id), + "pending_sessions entry should be cleared once the load resolves" + ); + + // Final close drops ref_count to 0 and dispatches the ACP close RPC. + cx.update(|cx| connection.clone().close_session(&session_id, cx)) + .await + .expect("final close failed"); + cx.run_until_parked(); + assert_eq!( + close_count.load(Ordering::SeqCst), + 1, + "close_session RPC should fire exactly once when the last handle is released" + ); + assert!( + !connection.sessions.borrow().contains_key(&session_id), + "session should be removed after final close" + ); + } } fn mcp_servers_for_project(project: &Entity, cx: &App) -> Vec { @@ -2091,7 +2847,7 @@ fn config_state( struct AcpSessionModes { session_id: acp::SessionId, - connection: Rc, + connection: ConnectionTo, state: Rc>, } @@ -2115,9 +2871,10 @@ impl acp_thread::AgentSessionModes for AcpSessionModes { }; let state = self.state.clone(); cx.foreground_executor().spawn(async move { - let result = connection - .set_session_mode(acp::SetSessionModeRequest::new(session_id, mode_id)) - .await; + let result = into_foreground_future( + connection.send_request(acp::SetSessionModeRequest::new(session_id, mode_id)), + ) + .await; if result.is_err() { state.borrow_mut().current_mode_id = old_mode_id; @@ -2132,14 +2889,14 @@ impl acp_thread::AgentSessionModes for AcpSessionModes { struct AcpModelSelector { session_id: acp::SessionId, - connection: Rc, + connection: ConnectionTo, state: Rc>, } impl AcpModelSelector { fn new( session_id: acp::SessionId, - connection: Rc, + connection: ConnectionTo, state: Rc>, ) -> Self { Self { @@ -2174,9 +2931,10 @@ impl acp_thread::AgentModelSelector for AcpModelSelector { }; let state = self.state.clone(); cx.foreground_executor().spawn(async move { - let result = connection - .set_session_model(acp::SetSessionModelRequest::new(session_id, model_id)) - .await; + let result = into_foreground_future( + connection.send_request(acp::SetSessionModelRequest::new(session_id, model_id)), + ) + .await; if result.is_err() { state.borrow_mut().current_model_id = old_model_id; @@ -2204,7 +2962,7 @@ impl acp_thread::AgentModelSelector for AcpModelSelector { struct AcpSessionConfigOptions { session_id: acp::SessionId, - connection: Rc, + connection: ConnectionTo, state: Rc>>, watch_tx: Rc>>, watch_rx: watch::Receiver<()>, @@ -2228,11 +2986,10 @@ impl acp_thread::AgentSessionConfigOptions for AcpSessionConfigOptions { let watch_tx = self.watch_tx.clone(); cx.foreground_executor().spawn(async move { - let response = connection - .set_session_config_option(acp::SetSessionConfigOptionRequest::new( - session_id, config_id, value, - )) - .await?; + let response = into_foreground_future(connection.send_request( + acp::SetSessionConfigOptionRequest::new(session_id, config_id, value), + )) + .await?; *state.borrow_mut() = response.config_options.clone(); watch_tx.borrow_mut().send(()).ok(); @@ -2245,133 +3002,204 @@ impl acp_thread::AgentSessionConfigOptions for AcpSessionConfigOptions { } } -struct ClientDelegate { - sessions: Rc>>, - session_list: Rc>>>, - cx: AsyncApp, +// --------------------------------------------------------------------------- +// Handler functions dispatched from background handler closures to the +// foreground thread via the ForegroundWork channel. +// --------------------------------------------------------------------------- + +fn session_thread( + ctx: &ClientContext, + session_id: &acp::SessionId, +) -> Result, acp::Error> { + let sessions = ctx.sessions.borrow(); + sessions + .get(session_id) + .map(|session| session.thread.clone()) + .ok_or_else(|| acp::Error::internal_error().data(format!("unknown session: {session_id}"))) } -#[async_trait::async_trait(?Send)] -impl acp::Client for ClientDelegate { - async fn request_permission( - &self, - arguments: acp::RequestPermissionRequest, - ) -> Result { - let thread; - { - let sessions_ref = self.sessions.borrow(); - let session = sessions_ref - .get(&arguments.session_id) - .context("Failed to get session")?; - thread = session.thread.clone(); - } - - let cx = &mut self.cx.clone(); - - let task = thread.update(cx, |thread, cx| { - thread.request_tool_call_authorization( - arguments.tool_call, - acp_thread::PermissionOptions::Flat(arguments.options), - cx, - ) - })??; - - let outcome = task.await; +fn respond_err(responder: Responder, err: acp::Error) { + // Log the actual error we're returning — otherwise agents that hit an + // error path (e.g. unknown session) would see only the generic internal + // error returned over the wire with no trace of why on the client side. + log::warn!( + "Responding to ACP request `{method}` with error: {err:?}", + method = responder.method() + ); + responder.respond_with_error(err).log_err(); +} - Ok(acp::RequestPermissionResponse::new(outcome.into())) - } +fn handle_request_permission( + args: acp::RequestPermissionRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let thread = match session_thread(ctx, &args.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; - async fn write_text_file( - &self, - arguments: acp::WriteTextFileRequest, - ) -> Result { - let cx = &mut self.cx.clone(); - let task = self - .session_thread(&arguments.session_id)? - .update(cx, |thread, cx| { - thread.write_text_file(arguments.path, arguments.content, cx) - })?; + cx.spawn(async move |cx| { + let result: Result<_, acp::Error> = async { + let task = thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization( + args.tool_call, + acp_thread::PermissionOptions::Flat(args.options), + cx, + ) + }) + .flatten_acp()?; + Ok(task.await) + } + .await; - task.await?; + match result { + Ok(outcome) => { + responder + .respond(acp::RequestPermissionResponse::new(outcome.into())) + .log_err(); + } + Err(e) => respond_err(responder, e), + } + }) + .detach(); +} - Ok(Default::default()) - } +fn handle_write_text_file( + args: acp::WriteTextFileRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let thread = match session_thread(ctx, &args.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; - async fn read_text_file( - &self, - arguments: acp::ReadTextFileRequest, - ) -> Result { - let task = self.session_thread(&arguments.session_id)?.update( - &mut self.cx.clone(), - |thread, cx| { - thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx) - }, - )?; + cx.spawn(async move |cx| { + let result: Result<_, acp::Error> = async { + thread + .update(cx, |thread, cx| { + thread.write_text_file(args.path, args.content, cx) + }) + .map_err(acp::Error::from)? + .await?; + Ok(()) + } + .await; - let content = task.await?; + match result { + Ok(()) => { + responder + .respond(acp::WriteTextFileResponse::default()) + .log_err(); + } + Err(e) => respond_err(responder, e), + } + }) + .detach(); +} - Ok(acp::ReadTextFileResponse::new(content)) - } +fn handle_read_text_file( + args: acp::ReadTextFileRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let thread = match session_thread(ctx, &args.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; - async fn session_notification( - &self, - notification: acp::SessionNotification, - ) -> Result<(), acp::Error> { - let (thread, session_modes, session_config_options) = { - let sessions = self.sessions.borrow(); - let session = sessions - .get(¬ification.session_id) - .context("Failed to get session")?; - ( - session.thread.clone(), - session.session_modes.clone(), - session.config_options.clone(), - ) - }; + cx.spawn(async move |cx| { + let result: Result<_, acp::Error> = async { + thread + .update(cx, |thread, cx| { + thread.read_text_file(args.path, args.line, args.limit, false, cx) + }) + .map_err(acp::Error::from)? + .await + } + .await; - if let acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate { - current_mode_id, - .. - }) = ¬ification.update - { - if let Some(session_modes) = &session_modes { - session_modes.borrow_mut().current_mode_id = current_mode_id.clone(); + match result { + Ok(content) => { + responder + .respond(acp::ReadTextFileResponse::new(content)) + .log_err(); } + Err(e) => respond_err(responder, e), } + }) + .detach(); +} - if let acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate { - config_options, - .. - }) = ¬ification.update - { - if let Some(opts) = &session_config_options { - *opts.config_options.borrow_mut() = config_options.clone(); - opts.tx.borrow_mut().send(()).ok(); - } +fn handle_session_notification( + notification: acp::SessionNotification, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + // Extract everything we need from the session while briefly borrowing. + let (thread, session_modes, config_opts_data) = { + let sessions = ctx.sessions.borrow(); + let Some(session) = sessions.get(¬ification.session_id) else { + log::warn!( + "Received session notification for unknown session: {:?}", + notification.session_id + ); + return; + }; + ( + session.thread.clone(), + session.session_modes.clone(), + session + .config_options + .as_ref() + .map(|opts| (opts.config_options.clone(), opts.tx.clone())), + ) + }; + // Borrow is dropped here. + + // Apply mode/config/session_list updates without holding the borrow. + if let acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate { + current_mode_id, .. + }) = ¬ification.update + { + if let Some(session_modes) = &session_modes { + session_modes.borrow_mut().current_mode_id = current_mode_id.clone(); } + } - if let acp::SessionUpdate::SessionInfoUpdate(info_update) = ¬ification.update - && let Some(session_list) = self.session_list.borrow().as_ref() - { - session_list.send_info_update(notification.session_id.clone(), info_update.clone()); + if let acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate { + config_options, .. + }) = ¬ification.update + { + if let Some((config_opts_cell, tx_cell)) = &config_opts_data { + *config_opts_cell.borrow_mut() = config_options.clone(); + tx_cell.borrow_mut().send(()).ok(); } + } - // Clone so we can inspect meta both before and after handing off to the thread - let update_clone = notification.update.clone(); + if let acp::SessionUpdate::SessionInfoUpdate(info_update) = ¬ification.update + && let Some(session_list) = ctx.session_list.borrow().as_ref() + { + session_list.send_info_update(notification.session_id.clone(), info_update.clone()); + } - // Pre-handle: if a ToolCall carries terminal_info, create/register a display-only terminal. - if let acp::SessionUpdate::ToolCall(tc) = &update_clone { - if let Some(meta) = &tc.meta { - if let Some(terminal_info) = meta.get("terminal_info") { - if let Some(id_str) = terminal_info.get("terminal_id").and_then(|v| v.as_str()) - { - let terminal_id = acp::TerminalId::new(id_str); - let cwd = terminal_info - .get("cwd") - .and_then(|v| v.as_str().map(PathBuf::from)); + // Pre-handle: if a ToolCall carries terminal_info, create/register a display-only terminal. + if let acp::SessionUpdate::ToolCall(tc) = ¬ification.update { + if let Some(meta) = &tc.meta { + if let Some(terminal_info) = meta.get("terminal_info") { + if let Some(id_str) = terminal_info.get("terminal_id").and_then(|v| v.as_str()) { + let terminal_id = acp::TerminalId::new(id_str); + let cwd = terminal_info + .get("cwd") + .and_then(|v| v.as_str().map(PathBuf::from)); - // Create a minimal display-only lower-level terminal and register it. - let _ = thread.update(&mut self.cx.clone(), |thread, cx| { + thread + .update(cx, |thread, cx| { let builder = TerminalBuilder::new_display_only( CursorShape::default(), AlternateScroll::On, @@ -2392,53 +3220,64 @@ impl acp::Client for ClientDelegate { cx, ); anyhow::Ok(()) - }); - } + }) + .log_err(); } } } + } - // Forward the update to the acp_thread as usual. - thread.update(&mut self.cx.clone(), |thread, cx| { + // Forward the update to the acp_thread as usual. + if let Err(err) = thread + .update(cx, |thread, cx| { thread.handle_session_update(notification.update.clone(), cx) - })??; - - // Post-handle: stream terminal output/exit if present on ToolCallUpdate meta. - if let acp::SessionUpdate::ToolCallUpdate(tcu) = &update_clone { - if let Some(meta) = &tcu.meta { - if let Some(term_out) = meta.get("terminal_output") { - if let Some(id_str) = term_out.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId::new(id_str); - if let Some(s) = term_out.get("data").and_then(|v| v.as_str()) { - let data = s.as_bytes().to_vec(); - let _ = thread.update(&mut self.cx.clone(), |thread, cx| { + }) + .flatten_acp() + { + log::error!( + "Failed to handle session update for {:?}: {err:?}", + notification.session_id + ); + } + + // Post-handle: stream terminal output/exit if present on ToolCallUpdate meta. + if let acp::SessionUpdate::ToolCallUpdate(tcu) = ¬ification.update { + if let Some(meta) = &tcu.meta { + if let Some(term_out) = meta.get("terminal_output") { + if let Some(id_str) = term_out.get("terminal_id").and_then(|v| v.as_str()) { + let terminal_id = acp::TerminalId::new(id_str); + if let Some(s) = term_out.get("data").and_then(|v| v.as_str()) { + let data = s.as_bytes().to_vec(); + thread + .update(cx, |thread, cx| { thread.on_terminal_provider_event( TerminalProviderEvent::Output { terminal_id, data }, cx, ); - }); - } + }) + .log_err(); } } + } - // terminal_exit - if let Some(term_exit) = meta.get("terminal_exit") { - if let Some(id_str) = term_exit.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId::new(id_str); - let status = acp::TerminalExitStatus::new() - .exit_code( - term_exit - .get("exit_code") - .and_then(|v| v.as_u64()) - .map(|i| i as u32), - ) - .signal( - term_exit - .get("signal") - .and_then(|v| v.as_str().map(|s| s.to_string())), - ); + if let Some(term_exit) = meta.get("terminal_exit") { + if let Some(id_str) = term_exit.get("terminal_id").and_then(|v| v.as_str()) { + let terminal_id = acp::TerminalId::new(id_str); + let status = acp::TerminalExitStatus::new() + .exit_code( + term_exit + .get("exit_code") + .and_then(|v| v.as_u64()) + .map(|i| i as u32), + ) + .signal( + term_exit + .get("signal") + .and_then(|v| v.as_str().map(|s| s.to_string())), + ); - let _ = thread.update(&mut self.cx.clone(), |thread, cx| { + thread + .update(cx, |thread, cx| { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id, @@ -2446,118 +3285,183 @@ impl acp::Client for ClientDelegate { }, cx, ); - }); - } + }) + .log_err(); } } } - - Ok(()) } +} - async fn create_terminal( - &self, - args: acp::CreateTerminalRequest, - ) -> Result { - let thread = self.session_thread(&args.session_id)?; - let project = thread.read_with(&self.cx, |thread, _cx| thread.project().clone())?; - - let terminal_entity = acp_thread::create_terminal_entity( - args.command.clone(), - &args.args, - args.env - .into_iter() - .map(|env| (env.name, env.value)) - .collect(), - args.cwd.clone(), - &project, - &mut self.cx.clone(), - ) - .await?; +fn handle_create_terminal( + args: acp::CreateTerminalRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let thread = match session_thread(ctx, &args.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; + let project = match thread + .read_with(cx, |thread, _cx| thread.project().clone()) + .map_err(acp::Error::from) + { + Ok(p) => p, + Err(e) => return respond_err(responder, e), + }; - // Register with renderer - let terminal_entity = thread.update(&mut self.cx.clone(), |thread, cx| { - thread.register_terminal_created( - acp::TerminalId::new(uuid::Uuid::new_v4().to_string()), - format!("{} {}", args.command, args.args.join(" ")), + cx.spawn(async move |cx| { + let result: Result<_, acp::Error> = async { + let terminal_entity = acp_thread::create_terminal_entity( + args.command.clone(), + &args.args, + args.env + .into_iter() + .map(|env| (env.name, env.value)) + .collect(), args.cwd.clone(), - args.output_byte_limit, - terminal_entity, + &project, cx, ) - })?; - let terminal_id = terminal_entity.read_with(&self.cx, |terminal, _| terminal.id().clone()); - Ok(acp::CreateTerminalResponse::new(terminal_id)) - } + .await?; - async fn kill_terminal( - &self, - args: acp::KillTerminalRequest, - ) -> Result { - self.session_thread(&args.session_id)? - .update(&mut self.cx.clone(), |thread, cx| { - thread.kill_terminal(args.terminal_id, cx) - })??; + let terminal_entity = thread.update(cx, |thread, cx| { + thread.register_terminal_created( + acp::TerminalId::new(uuid::Uuid::new_v4().to_string()), + format!("{} {}", args.command, args.args.join(" ")), + args.cwd.clone(), + args.output_byte_limit, + terminal_entity, + cx, + ) + })?; + let terminal_id = terminal_entity.read_with(cx, |terminal, _| terminal.id().clone()); + Ok(terminal_id) + } + .await; - Ok(Default::default()) - } + match result { + Ok(terminal_id) => { + responder + .respond(acp::CreateTerminalResponse::new(terminal_id)) + .log_err(); + } + Err(e) => respond_err(responder, e), + } + }) + .detach(); +} - async fn ext_method(&self, _args: acp::ExtRequest) -> Result { - Err(acp::Error::method_not_found()) - } +fn handle_kill_terminal( + args: acp::KillTerminalRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let thread = match session_thread(ctx, &args.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; - async fn ext_notification(&self, _args: acp::ExtNotification) -> Result<(), acp::Error> { - Err(acp::Error::method_not_found()) + match thread + .update(cx, |thread, cx| thread.kill_terminal(args.terminal_id, cx)) + .flatten_acp() + { + Ok(()) => { + responder + .respond(acp::KillTerminalResponse::default()) + .log_err(); + } + Err(e) => respond_err(responder, e), } +} - async fn release_terminal( - &self, - args: acp::ReleaseTerminalRequest, - ) -> Result { - self.session_thread(&args.session_id)? - .update(&mut self.cx.clone(), |thread, cx| { - thread.release_terminal(args.terminal_id, cx) - })??; +fn handle_release_terminal( + args: acp::ReleaseTerminalRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let thread = match session_thread(ctx, &args.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; - Ok(Default::default()) + match thread + .update(cx, |thread, cx| { + thread.release_terminal(args.terminal_id, cx) + }) + .flatten_acp() + { + Ok(()) => { + responder + .respond(acp::ReleaseTerminalResponse::default()) + .log_err(); + } + Err(e) => respond_err(responder, e), } +} - async fn terminal_output( - &self, - args: acp::TerminalOutputRequest, - ) -> Result { - self.session_thread(&args.session_id)? - .read_with(&mut self.cx.clone(), |thread, cx| { - let out = thread - .terminal(args.terminal_id)? - .read(cx) - .current_output(cx); +fn handle_terminal_output( + args: acp::TerminalOutputRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let thread = match session_thread(ctx, &args.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; - Ok(out) - })? + match thread + .read_with(cx, |thread, cx| -> anyhow::Result<_> { + let out = thread + .terminal(args.terminal_id)? + .read(cx) + .current_output(cx); + Ok(out) + }) + .flatten_acp() + { + Ok(output) => { + responder.respond(output).log_err(); + } + Err(e) => respond_err(responder, e), } +} - async fn wait_for_terminal_exit( - &self, - args: acp::WaitForTerminalExitRequest, - ) -> Result { - let exit_status = self - .session_thread(&args.session_id)? - .update(&mut self.cx.clone(), |thread, cx| { - anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit()) - })?? - .await; +fn handle_wait_for_terminal_exit( + args: acp::WaitForTerminalExitRequest, + responder: Responder, + cx: &mut AsyncApp, + ctx: &ClientContext, +) { + let thread = match session_thread(ctx, &args.session_id) { + Ok(t) => t, + Err(e) => return respond_err(responder, e), + }; - Ok(acp::WaitForTerminalExitResponse::new(exit_status)) - } -} + cx.spawn(async move |cx| { + let result: Result<_, acp::Error> = async { + let exit_status = thread + .update(cx, |thread, cx| { + anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit()) + }) + .flatten_acp()? + .await; + Ok(exit_status) + } + .await; -impl ClientDelegate { - fn session_thread(&self, session_id: &acp::SessionId) -> Result> { - let sessions = self.sessions.borrow(); - sessions - .get(session_id) - .context("Failed to get session") - .map(|session| session.thread.clone()) - } + match result { + Ok(exit_status) => { + responder + .respond(acp::WaitForTerminalExitResponse::new(exit_status)) + .log_err(); + } + Err(e) => respond_err(responder, e), + } + }) + .detach(); } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index f609a5f50aef3a..9c1d36bf9a7ff7 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -12,6 +12,7 @@ use http_client::read_no_proxy_from_env; use project::{AgentId, Project, agent_server_store::AgentServerStore}; use acp_thread::AgentConnection; +use agent_client_protocol::schema as acp_schema; use anyhow::Result; use gpui::{App, AppContext, Entity, Task}; use settings::SettingsStore; @@ -52,31 +53,31 @@ pub trait AgentServer: Send { fn into_any(self: Rc) -> Rc; - fn default_mode(&self, _cx: &App) -> Option { + fn default_mode(&self, _cx: &App) -> Option { None } fn set_default_mode( &self, - _mode_id: Option, + _mode_id: Option, _fs: Arc, _cx: &mut App, ) { } - fn default_model(&self, _cx: &App) -> Option { + fn default_model(&self, _cx: &App) -> Option { None } fn set_default_model( &self, - _model_id: Option, + _model_id: Option, _fs: Arc, _cx: &mut App, ) { } - fn favorite_model_ids(&self, _cx: &mut App) -> HashSet { + fn favorite_model_ids(&self, _cx: &mut App) -> HashSet { HashSet::default() } @@ -95,16 +96,16 @@ pub trait AgentServer: Send { fn favorite_config_option_value_ids( &self, - _config_id: &agent_client_protocol::SessionConfigId, + _config_id: &acp_schema::SessionConfigId, _cx: &mut App, - ) -> HashSet { + ) -> HashSet { HashSet::default() } fn toggle_favorite_config_option_value( &self, - _config_id: agent_client_protocol::SessionConfigId, - _value_id: agent_client_protocol::SessionConfigValueId, + _config_id: acp_schema::SessionConfigId, + _value_id: acp_schema::SessionConfigValueId, _should_be_favorite: bool, _fs: Arc, _cx: &App, @@ -113,7 +114,7 @@ pub trait AgentServer: Send { fn toggle_favorite_model( &self, - _model_id: agent_client_protocol::ModelId, + _model_id: acp_schema::ModelId, _should_be_favorite: bool, _fs: Arc, _cx: &App, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 151ddcefcfb0b8..b3574f6e81a5a1 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,6 +1,6 @@ use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::{Context as _, Result}; use collections::HashSet; use fs::Fs; diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index aa29a0c230c139..aa9cdb2cc1bd9a 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,6 +1,6 @@ use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use client::RefreshLlmTokenListener; use futures::{FutureExt, StreamExt, channel::mpsc, select}; use gpui::AppContext; @@ -379,7 +379,7 @@ macro_rules! common_e2e_tests { async fn tool_call_with_permission(cx: &mut ::gpui::TestAppContext) { $crate::e2e_tests::test_tool_call_with_permission( $server, - ::agent_client_protocol::PermissionOptionId::new($allow_option_id), + ::agent_client_protocol::schema::PermissionOptionId::new($allow_option_id), cx, ) .await; diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index a8b21fcbd84996..5dd939c4ad1d5d 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -3,7 +3,7 @@ mod agent_profile; use std::path::{Component, Path}; use std::sync::{Arc, LazyLock}; -use agent_client_protocol::ModelId; +use agent_client_protocol::schema as acp; use collections::{HashSet, IndexMap}; use fs::Fs; use futures::channel::oneshot; @@ -142,7 +142,7 @@ pub struct AgentSettings { pub sidebar_side: SidebarDockPosition, pub default_width: Pixels, pub default_height: Pixels, - pub max_content_width: Pixels, + pub max_content_width: Option, pub default_model: Option, pub inline_assistant_model: Option, pub inline_assistant_use_streaming_tools: bool, @@ -204,13 +204,54 @@ impl AgentSettings { self.message_editor_min_lines * 2 } - pub fn favorite_model_ids(&self) -> HashSet { + pub fn favorite_model_ids(&self) -> HashSet { self.favorite_models .iter() - .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model))) + .map(|sel| acp::ModelId::new(format!("{}/{}", sel.provider.0, sel.model))) .collect() } +} + +pub fn language_model_to_selection( + model: &Arc, + override_selection: Option<&LanguageModelSelection>, +) -> LanguageModelSelection { + let provider = model.provider_id().0.to_string().into(); + let model_name = model.id().0.to_string(); + match override_selection { + Some(current) => LanguageModelSelection { + provider, + model: model_name, + enable_thinking: current.enable_thinking && model.supports_thinking(), + effort: current + .effort + .clone() + .filter(|value| { + model + .supported_effort_levels() + .iter() + .any(|level| level.value.as_ref() == value.as_str()) + }) + .or_else(|| { + model + .default_effort_level() + .map(|effort| effort.value.to_string()) + }), + speed: current.speed.filter(|_| model.supports_fast_mode()), + }, + None => LanguageModelSelection { + provider, + model: model_name, + enable_thinking: model.supports_thinking(), + effort: model + .default_effort_level() + .map(|effort| effort.value.to_string()), + speed: None, + }, + } +} +impl AgentSettings { pub fn get_layout(cx: &App) -> WindowLayout { let store = cx.global::(); let merged = store.merged_settings(); @@ -593,7 +634,11 @@ impl Settings for AgentSettings { sidebar_side: agent.sidebar_side.unwrap(), default_width: px(agent.default_width.unwrap()), default_height: px(agent.default_height.unwrap()), - max_content_width: px(agent.max_content_width.unwrap()), + max_content_width: if agent.limit_content_width.unwrap() { + Some(px(agent.max_content_width.unwrap())) + } else { + None + }, flexible: agent.flexible.unwrap(), default_model: Some(agent.default_model.unwrap()), inline_assistant_model: agent.inline_assistant_model, diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 3813e99bcd1650..a48f3a79e80e5c 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -55,6 +55,7 @@ file_icons.workspace = true fs.workspace = true futures.workspace = true git.workspace = true +git_ui.workspace = true fuzzy.workspace = true gpui.workspace = true gpui_tokio.workspace = true @@ -101,7 +102,6 @@ text.workspace = true theme.workspace = true theme_settings.workspace = true time.workspace = true -time_format.workspace = true ui.workspace = true ui_input.workspace = true url.workspace = true diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 13ec53b25c50b5..da0704889e7fb9 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -16,7 +16,7 @@ use extension::ExtensionManifest; use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable, + Action, Anchor, AnyView, App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Subscription, Task, WeakEntity, }; use itertools::Itertools; @@ -463,7 +463,7 @@ impl AgentConfiguration { })) } }) - .anchor(gpui::Corner::TopRight) + .anchor(gpui::Anchor::TopRight) .offset(gpui::Point { x: px(0.0), y: px(2.0), @@ -562,7 +562,7 @@ impl AgentConfiguration { })) } }) - .anchor(gpui::Corner::TopRight) + .anchor(gpui::Anchor::TopRight) .offset(gpui::Point { x: px(0.0), y: px(2.0), @@ -705,7 +705,7 @@ impl AgentConfiguration { .icon_size(IconSize::Small), Tooltip::text("Configure MCP Server"), ) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .menu({ let fs = self.fs.clone(); let context_server_id = context_server_id.clone(); @@ -1059,7 +1059,7 @@ impl AgentConfiguration { })) } }) - .anchor(gpui::Corner::TopRight) + .anchor(gpui::Anchor::TopRight) .offset(gpui::Point { x: px(0.0), y: px(2.0), diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index e0df79ba4dfe22..1cff19c7cf4b3e 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -157,6 +157,7 @@ impl ModelInput { parallel_tool_calls, prompt_cache_key, chat_completions, + .. } = ModelCapabilities::default(); Self { @@ -209,6 +210,7 @@ impl ModelInput { parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(), chat_completions: self.capabilities.supports_chat_completions.selected(), + interleaved_reasoning: false, }, }) } diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index d903a6435d8769..218347d5c57c21 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -10,7 +10,7 @@ use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Subscri use project::{AgentServerStore, AgentServersUpdated, Project}; use watch::Receiver; -use crate::{Agent, ThreadHistory}; +use crate::Agent; pub enum AgentConnectionEntry { Connecting { @@ -25,7 +25,6 @@ pub enum AgentConnectionEntry { #[derive(Clone)] pub struct AgentConnectedState { pub connection: Rc, - pub history: Option>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -44,13 +43,6 @@ impl AgentConnectionEntry { } } - pub fn history(&self) -> Option<&Entity> { - match self { - AgentConnectionEntry::Connected(state) => state.history.as_ref(), - _ => None, - } - } - pub fn status(&self) -> AgentConnectionStatus { match self { AgentConnectionEntry::Connecting { .. } => AgentConnectionStatus::Connecting, @@ -241,16 +233,8 @@ impl AgentConnectionStore { let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx)); let connect_task = server.connect(delegate, self.project.clone(), cx); - let connect_task = cx.spawn(async move |_this, cx| match connect_task.await { - Ok(connection) => cx.update(|cx| { - let history = connection - .session_list(cx) - .map(|session_list| cx.new(|cx| ThreadHistory::new(session_list, cx))); - Ok(AgentConnectedState { - connection, - history, - }) - }), + let connect_task = cx.spawn(async move |_this, _cx| match connect_task.await { + Ok(connection) => Ok(AgentConnectedState { connection }), Err(err) => match err.downcast::() { Ok(load_error) => Err(load_error), Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))), diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index 93984121c26103..cbffced96df326 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -131,7 +131,7 @@ impl Render for AgentModelSelector { .size(IconSize::XSmall), ), tooltip, - gpui::Corner::TopRight, + gpui::Anchor::TopRight, cx, ) .with_handle(self.menu_handle.clone()) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5fc262f8b7206e..b363d6a42ae371 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,5 +1,5 @@ use std::{ - path::{Path, PathBuf}, + path::PathBuf, rc::Rc, sync::{ Arc, @@ -10,7 +10,7 @@ use std::{ use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_servers::AgentServer; use collections::HashSet; use db::kvp::{Dismissable, KeyValueStore}; @@ -30,49 +30,43 @@ use zed_actions::{ }; use crate::DEFAULT_THREAD_TITLE; +use crate::ExpandMessageEditor; +use crate::ManageProfiles; +use crate::agent_connection_store::AgentConnectionStore; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore}; use crate::{ - AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CreateWorktree, - Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget, - OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, - SwitchWorktree, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, - ToggleWorktreeSelector, + AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, + InlineAssistant, LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, + ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata, + ToggleNewThreadMenu, ToggleOptionsMenu, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, conversation_view::{AcpThreadViewEvent, ThreadView}, - thread_worktree_picker::ThreadWorktreePicker, ui::EndTrialUpsell, }; use crate::{ Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread, NewNativeAgentThreadFromSummary, }; -use crate::{ExpandMessageEditor, ThreadHistoryView}; -use crate::{ManageProfiles, ThreadHistoryViewEvent}; -use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::Result; +use chrono::{DateTime, Utc}; use client::UserStore; use cloud_api_types::Plan; use collections::HashMap; -use editor::Editor; +use editor::{Editor, MultiBuffer}; use extension::ExtensionEvents; use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner, - DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable, - KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, - pulsating_between, + Action, Anchor, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, + Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription, + Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, }; use language::LanguageRegistry; use language_model::LanguageModelRegistry; -use project::project_settings::ProjectSettings; -use project::trusted_worktrees::{PathTrust, TrustedWorktrees}; -use project::{Project, ProjectPath, Worktree, linked_worktree_short_name}; +use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; -use release_channel::ReleaseChannel; -use remote::RemoteConnectionOptions; use rules_library::{RulesLibrary, open_rules_library}; use settings::TerminalDockPosition; use settings::{Settings, update_settings_file}; @@ -83,21 +77,17 @@ use ui::{ Button, Callout, ContextMenu, ContextMenuEntry, IconButton, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize, }; -use util::{ResultExt as _, debug_panic}; +use util::ResultExt as _; use workspace::{ - CollaboratorId, DockStructure, DraggedSelection, DraggedTab, OpenMode, PathList, - SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, + CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList, + ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, }; const AGENT_PANEL_KEY: &str = "agent_panel"; const MIN_PANEL_WIDTH: Pixels = px(300.); -const RECENTLY_UPDATED_MENU_LIMIT: usize = 6; const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; -fn agent_v2_enabled(cx: &App) -> bool { - !matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable)) -} /// Maximum number of idle threads kept in the agent panel's retained list. /// Set as a GPUI global to override; otherwise defaults to 5. pub struct MaxIdleRetainedThreads(pub usize); @@ -207,24 +197,17 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx)); } }) - .register_action(|workspace, _: &OpenHistory, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| panel.open_history(window, cx)); - } - }) .register_action(|workspace, _: &OpenSettings, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); panel.update(cx, |panel, cx| panel.open_configuration(window, cx)); } }) - .register_action(|workspace, _action: &NewExternalAgentThread, window, cx| { + .register_action(|workspace, action: &NewExternalAgentThread, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); panel.update(cx, |panel, cx| { - let id = panel.create_thread("agent_panel", window, cx); - panel.activate_retained_thread(id, true, window, cx); + panel.new_external_agent_thread(action, window, cx); }); } }) @@ -254,14 +237,6 @@ pub fn init(cx: &mut App) { AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); } }) - .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| { - panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx); - }); - } - }) .register_action(|workspace, _: &ToggleOptionsMenu, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -278,14 +253,6 @@ pub fn init(cx: &mut App) { }); } }) - .register_action(|workspace, _: &ToggleWorktreeSelector, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| { - panel.toggle_worktree_selector(&ToggleWorktreeSelector, window, cx); - }); - } - }) .register_action(|_workspace, _: &ResetOnboarding, window, cx| { window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx); window.refresh(); @@ -325,6 +292,24 @@ pub fn init(cx: &mut App) { }); } }) + .register_action(|workspace, _: &ShowThreadMetadata, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.show_thread_metadata(&ShowThreadMetadata, window, cx); + }); + } + }) + .register_action(|workspace, _: &ShowAllSidebarThreadMetadata, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.show_all_sidebar_thread_metadata( + &ShowAllSidebarThreadMetadata, + window, + cx, + ); + }); + } + }) .register_action(|workspace, action: &ReviewBranchDiff, window, cx| { let Some(panel) = workspace.panel::(cx) else { return; @@ -364,7 +349,7 @@ pub fn init(cx: &mut App) { auto_submit: true, }), true, - "agent_panel", + "git_panel", window, cx, ); @@ -391,7 +376,7 @@ pub fn init(cx: &mut App) { auto_submit: true, }), true, - "agent_panel", + "git_panel", window, cx, ); @@ -420,7 +405,7 @@ pub fn init(cx: &mut App) { auto_submit: true, }), true, - "agent_panel", + "git_panel", window, cx, ); @@ -489,28 +474,6 @@ pub fn init(cx: &mut App) { }); }); }, - ) - .register_action( - |workspace: &mut Workspace, action: &CreateWorktree, window, cx| { - let previous_state = - AgentPanel::capture_workspace_state(workspace, window, cx); - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.create_worktree(action, previous_state, window, cx); - }); - } - }, - ) - .register_action( - |workspace: &mut Workspace, action: &SwitchWorktree, window, cx| { - let previous_state = - AgentPanel::capture_workspace_state(workspace, window, cx); - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.switch_to_worktree(action, previous_state, window, cx); - }); - } - }, ); }, ) @@ -624,6 +587,46 @@ fn build_conflicted_files_resolution_prompt( content } +fn format_timestamp_human(dt: &DateTime) -> String { + let now = Utc::now(); + let duration = now.signed_duration_since(*dt); + + let relative = if duration.num_seconds() < 0 { + "in the future".to_string() + } else if duration.num_seconds() < 60 { + let seconds = duration.num_seconds(); + format!("{seconds} seconds ago") + } else if duration.num_minutes() < 60 { + let minutes = duration.num_minutes(); + format!("{minutes} minutes ago") + } else if duration.num_hours() < 24 { + let hours = duration.num_hours(); + format!("{hours} hours ago") + } else { + let days = duration.num_days(); + format!("{days} days ago") + }; + + format!("{} ({})", dt.to_rfc3339(), relative) +} + +/// Used for `dev: show thread metadata` action +fn thread_metadata_to_debug_json( + metadata: &crate::thread_metadata_store::ThreadMetadata, +) -> serde_json::Value { + serde_json::json!({ + "thread_id": metadata.thread_id, + "session_id": metadata.session_id.as_ref().map(|s| s.0.to_string()), + "agent_id": metadata.agent_id.0.to_string(), + "title": metadata.title.as_ref().map(|t| t.to_string()), + "updated_at": format_timestamp_human(&metadata.updated_at), + "created_at": metadata.created_at.as_ref().map(format_timestamp_human), + "interacted_at": metadata.interacted_at.as_ref().map(format_timestamp_human), + "worktree_paths": format!("{:?}", metadata.worktree_paths), + "archived": metadata.archived, + }) +} + pub(crate) struct AgentThread { conversation_view: Entity, } @@ -644,14 +647,12 @@ impl From for BaseView { } enum OverlayView { - History { view: Entity }, Configuration, } enum VisibleSurface<'a> { Uninitialized, AgentThread(&'a Entity), - History(&'a Entity), Configuration(Option<&'a Entity>), } @@ -660,61 +661,6 @@ enum WhichFontSize { None, } -#[derive(Clone, Debug)] -pub enum WorktreeCreationStatus { - Creating(SharedString), - Loading(SharedString), - Error(SharedString), -} - -#[derive(Clone, Debug)] -enum WorktreeCreationArgs { - New { - worktree_name: Option, - branch_target: NewWorktreeBranchTarget, - }, - Linked { - worktree_path: PathBuf, - display_name: String, - }, -} - -struct PreviousWorkspaceState { - dock_structure: DockStructure, - open_file_paths: Vec, - active_file_path: Option, -} - -#[cfg(test)] -impl PreviousWorkspaceState { - /// An empty state with all docks hidden and no open files. - fn empty() -> Self { - use workspace::DockData; - - Self { - dock_structure: DockStructure { - left: DockData { - visible: false, - active_panel: None, - zoom: false, - }, - right: DockData { - visible: false, - active_panel: None, - zoom: false, - }, - bottom: DockData { - visible: false, - active_panel: None, - zoom: false, - }, - }, - open_file_paths: Vec::new(), - active_file_path: None, - } - } -} - impl BaseView { pub fn which_font_size_used(&self) -> WhichFontSize { WhichFontSize::AgentFont @@ -724,7 +670,6 @@ impl BaseView { impl OverlayView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { - OverlayView::History { .. } => WhichFontSize::AgentFont, OverlayView::Configuration => WhichFontSize::None, } } @@ -750,10 +695,7 @@ pub struct AgentPanel { draft_thread: Option>, retained_threads: HashMap>, new_thread_menu_handle: PopoverMenuHandle, - start_thread_in_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, - agent_navigation_menu_handle: PopoverMenuHandle, - agent_navigation_menu: Option>, _extension_subscription: Option, _project_subscription: Subscription, zoomed: bool, @@ -761,10 +703,8 @@ pub struct AgentPanel { new_user_onboarding: Entity, new_user_onboarding_upsell_dismissed: AtomicBool, selected_agent: Agent, - worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>, _thread_view_subscription: Option, _active_thread_focus_subscription: Option, - _worktree_creation_task: Option>, show_trust_workspace_message: bool, _base_view_observation: Option, _draft_editor_observation: Option, @@ -779,18 +719,43 @@ impl AgentPanel { let selected_agent = self.selected_agent.clone(); let is_draft_active = self.active_thread_is_draft(cx); - let last_active_thread = self.active_agent_thread(cx).map(|thread| { - let thread = thread.read(cx); - - let title = thread.title(); - let work_dirs = thread.work_dirs().cloned(); - SerializedActiveThread { - session_id: (!is_draft_active).then(|| thread.session_id().0.to_string()), - agent_type: self.selected_agent.clone(), - title: title.map(|t| t.to_string()), - work_dirs: work_dirs.map(|dirs| dirs.serialize()), - } - }); + let last_active_thread = self + .active_agent_thread(cx) + .map(|thread| { + let thread = thread.read(cx); + + let title = thread.title(); + let work_dirs = thread.work_dirs().cloned(); + SerializedActiveThread { + session_id: (!is_draft_active).then(|| thread.session_id().0.to_string()), + agent_type: self.selected_agent.clone(), + title: title.map(|t| t.to_string()), + work_dirs: work_dirs.map(|dirs| dirs.serialize()), + } + }) + .or_else(|| { + // The active view may be in `Loading` or `LoadError` — for + // example, while a restored thread is waiting for a custom + // agent to finish registering. Without this fallback, a + // stray `serialize()` triggered during that window would + // write `session_id=None` and wipe the restored session + if is_draft_active { + return None; + } + let conversation_view = self.active_conversation_view()?; + let session_id = conversation_view.read(cx).root_session_id.clone()?; + let metadata = ThreadMetadataStore::try_global(cx) + .and_then(|store| store.read(cx).entry_by_session(&session_id).cloned()); + Some(SerializedActiveThread { + session_id: Some(session_id.0.to_string()), + agent_type: self.selected_agent.clone(), + title: metadata + .as_ref() + .and_then(|m| m.title.as_ref()) + .map(|t| t.to_string()), + work_dirs: metadata.map(|m| m.folder_paths().serialize()), + }) + }); let kvp = KeyValueStore::global(cx); let draft_thread_prompt = self.draft_thread.as_ref().and_then(|conversation| { @@ -986,7 +951,7 @@ impl AgentPanel { pub(crate) fn new( workspace: &Workspace, prompt_store: Option>, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) -> Self { let fs = workspace.app_state().fs.clone(); @@ -1004,48 +969,6 @@ impl AgentPanel { let base_view = BaseView::Uninitialized; - let weak_panel = cx.entity().downgrade(); - - window.defer(cx, move |window, cx| { - let panel = weak_panel.clone(); - let agent_navigation_menu = - ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| { - if let Some(panel) = panel.upgrade() { - if let Some(history) = panel - .update(cx, |panel, cx| panel.history_for_selected_agent(window, cx)) - { - menu = Self::populate_recently_updated_menu_section( - menu, panel, history, cx, - ); - menu = menu.action("View All", Box::new(OpenHistory)); - } - } - - menu = menu - .fixed_width(px(320.).into()) - .keep_open_on_confirm(false) - .key_context("NavigationMenu"); - - menu - }); - weak_panel - .update(cx, |panel, cx| { - cx.subscribe_in( - &agent_navigation_menu, - window, - |_, menu, _: &DismissEvent, window, cx| { - menu.update(cx, |menu, _| { - menu.clear_selected(); - }); - cx.focus_self(window); - }, - ) - .detach(); - panel.agent_navigation_menu = Some(agent_navigation_menu); - }) - .ok(); - }); - let weak_panel = cx.entity().downgrade(); let onboarding = cx.new(|cx| { AgentPanelOnboarding::new( @@ -1117,10 +1040,8 @@ impl AgentPanel { draft_thread: None, retained_threads: HashMap::default(), new_thread_menu_handle: PopoverMenuHandle::default(), - start_thread_in_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), - agent_navigation_menu_handle: PopoverMenuHandle::default(), - agent_navigation_menu: None, + _extension_subscription: extension_subscription, _project_subscription, zoomed: false, @@ -1128,10 +1049,8 @@ impl AgentPanel { new_user_onboarding: onboarding, thread_store, selected_agent: Agent::default(), - worktree_creation_status: None, _thread_view_subscription: None, _active_thread_focus_subscription: None, - _worktree_creation_task: None, show_trust_workspace_message: false, new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)), _base_view_observation: None, @@ -1199,6 +1118,14 @@ impl AgentPanel { &self.connection_store } + pub fn selected_agent(&self, cx: &App) -> Agent { + if self.project.read(cx).is_via_collab() { + Agent::NativeAgent + } else { + self.selected_agent.clone() + } + } + pub fn open_thread( &mut self, session_id: acp::SessionId, @@ -1245,18 +1172,36 @@ impl AgentPanel { let old_view = std::mem::replace(&mut self.base_view, BaseView::Uninitialized); self.retain_running_thread(old_view, cx); self.clear_overlay_state(); - self.activate_draft(false, window, cx); + self.activate_draft(false, "agent_panel", window, cx); self.serialize(cx); cx.emit(AgentPanelEvent::ActiveViewChanged); cx.notify(); } pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { - self.activate_draft(true, window, cx); + self.activate_draft(true, "agent_panel", window, cx); } - pub fn activate_draft(&mut self, focus: bool, window: &mut Window, cx: &mut Context) { - let draft = self.ensure_draft(window, cx); + pub fn new_external_agent_thread( + &mut self, + action: &NewExternalAgentThread, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(agent) = action.agent.clone() { + self.selected_agent = agent; + } + self.activate_draft(true, "agent_panel", window, cx); + } + + pub fn activate_draft( + &mut self, + focus: bool, + source: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + let draft = self.ensure_draft(source, window, cx); if let BaseView::AgentThread { conversation_view } = &self.base_view { if conversation_view.entity_id() == draft.entity_id() { if focus { @@ -1277,37 +1222,27 @@ impl AgentPanel { fn ensure_draft( &mut self, + source: &'static str, window: &mut Window, cx: &mut Context, ) -> Entity { - let desired_agent = if self.project.read(cx).is_via_collab() { - Agent::NativeAgent - } else { - self.selected_agent.clone() - }; + let desired_agent = self.selected_agent(cx); if let Some(draft) = &self.draft_thread { let agent_matches = *draft.read(cx).agent_key() == desired_agent; - let has_editor_content = draft.read(cx).root_thread_view().is_some_and(|tv| { - !tv.read(cx) - .message_editor - .read(cx) - .text(cx) - .trim() - .is_empty() - }); - if agent_matches || has_editor_content { + if agent_matches { return draft.clone(); } self.draft_thread = None; self._draft_editor_observation = None; } + let previous_content = self.active_initial_content(cx); let thread = self.create_agent_thread( desired_agent, None, None, None, - None, - "agent_panel", + previous_content, + source, window, cx, ); @@ -1346,11 +1281,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) -> ThreadId { - let agent = if self.project.read(cx).is_via_collab() { - Agent::NativeAgent - } else { - self.selected_agent.clone() - }; + let agent = self.selected_agent(cx); let thread = self.create_agent_thread(agent, None, None, None, None, source, window, cx); let thread_id = thread.conversation_view.read(cx).thread_id; self.retained_threads @@ -1393,7 +1324,7 @@ impl AgentPanel { if self.active_thread_id(cx) == Some(id) { self.clear_overlay_state(); - self.activate_draft(false, window, cx); + self.activate_draft(false, "agent_panel", window, cx); self.serialize(cx); cx.emit(AgentPanelEvent::ActiveViewChanged); cx.notify(); @@ -1452,36 +1383,6 @@ impl AgentPanel { }); } - fn take_active_initial_content( - &mut self, - cx: &mut Context, - ) -> Option { - self.active_thread_view(cx).and_then(|thread_view| { - thread_view.update(cx, |thread_view, cx| { - let draft_blocks = thread_view - .thread - .read(cx) - .draft_prompt() - .map(|draft| draft.to_vec()) - .filter(|draft| !draft.is_empty()); - - let draft_blocks = draft_blocks.or_else(|| { - let text = thread_view.message_editor.read(cx).text(cx); - if text.trim().is_empty() { - None - } else { - Some(vec![acp::ContentBlock::Text(acp::TextContent::new(text))]) - } - }); - - draft_blocks.map(|blocks| AgentInitialContent::ContentBlock { - blocks, - auto_submit: false, - }) - }) - }) - } - fn new_native_agent_thread_from_summary( &mut self, action: &NewNativeAgentThreadFromSummary, @@ -1490,31 +1391,30 @@ impl AgentPanel { ) { let session_id = action.from_session_id.clone(); - let Some(history) = self - .connection_store + let Some(thread) = ThreadStore::global(cx) .read(cx) - .entry(&Agent::NativeAgent) - .and_then(|e| e.read(cx).history().cloned()) + .entries() + .find(|t| t.id == session_id) else { - debug_panic!("Native agent is not registered"); + log::error!("No session found for summarization with id {}", session_id); + return; + }; + + let Some(parent_session_id) = thread.parent_session_id else { + log::error!("Session {} has no parent session", session_id); return; }; cx.spawn_in(window, async move |this, cx| { this.update_in(cx, |this, window, cx| { - let thread = history - .read(cx) - .session_for_id(&session_id) - .context("Session not found")?; - this.external_thread( Some(Agent::NativeAgent), None, None, None, Some(AgentInitialContent::ThreadSummary { - session_id: thread.session_id, - title: thread.title, + session_id: parent_session_id, + title: Some(thread.title), }), true, "agent_panel", @@ -1539,13 +1439,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let agent = agent_choice.unwrap_or_else(|| { - if self.project.read(cx).is_via_collab() { - Agent::NativeAgent - } else { - self.selected_agent.clone() - } - }); + let agent = agent_choice.unwrap_or_else(|| self.selected_agent(cx)); let thread = self.create_agent_thread( agent, resume_session_id, @@ -1591,79 +1485,6 @@ impl AgentPanel { }) } - fn has_history_for_selected_agent(&self, cx: &App) -> bool { - match &self.selected_agent { - Agent::NativeAgent => true, - Agent::Custom { .. } => self - .connection_store - .read(cx) - .entry(&self.selected_agent) - .map_or(false, |entry| entry.read(cx).history().is_some()), - #[cfg(any(test, feature = "test-support"))] - Agent::Stub => false, - } - } - - fn history_for_selected_agent( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Option> { - let agent = self.selected_agent.clone(); - let history = self - .connection_store - .read(cx) - .entry(&agent)? - .read(cx) - .history()? - .clone(); - Some(self.create_thread_history_view(agent, history, window, cx)) - } - - fn create_thread_history_view( - &self, - agent: Agent, - history: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx)); - cx.subscribe_in( - &view, - window, - move |this, _, event, window, cx| match event { - ThreadHistoryViewEvent::Open(thread) => { - this.load_agent_thread( - agent.clone(), - thread.session_id.clone(), - thread.work_dirs.clone(), - thread.title.clone(), - true, - "agent_panel", - window, - cx, - ); - } - }, - ) - .detach(); - view - } - - fn open_history(&mut self, window: &mut Window, cx: &mut Context) { - if matches!(self.overlay_view, Some(OverlayView::History { .. })) { - self.clear_overlay(true, window, cx); - return; - } - - let Some(view) = self.history_for_selected_agent(window, cx) else { - return; - }; - - self.set_overlay(OverlayView::History { view }, true, window, cx); - cx.notify(); - } - pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) { if self.overlay_view.is_some() { self.clear_overlay(true, window, cx); @@ -1671,24 +1492,13 @@ impl AgentPanel { } } - pub fn toggle_navigation_menu( - &mut self, - _: &ToggleNavigationMenu, - window: &mut Window, - cx: &mut Context, - ) { - if !self.has_history_for_selected_agent(cx) { - return; - } - self.agent_navigation_menu_handle.toggle(window, cx); - } - pub fn toggle_options_menu( &mut self, _: &ToggleOptionsMenu, window: &mut Window, cx: &mut Context, ) { + window.focus(&self.focus_handle, cx); self.agent_panel_menu_handle.toggle(window, cx); } @@ -1701,15 +1511,6 @@ impl AgentPanel { self.new_thread_menu_handle.toggle(window, cx); } - pub fn toggle_worktree_selector( - &mut self, - _: &ToggleWorktreeSelector, - window: &mut Window, - cx: &mut Context, - ) { - self.start_thread_in_menu_handle.toggle(window, cx); - } - pub fn increase_font_size( &mut self, action: &IncreaseBufferFontSize, @@ -1979,51 +1780,153 @@ impl AgentPanel { .detach_and_log_err(cx); } - fn handle_agent_configuration_event( + fn show_thread_metadata( &mut self, - _entity: &Entity, - event: &AssistantConfigurationEvent, + _: &ShowThreadMetadata, window: &mut Window, cx: &mut Context, ) { - match event { - AssistantConfigurationEvent::NewThread(provider) => { - if LanguageModelRegistry::read_global(cx) - .default_model() - .is_none_or(|model| model.provider.id() != provider.id()) - && let Some(model) = provider.default_model(cx) - { - update_settings_file(self.fs.clone(), cx, move |settings, _| { - let provider = model.provider_id().0.to_string(); - let enable_thinking = model.supports_thinking(); - let effort = model - .default_effort_level() - .map(|effort| effort.value.to_string()); - let model = model.id().0.to_string(); - settings - .agent - .get_or_insert_default() - .set_model(LanguageModelSelection { - provider: LanguageModelProviderSetting(provider), - model, - enable_thinking, - effort, - speed: None, - }) - }); - } + let Some(thread_id) = self.active_thread_id(cx) else { + Self::show_deferred_toast(&self.workspace, "No active thread", cx); + return; + }; - self.new_thread(&NewThread, window, cx); - if let Some((thread, model)) = self - .active_native_agent_thread(cx) - .zip(provider.default_model(cx)) - { - thread.update(cx, |thread, cx| { - thread.set_model(model, cx); - }); - } - } - } + let Some(store) = ThreadMetadataStore::try_global(cx) else { + Self::show_deferred_toast(&self.workspace, "Thread metadata store not available", cx); + return; + }; + + let Some(metadata) = store.read(cx).entry(thread_id).cloned() else { + Self::show_deferred_toast(&self.workspace, "No metadata found for active thread", cx); + return; + }; + + let json = thread_metadata_to_debug_json(&metadata); + let text = serde_json::to_string_pretty(&json).unwrap_or_default(); + let title = format!("Thread Metadata: {}", metadata.display_title()); + + self.open_json_buffer(title, text, window, cx); + } + + fn show_all_sidebar_thread_metadata( + &mut self, + _: &ShowAllSidebarThreadMetadata, + window: &mut Window, + cx: &mut Context, + ) { + let Some(store) = ThreadMetadataStore::try_global(cx) else { + Self::show_deferred_toast(&self.workspace, "Thread metadata store not available", cx); + return; + }; + + let entries: Vec = store + .read(cx) + .entries() + .filter(|t| !t.archived) + .map(thread_metadata_to_debug_json) + .collect(); + + let json = serde_json::Value::Array(entries); + let text = serde_json::to_string_pretty(&json).unwrap_or_default(); + + self.open_json_buffer("All Sidebar Thread Metadata".to_string(), text, window, cx); + } + + fn open_json_buffer( + &self, + title: String, + text: String, + window: &mut Window, + cx: &mut Context, + ) { + let json_language = self.language_registry.language_for_name("JSON"); + let project = self.project.clone(); + let workspace = self.workspace.clone(); + + window + .spawn(cx, async move |cx| { + let json_language = json_language.await.ok(); + + let buffer = project + .update(cx, |project, cx| { + project.create_buffer(json_language, false, cx) + }) + .await?; + + buffer.update(cx, |buffer, cx| { + buffer.set_text(text, cx); + buffer.set_capability(language::Capability::ReadWrite, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let buffer = + cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.clone())); + + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); + editor.set_breadcrumb_header(title); + editor.disable_mouse_wheel_zoom(); + editor + })), + None, + true, + window, + cx, + ); + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn handle_agent_configuration_event( + &mut self, + _entity: &Entity, + event: &AssistantConfigurationEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + AssistantConfigurationEvent::NewThread(provider) => { + if LanguageModelRegistry::read_global(cx) + .default_model() + .is_none_or(|model| model.provider.id() != provider.id()) + && let Some(model) = provider.default_model(cx) + { + update_settings_file(self.fs.clone(), cx, move |settings, _| { + let provider = model.provider_id().0.to_string(); + let enable_thinking = model.supports_thinking(); + let effort = model + .default_effort_level() + .map(|effort| effort.value.to_string()); + let model = model.id().0.to_string(); + settings + .agent + .get_or_insert_default() + .set_model(LanguageModelSelection { + provider: LanguageModelProviderSetting(provider), + model, + enable_thinking, + effort, + speed: None, + }) + }); + } + + self.new_thread(&NewThread, window, cx); + if let Some((thread, model)) = self + .active_native_agent_thread(cx) + .zip(provider.default_model(cx)) + { + thread.update(cx, |thread, cx| { + thread.set_model(model, cx); + }); + } + } + } } pub fn workspace_id(&self) -> Option { @@ -2233,18 +2136,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let was_in_history = matches!(self.overlay_view, Some(OverlayView::History { .. })); self.overlay_view = Some(overlay); - - if let Some(OverlayView::History { view }) = &self.overlay_view - && !was_in_history - { - view.update(cx, |view, cx| { - view.history() - .update(cx, |history, cx| history.refresh_full_history(cx)) - }); - } - if focus { self.focus_handle(cx).focus(window, cx); } @@ -2301,7 +2193,6 @@ impl AgentPanel { fn visible_surface(&self) -> VisibleSurface<'_> { if let Some(overlay_view) = &self.overlay_view { return match overlay_view { - OverlayView::History { view } => VisibleSurface::History(view), OverlayView::Configuration => { VisibleSurface::Configuration(self.configuration.as_ref()) } @@ -2320,10 +2211,6 @@ impl AgentPanel { self.overlay_view.is_some() } - fn is_history_or_configuration_visible(&self) -> bool { - self.is_overlay_open() - } - fn visible_font_size(&self) -> WhichFontSize { self.overlay_view.as_ref().map_or_else( || self.base_view.which_font_size_used(), @@ -2331,64 +2218,6 @@ impl AgentPanel { ) } - fn populate_recently_updated_menu_section( - mut menu: ContextMenu, - panel: Entity, - view: Entity, - cx: &mut Context, - ) -> ContextMenu { - let entries = view - .read(cx) - .history() - .read(cx) - .sessions() - .iter() - .take(RECENTLY_UPDATED_MENU_LIMIT) - .cloned() - .collect::>(); - - if entries.is_empty() { - return menu; - } - - menu = menu.header("Recently Updated"); - - for entry in entries { - let title = entry - .title - .as_ref() - .filter(|title| !title.is_empty()) - .cloned() - .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE)); - - menu = menu.entry(title, None, { - let panel = panel.downgrade(); - let entry = entry.clone(); - move |window, cx| { - let entry = entry.clone(); - panel - .update(cx, move |this, cx| { - if let Some(agent) = this.selected_agent() { - this.load_agent_thread( - agent, - entry.session_id.clone(), - entry.work_dirs.clone(), - entry.title.clone(), - true, - "agent_panel", - window, - cx, - ); - } - }) - .ok(); - } - }); - } - - menu.separator() - } - fn subscribe_to_active_thread_view( server_view: &Entity, window: &mut Window, @@ -2399,7 +2228,7 @@ impl AgentPanel { &tv, window, |this, _view, event: &AcpThreadViewEvent, _window, cx| match event { - AcpThreadViewEvent::MessageSentOrQueued => { + AcpThreadViewEvent::Interacted => { let Some(thread_id) = this.active_thread_id(cx) else { return; }; @@ -2411,17 +2240,13 @@ impl AgentPanel { this._draft_editor_observation = None; } this.retained_threads.remove(&thread_id); - cx.emit(AgentPanelEvent::MessageSentOrQueued { thread_id }); + cx.emit(AgentPanelEvent::ThreadInteracted { thread_id }); } }, ) }) } - pub(crate) fn selected_agent(&self) -> Option { - Some(self.selected_agent.clone()) - } - fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context) { if let Some(extension_store) = ExtensionStore::try_global(cx) { let (manifests, extensions_dir) = { @@ -2466,31 +2291,6 @@ impl AgentPanel { ); } - pub fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { - self.new_agent_thread_inner(agent, true, window, cx); - } - - fn new_agent_thread_inner( - &mut self, - agent: Agent, - focus: bool, - window: &mut Window, - cx: &mut Context, - ) { - let initial_content = self.take_active_initial_content(cx); - self.external_thread( - Some(agent), - None, - None, - None, - initial_content, - focus, - "agent_panel", - window, - cx, - ); - } - pub fn load_agent_thread( &mut self, agent: Agent, @@ -2680,1063 +2480,87 @@ impl AgentPanel { .is_some_and(|active| active.entity_id() == draft.entity_id()) }) } +} - // TODO: The mapping from workspace root paths to git repositories needs a - // unified approach across the codebase: this method, `sidebar::is_root_repo`, - // thread persistence (which PathList is saved to the database), and thread - // querying (which PathList is used to read threads back). All of these need - // to agree on how repos are resolved for a given workspace, especially in - // multi-root and nested-repo configurations. - /// Partitions the project's visible worktrees into git-backed repositories - /// and plain (non-git) paths. Git repos will have worktrees created for - /// them; non-git paths are carried over to the new workspace as-is. - /// - /// When multiple worktrees map to the same repository, the most specific - /// match wins (deepest work directory path), with a deterministic - /// tie-break on entity id. Each repository appears at most once. - fn classify_worktrees( - &self, - cx: &App, - ) -> (Vec>, Vec) { - let project = &self.project; - let repositories = project.read(cx).repositories(cx).clone(); - let mut git_repos: Vec> = Vec::new(); - let mut non_git_paths: Vec = Vec::new(); - let mut seen_repo_ids = std::collections::HashSet::new(); - - for worktree in project.read(cx).visible_worktrees(cx) { - let wt_path = worktree.read(cx).abs_path(); - - let matching_repo = repositories - .iter() - .filter_map(|(id, repo)| { - let work_dir = repo.read(cx).work_directory_abs_path.clone(); - if wt_path.starts_with(work_dir.as_ref()) { - Some((*id, repo.clone(), work_dir.as_ref().components().count())) - } else { - None - } - }) - .max_by( - |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| { - left_depth - .cmp(right_depth) - .then_with(|| left_id.cmp(right_id)) - }, - ); - - if let Some((id, repo, _)) = matching_repo { - if seen_repo_ids.insert(id) { - git_repos.push(repo); +impl Focusable for AgentPanel { + fn focus_handle(&self, cx: &App) -> FocusHandle { + match self.visible_surface() { + VisibleSurface::Uninitialized => self.focus_handle.clone(), + VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), + VisibleSurface::Configuration(configuration) => { + if let Some(configuration) = configuration { + configuration.focus_handle(cx) + } else { + self.focus_handle.clone() } - } else { - non_git_paths.push(wt_path.to_path_buf()); - } - } - - (git_repos, non_git_paths) - } - - fn resolve_worktree_branch_target( - branch_target: &NewWorktreeBranchTarget, - ) -> (Option, Option) { - match branch_target { - NewWorktreeBranchTarget::CurrentBranch => (None, None), - NewWorktreeBranchTarget::ExistingBranch { name } => { - (Some(name.clone()), Some(name.clone())) - } - NewWorktreeBranchTarget::CreateBranch { name, from_ref } => { - (Some(name.clone()), from_ref.clone()) } } } +} - fn maybe_propagate_worktree_trust( - this: &WeakEntity, - new_workspace: &Entity, - paths: &[PathBuf], - cx: &mut AsyncWindowContext, - ) { - cx.update(|_, cx| { - if ProjectSettings::get_global(cx).session.trust_all_worktrees { - return; - } - let Some(trusted_store) = TrustedWorktrees::try_get_global(cx) else { - return; - }; +fn agent_panel_dock_position(cx: &App) -> DockPosition { + AgentSettings::get_global(cx).dock.into() +} - let source_is_trusted = this - .upgrade() - .map(|panel| { - let source_worktree_store = panel.read(cx).project.read(cx).worktree_store(); - !trusted_store - .read(cx) - .has_restricted_worktrees(&source_worktree_store, cx) - }) - .unwrap_or(false); +pub enum AgentPanelEvent { + ActiveViewChanged, + ThreadFocused, + RetainedThreadChanged, + ThreadInteracted { thread_id: ThreadId }, +} - if !source_is_trusted { - return; - } +impl EventEmitter for AgentPanel {} +impl EventEmitter for AgentPanel {} - let worktree_store = new_workspace.read(cx).project().read(cx).worktree_store(); - let paths_to_trust: HashSet<_> = paths - .iter() - .filter_map(|path| { - let (worktree, _) = worktree_store.read(cx).find_worktree(path, cx)?; - Some(PathTrust::Worktree(worktree.read(cx).id())) - }) - .collect(); +impl Panel for AgentPanel { + fn persistent_name() -> &'static str { + "AgentPanel" + } - if !paths_to_trust.is_empty() { - trusted_store.update(cx, |store, cx| { - store.trust(&worktree_store, paths_to_trust, cx); - }); - } - }) - .ok(); + fn panel_key() -> &'static str { + AGENT_PANEL_KEY } - /// Kicks off an async git-worktree creation for each repository. Returns: - /// - /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the - /// receiver resolves once the git worktree command finishes. - /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used - /// later to remap open editor tabs into the new workspace. - fn start_worktree_creations( - git_repos: &[Entity], - worktree_name: Option, - existing_worktree_names: &[String], - existing_worktree_paths: &HashSet, - base_ref: Option, - worktree_directory_setting: &str, - rng: &mut impl rand::Rng, - cx: &mut Context, - ) -> Result<( - Vec<( - Entity, - PathBuf, - futures::channel::oneshot::Receiver>, - )>, - Vec<(PathBuf, PathBuf)>, - )> { - let mut creation_infos = Vec::new(); - let mut path_remapping = Vec::new(); - - let worktree_name = worktree_name.unwrap_or_else(|| { - let existing_refs: Vec<&str> = - existing_worktree_names.iter().map(|s| s.as_str()).collect(); - crate::worktree_names::generate_worktree_name(&existing_refs, rng) - .unwrap_or_else(|| "worktree".to_string()) - }); - - for repo in git_repos { - let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| { - let new_path = - repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?; - if existing_worktree_paths.contains(&new_path) { - anyhow::bail!("A worktree already exists at {}", new_path.display()); - } - let target = git::repository::CreateWorktreeTarget::Detached { - base_sha: base_ref.clone(), - }; - let receiver = repo.create_worktree(target, new_path.clone()); - let work_dir = repo.work_directory_abs_path.clone(); - anyhow::Ok((work_dir, new_path, receiver)) - })?; - path_remapping.push((work_dir.to_path_buf(), new_path.clone())); - creation_infos.push((repo.clone(), new_path, receiver)); - } + fn position(&self, _window: &Window, cx: &App) -> DockPosition { + agent_panel_dock_position(cx) + } - Ok((creation_infos, path_remapping)) - } - - /// Waits for every in-flight worktree creation to complete. If any - /// creation fails, all successfully-created worktrees are rolled back - /// (removed) so the project isn't left in a half-migrated state. - async fn await_and_rollback_on_failure( - creation_infos: Vec<( - Entity, - PathBuf, - futures::channel::oneshot::Receiver>, - )>, - fs: Arc, - cx: &mut AsyncWindowContext, - ) -> Result> { - let mut created_paths: Vec = Vec::new(); - let mut repos_and_paths: Vec<(Entity, PathBuf)> = - Vec::new(); - let mut first_error: Option = None; - - for (repo, new_path, receiver) in creation_infos { - repos_and_paths.push((repo.clone(), new_path.clone())); - match receiver.await { - Ok(Ok(())) => { - created_paths.push(new_path); - } - Ok(Err(err)) => { - if first_error.is_none() { - first_error = Some(err); - } - } - Err(_canceled) => { - if first_error.is_none() { - first_error = Some(anyhow!("Worktree creation was canceled")); - } - } - } - } + fn position_is_valid(&self, position: DockPosition) -> bool { + position != DockPosition::Bottom + } - let Some(err) = first_error else { - return Ok(created_paths); + fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { + let side = match position { + DockPosition::Left => "left", + DockPosition::Right | DockPosition::Bottom => "right", }; + telemetry::event!("Agent Panel Side Changed", side = side); + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings + .agent + .get_or_insert_default() + .set_dock(position.into()); + }); + } - // Rollback all attempted worktrees (both successful and failed, - // since a failed creation may have left an orphan directory). - let mut rollback_futures = Vec::new(); - for (rollback_repo, rollback_path) in &repos_and_paths { - let receiver = cx - .update(|_, cx| { - rollback_repo.update(cx, |repo, _cx| { - repo.remove_worktree(rollback_path.clone(), true) - }) - }) - .ok(); + fn default_size(&self, window: &Window, cx: &App) -> Pixels { + let settings = AgentSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => settings.default_width, + DockPosition::Bottom => settings.default_height, + } + } - rollback_futures.push((rollback_path.clone(), receiver)); + fn min_size(&self, window: &Window, cx: &App) -> Option { + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => Some(MIN_PANEL_WIDTH), + DockPosition::Bottom => None, } + } - let mut rollback_failures: Vec = Vec::new(); - for (path, receiver_opt) in rollback_futures { - let mut git_remove_failed = false; - - if let Some(receiver) = receiver_opt { - match receiver.await { - Ok(Ok(())) => {} - Ok(Err(rollback_err)) => { - log::error!( - "git worktree remove failed for {}: {rollback_err}", - path.display() - ); - git_remove_failed = true; - } - Err(canceled) => { - log::error!( - "git worktree remove failed for {}: {canceled}", - path.display() - ); - git_remove_failed = true; - } - } - } else { - log::error!( - "failed to dispatch git worktree remove for {}", - path.display() - ); - git_remove_failed = true; - } - - // `git worktree remove` normally removes this directory, but since - // `git worktree remove` failed (or wasn't dispatched), manually rm the directory. - if git_remove_failed { - if let Err(fs_err) = fs - .remove_dir( - &path, - fs::RemoveOptions { - recursive: true, - ignore_if_not_exists: true, - }, - ) - .await - { - let msg = format!("{}: failed to remove directory: {fs_err}", path.display()); - log::error!("{}", msg); - rollback_failures.push(msg); - } - } - } - let mut error_message = format!("Failed to create worktree: {err}"); - if !rollback_failures.is_empty() { - error_message.push_str("\n\nFailed to clean up: "); - error_message.push_str(&rollback_failures.join(", ")); - } - Err(anyhow!(error_message)) - } - - /// Attempts to check out a branch in a newly created worktree. - /// First tries checking out an existing branch, then tries creating a new - /// branch. If both fail, the worktree stays in detached HEAD state. - async fn try_checkout_branch_in_worktree( - repo: &Entity, - branch_name: &str, - worktree_path: &Path, - cx: &mut AsyncWindowContext, - ) { - // First, try checking out the branch (it may already exist). - let Ok(receiver) = cx.update(|_, cx| { - repo.update(cx, |repo, _cx| { - repo.checkout_branch_in_worktree( - branch_name.to_string(), - worktree_path.to_path_buf(), - false, - ) - }) - }) else { - log::warn!( - "Failed to check out branch {branch_name} for worktree at {}. \ - Staying in detached HEAD state.", - worktree_path.display(), - ); - - return; - }; - - let Ok(result) = receiver.await else { - log::warn!( - "Branch checkout was canceled for worktree at {}. \ - Staying in detached HEAD state.", - worktree_path.display() - ); - - return; - }; - - if let Err(err) = result { - log::info!( - "Failed to check out branch '{branch_name}' in worktree at {}, \ - will try creating it: {err}", - worktree_path.display() - ); - } else { - log::info!( - "Checked out branch '{branch_name}' in worktree at {}", - worktree_path.display() - ); - - return; - } - - // Checkout failed, so try creating the branch. - let create_result = cx.update(|_, cx| { - repo.update(cx, |repo, _cx| { - repo.checkout_branch_in_worktree( - branch_name.to_string(), - worktree_path.to_path_buf(), - true, - ) - }) - }); - - match create_result { - Ok(receiver) => match receiver.await { - Ok(Ok(())) => { - log::info!( - "Created and checked out branch '{branch_name}' in worktree at {}", - worktree_path.display() - ); - } - Ok(Err(err)) => { - log::warn!( - "Failed to create branch '{branch_name}' in worktree at {}: {err}. \ - Staying in detached HEAD state.", - worktree_path.display() - ); - } - Err(_) => { - log::warn!( - "Branch creation was canceled for worktree at {}. \ - Staying in detached HEAD state.", - worktree_path.display() - ); - } - }, - Err(err) => { - log::warn!( - "Failed to dispatch branch creation for worktree at {}: {err}. \ - Staying in detached HEAD state.", - worktree_path.display(), - ); - } - } - } - - fn capture_workspace_state( - workspace: &Workspace, - window: &Window, - cx: &App, - ) -> PreviousWorkspaceState { - let dock_structure = workspace.capture_dock_state(window, cx); - let open_file_paths = workspace.open_item_abs_paths(cx); - let active_file_path = workspace - .active_item(cx) - .and_then(|item| item.project_path(cx)) - .and_then(|pp| workspace.project().read(cx).absolute_path(&pp, cx)); - - PreviousWorkspaceState { - dock_structure, - open_file_paths, - active_file_path, - } - } - - fn create_worktree( - &mut self, - action: &CreateWorktree, - previous_workspace_state: PreviousWorkspaceState, - window: &mut Window, - cx: &mut Context, - ) { - if !self.project_has_git_repository(cx) { - log::error!("create_worktree: no git repository in the project"); - return; - } - if self.project.read(cx).is_via_collab() { - log::error!("create_worktree: not supported in collab projects"); - return; - } - if matches!( - self.worktree_creation_status, - Some(( - _, - WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_) - )) - ) { - return; - } - - let content = self.take_active_initial_content(cx); - let content_blocks = match content { - Some(AgentInitialContent::ContentBlock { blocks, .. }) => blocks, - _ => Vec::new(), - }; - - self.handle_worktree_requested( - content_blocks, - WorktreeCreationArgs::New { - worktree_name: action.worktree_name.clone(), - branch_target: action.branch_target.clone(), - }, - previous_workspace_state, - window, - cx, - ); - } - - fn switch_to_worktree( - &mut self, - action: &SwitchWorktree, - previous_workspace_state: PreviousWorkspaceState, - window: &mut Window, - cx: &mut Context, - ) { - if !self.project_has_git_repository(cx) { - log::error!("switch_to_worktree: no git repository in the project"); - return; - } - if self.project.read(cx).is_via_collab() { - log::error!("switch_to_worktree: not supported in collab projects"); - return; - } - if matches!( - self.worktree_creation_status, - Some(( - _, - WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_) - )) - ) { - return; - } - - let content = self.take_active_initial_content(cx); - let content_blocks = match content { - Some(AgentInitialContent::ContentBlock { blocks, .. }) => blocks, - _ => Vec::new(), - }; - - self.handle_worktree_requested( - content_blocks, - WorktreeCreationArgs::Linked { - worktree_path: action.path.clone(), - display_name: action.display_name.clone(), - }, - previous_workspace_state, - window, - cx, - ); - } - - fn set_worktree_creation_error( - &mut self, - message: SharedString, - window: &mut Window, - cx: &mut Context, - ) { - if let Some((_, status)) = &mut self.worktree_creation_status { - *status = WorktreeCreationStatus::Error(message); - } - if matches!(self.base_view, BaseView::Uninitialized) { - let selected_agent = self.selected_agent.clone(); - self.new_agent_thread(selected_agent, window, cx); - } - cx.notify(); - } - - fn handle_worktree_requested( - &mut self, - content: Vec, - args: WorktreeCreationArgs, - previous_workspace_state: PreviousWorkspaceState, - window: &mut Window, - cx: &mut Context, - ) { - if matches!( - self.worktree_creation_status, - Some(( - _, - WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_) - )) - ) { - return; - } - - let conversation_view_id = self - .active_conversation_view() - .map(|v| v.entity_id()) - .unwrap_or_else(|| EntityId::from(0u64)); - let display_name: SharedString = match &args { - WorktreeCreationArgs::New { - worktree_name: Some(name), - .. - } => name.clone().into(), - WorktreeCreationArgs::New { .. } => "worktree".into(), - WorktreeCreationArgs::Linked { display_name, .. } => display_name.clone().into(), - }; - let status = if matches!(args, WorktreeCreationArgs::Linked { .. }) { - WorktreeCreationStatus::Loading(display_name) - } else { - WorktreeCreationStatus::Creating(display_name) - }; - self.worktree_creation_status = Some((conversation_view_id, status)); - cx.notify(); - - let (git_repos, non_git_paths) = self.classify_worktrees(cx); - - if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() { - self.set_worktree_creation_error( - "No git repositories found in the project".into(), - window, - cx, - ); - return; - } - - let remote_connection_options = self.project.read(cx).remote_connection_options(cx); - - if remote_connection_options.is_some() { - let is_disconnected = self - .project - .read(cx) - .remote_client() - .is_some_and(|client| client.read(cx).is_disconnected()); - if is_disconnected { - self.set_worktree_creation_error( - "Cannot create worktree: remote connection is not active".into(), - window, - cx, - ); - return; - } - } - - let workspace = self.workspace.clone(); - let window_handle = window - .window_handle() - .downcast::(); - - let selected_agent = self.selected_agent(); - - let git_repo_work_dirs: Vec = git_repos - .iter() - .map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf()) - .collect(); - - let task = cx.spawn_in(window, async move |this, cx| { - let (all_paths, path_remapping, has_non_git) = match args { - WorktreeCreationArgs::New { - worktree_name, - branch_target, - } => { - let worktree_receivers: Vec<_> = this.update_in(cx, |_this, _window, cx| { - git_repos - .iter() - .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees())) - .collect() - })?; - let worktree_directory_setting = this.update_in(cx, |_this, _window, cx| { - ProjectSettings::get_global(cx) - .git - .worktree_directory - .clone() - })?; - - let mut existing_worktree_names = Vec::new(); - let mut existing_worktree_paths = HashSet::default(); - for result in futures::future::join_all(worktree_receivers).await { - match result { - Ok(Ok(worktrees)) => { - for worktree in worktrees { - if let Some(name) = worktree - .path - .parent() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - { - existing_worktree_names.push(name.to_string()); - } - existing_worktree_paths.insert(worktree.path.clone()); - } - } - Ok(Err(err)) => { - Err::<(), _>(err).log_err(); - } - Err(_) => {} - } - } - - let mut rng = rand::rng(); - - let (branch_to_checkout, base_ref) = - Self::resolve_worktree_branch_target(&branch_target); - - let (creation_infos, path_remapping) = - match this.update_in(cx, |_this, _window, cx| { - Self::start_worktree_creations( - &git_repos, - worktree_name, - &existing_worktree_names, - &existing_worktree_paths, - base_ref, - &worktree_directory_setting, - &mut rng, - cx, - ) - }) { - Ok(Ok(result)) => result, - Ok(Err(err)) | Err(err) => { - this.update_in(cx, |this, window, cx| { - this.set_worktree_creation_error( - format!("Failed to validate worktree directory: {err}") - .into(), - window, - cx, - ); - }) - .log_err(); - return anyhow::Ok(()); - } - }; - - let repo_paths: Vec<(Entity, PathBuf)> = - creation_infos - .iter() - .map(|(repo, path, _)| (repo.clone(), path.clone())) - .collect(); - - let fs = cx.update(|_, cx| ::global(cx))?; - - let created_paths = - match Self::await_and_rollback_on_failure(creation_infos, fs, cx).await { - Ok(paths) => paths, - Err(err) => { - this.update_in(cx, |this, window, cx| { - this.set_worktree_creation_error( - format!("{err}").into(), - window, - cx, - ); - })?; - return anyhow::Ok(()); - } - }; - - if let Some(ref branch_name) = branch_to_checkout { - for (repo, worktree_path) in &repo_paths { - Self::try_checkout_branch_in_worktree( - repo, - branch_name, - worktree_path, - cx, - ) - .await; - } - } - - let mut all_paths = created_paths; - let has_non_git = !non_git_paths.is_empty(); - all_paths.extend(non_git_paths.iter().cloned()); - (all_paths, path_remapping, has_non_git) - } - WorktreeCreationArgs::Linked { worktree_path, .. } => { - let path_remapping: Vec<(PathBuf, PathBuf)> = git_repo_work_dirs - .iter() - .map(|work_dir| (work_dir.clone(), worktree_path.clone())) - .collect(); - let mut all_paths = vec![worktree_path]; - let has_non_git = !non_git_paths.is_empty(); - all_paths.extend(non_git_paths.iter().cloned()); - (all_paths, path_remapping, has_non_git) - } - }; - - if workspace.upgrade().is_none() { - this.update_in(cx, |this, window, cx| { - this.set_worktree_creation_error( - "Workspace no longer available".into(), - window, - cx, - ); - })?; - return anyhow::Ok(()); - } - - let this_for_error = this.clone(); - if let Err(err) = Self::open_worktree_workspace_and_start_thread( - this, - all_paths, - window_handle, - previous_workspace_state, - path_remapping, - non_git_paths, - has_non_git, - content, - selected_agent, - remote_connection_options, - cx, - ) - .await - { - this_for_error - .update_in(cx, |this, window, cx| { - this.set_worktree_creation_error( - format!("Failed to set up workspace: {err}").into(), - window, - cx, - ); - }) - .log_err(); - } - anyhow::Ok(()) - }); - - self._worktree_creation_task = Some(cx.background_spawn(async move { - task.await.log_err(); - })); - } - - async fn open_worktree_workspace_and_start_thread( - this: WeakEntity, - all_paths: Vec, - window_handle: Option>, - previous_workspace_state: PreviousWorkspaceState, - path_remapping: Vec<(PathBuf, PathBuf)>, - non_git_paths: Vec, - has_non_git: bool, - content: Vec, - selected_agent: Option, - remote_connection_options: Option, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - let window_handle = window_handle - .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?; - - let (workspace_task, modal_workspace) = - window_handle.update(cx, |multi_workspace, window, cx| { - let path_list = PathList::new(&all_paths); - let active_workspace = multi_workspace.workspace().clone(); - let modal_workspace = active_workspace.clone(); - - let dock_structure = previous_workspace_state.dock_structure; - let init = Box::new( - move |workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context| { - workspace.set_dock_structure(dock_structure, window, cx); - }, - ); - - let task = multi_workspace.find_or_create_workspace( - path_list, - remote_connection_options, - None, - move |connection_options, window, cx| { - remote_connection::connect_with_modal( - &active_workspace, - connection_options, - window, - cx, - ) - }, - &[], - Some(init), - OpenMode::Add, - window, - cx, - ); - (task, modal_workspace) - })?; - - let result = workspace_task.await; - remote_connection::dismiss_connection_modal(&modal_workspace, cx); - let new_workspace = result?; - - let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()); - - if let Some(task) = panels_task { - task.await.log_err(); - } - - new_workspace - .update(cx, |workspace, cx| { - workspace.project().read(cx).wait_for_initial_scan(cx) - }) - .await; - - new_workspace - .update(cx, |workspace, cx| { - let repos = workspace - .project() - .read(cx) - .repositories(cx) - .values() - .cloned() - .collect::>(); - - let tasks = repos - .into_iter() - .map(|repo| repo.update(cx, |repo, _| repo.barrier())); - futures::future::join_all(tasks) - }) - .await; - - Self::maybe_propagate_worktree_trust(&this, &new_workspace, &all_paths, cx); - - let initial_content = AgentInitialContent::ContentBlock { - blocks: content, - auto_submit: false, - }; - - window_handle.update(cx, |_multi_workspace, window, cx| { - new_workspace.update(cx, |workspace, cx| { - if has_non_git { - let toast_id = workspace::notifications::NotificationId::unique::(); - workspace.show_toast( - workspace::Toast::new( - toast_id, - "Some project folders are not git repositories. \ - They were included as-is without creating a worktree.", - ), - cx, - ); - } - - // Remap every previously-open file path into the new worktree. - // Paths that can't be remapped (e.g. files that don't exist on - // the target branch) are silently skipped — best-effort. - let remap_path = |original_path: PathBuf| -> Option { - let best_match = path_remapping - .iter() - .filter_map(|(old_root, new_root)| { - original_path.strip_prefix(old_root).ok().map(|relative| { - (old_root.components().count(), new_root.join(relative)) - }) - }) - .max_by_key(|(depth, _)| *depth); - - if let Some((_, remapped_path)) = best_match { - return Some(remapped_path); - } - - for non_git in &non_git_paths { - if original_path.starts_with(non_git) { - return Some(original_path); - } - } - None - }; - - let remapped_active_path = previous_workspace_state - .active_file_path - .and_then(|p| remap_path(p)); - - // Collect all remapped paths, deduplicating and preserving order. - // The active file is placed last so it ends up as the focused tab. - let mut paths_to_open: Vec = Vec::new(); - let mut seen = HashSet::default(); - for path in previous_workspace_state.open_file_paths { - if let Some(remapped) = remap_path(path) { - if remapped_active_path.as_ref() != Some(&remapped) - && seen.insert(remapped.clone()) - { - paths_to_open.push(remapped); - } - } - } - - if let Some(active) = &remapped_active_path { - if seen.insert(active.clone()) { - paths_to_open.push(active.clone()); - } - } - - if !paths_to_open.is_empty() { - let open_task = workspace.open_paths( - paths_to_open, - workspace::OpenOptions { - focus: Some(false), - ..Default::default() - }, - None, - window, - cx, - ); - cx.spawn(async move |_, _| -> anyhow::Result<()> { - for item in open_task.await.into_iter().flatten() { - // Best-effort: files that don't exist on the target - // branch will fail to open and that's fine. - item.log_err(); - } - Ok(()) - }) - .detach_and_log_err(cx); - } - }); - })?; - - window_handle.update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(new_workspace.clone(), window, cx); - - new_workspace.update(cx, |workspace, cx| { - workspace.run_create_worktree_tasks(window, cx); - - workspace.focus_panel::(window, cx); - - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.external_thread( - selected_agent, - None, - None, - None, - Some(initial_content), - true, - "agent_panel", - window, - cx, - ); - }); - } - }) - })?; - - this.update_in(cx, |this, window, cx| { - this.worktree_creation_status = None; - - if let Some(thread_view) = this.active_thread_view(cx) { - thread_view.update(cx, |thread_view, cx| { - thread_view - .message_editor - .update(cx, |editor, cx| editor.clear(window, cx)); - }); - } - - this.serialize(cx); - cx.notify(); - })?; - - anyhow::Ok(()) - } -} - -impl Focusable for AgentPanel { - fn focus_handle(&self, cx: &App) -> FocusHandle { - match self.visible_surface() { - VisibleSurface::Uninitialized => self.focus_handle.clone(), - VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), - VisibleSurface::History(view) => view.read(cx).focus_handle(cx), - VisibleSurface::Configuration(configuration) => { - if let Some(configuration) = configuration { - configuration.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } - } - } -} - -fn agent_panel_dock_position(cx: &App) -> DockPosition { - AgentSettings::get_global(cx).dock.into() -} - -pub enum AgentPanelEvent { - ActiveViewChanged, - ThreadFocused, - RetainedThreadChanged, - MessageSentOrQueued { thread_id: ThreadId }, -} - -impl EventEmitter for AgentPanel {} -impl EventEmitter for AgentPanel {} - -impl Panel for AgentPanel { - fn persistent_name() -> &'static str { - "AgentPanel" - } - - fn panel_key() -> &'static str { - AGENT_PANEL_KEY - } - - fn position(&self, _window: &Window, cx: &App) -> DockPosition { - agent_panel_dock_position(cx) - } - - fn position_is_valid(&self, position: DockPosition) -> bool { - position != DockPosition::Bottom - } - - fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { - let side = match position { - DockPosition::Left => "left", - DockPosition::Right | DockPosition::Bottom => "right", - }; - telemetry::event!("Agent Panel Side Changed", side = side); - settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { - settings - .agent - .get_or_insert_default() - .set_dock(position.into()); - }); - } - - fn default_size(&self, window: &Window, cx: &App) -> Pixels { - let settings = AgentSettings::get_global(cx); - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => settings.default_width, - DockPosition::Bottom => settings.default_height, - } - } - - fn min_size(&self, window: &Window, cx: &App) -> Option { - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => Some(MIN_PANEL_WIDTH), - DockPosition::Bottom => None, - } - } - - fn supports_flexible_size(&self) -> bool { - true - } + fn supports_flexible_size(&self) -> bool { + true + } fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool { AgentSettings::get_global(cx).flexible @@ -3797,14 +2621,123 @@ impl Panel for AgentPanel { impl AgentPanel { fn ensure_thread_initialized(&mut self, window: &mut Window, cx: &mut Context) { - if matches!(self.base_view, BaseView::Uninitialized) - && !matches!( - self.worktree_creation_status, - Some((_, WorktreeCreationStatus::Creating(_))) - ) - { - self.activate_draft(false, window, cx); + if matches!(self.base_view, BaseView::Uninitialized) { + self.activate_draft(false, "agent_panel", window, cx); + } + } + + fn destination_has_meaningful_state(&self, cx: &App) -> bool { + if self.overlay_view.is_some() || !self.retained_threads.is_empty() { + return true; + } + + match &self.base_view { + BaseView::Uninitialized => false, + BaseView::AgentThread { conversation_view } => { + let has_entries = conversation_view + .read(cx) + .root_thread_view() + .is_some_and(|tv| !tv.read(cx).thread.read(cx).entries().is_empty()); + if has_entries { + return true; + } + + conversation_view + .read(cx) + .root_thread_view() + .is_some_and(|thread_view| { + let thread_view = thread_view.read(cx); + thread_view + .thread + .read(cx) + .draft_prompt() + .is_some_and(|draft| !draft.is_empty()) + || !thread_view + .message_editor + .read(cx) + .text(cx) + .trim() + .is_empty() + }) + } + } + } + + fn active_initial_content(&self, cx: &App) -> Option { + self.active_thread_view(cx).and_then(|thread_view| { + thread_view + .read(cx) + .thread + .read(cx) + .draft_prompt() + .map(|draft| AgentInitialContent::ContentBlock { + blocks: draft.to_vec(), + auto_submit: false, + }) + .filter(|initial_content| match initial_content { + AgentInitialContent::ContentBlock { blocks, .. } => !blocks.is_empty(), + _ => true, + }) + .or_else(|| { + let text = thread_view.read(cx).message_editor.read(cx).text(cx); + if text.trim().is_empty() { + None + } else { + Some(AgentInitialContent::ContentBlock { + blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))], + auto_submit: false, + }) + } + }) + }) + } + + fn source_panel_initialization( + source_workspace: &WeakEntity, + cx: &App, + ) -> Option<(Agent, AgentInitialContent)> { + let source_workspace = source_workspace.upgrade()?; + let source_panel = source_workspace.read(cx).panel::(cx)?; + let source_panel = source_panel.read(cx); + let initial_content = source_panel.active_initial_content(cx)?; + let agent = if source_panel.project.read(cx).is_via_collab() { + Agent::NativeAgent + } else { + source_panel.selected_agent.clone() + }; + Some((agent, initial_content)) + } + + pub fn initialize_from_source_workspace_if_needed( + &mut self, + source_workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.destination_has_meaningful_state(cx) { + return false; } + + let Some((agent, initial_content)) = + Self::source_panel_initialization(&source_workspace, cx) + else { + return false; + }; + + let thread = self.create_agent_thread( + agent, + None, + None, + None, + Some(initial_content), + "agent_panel", + window, + cx, + ); + self.draft_thread = Some(thread.conversation_view.clone()); + self.observe_draft_editor(&thread.conversation_view, cx); + self.set_base_view(thread.into(), false, window, cx); + true } fn render_title_view(&self, _window: &mut Window, cx: &Context) -> AnyElement { @@ -3889,7 +2822,6 @@ impl AgentPanel { .into_any_element() } } - VisibleSurface::History(_) => Label::new("History").truncate().into_any_element(), VisibleSurface::Configuration(_) => { Label::new("Settings").truncate().into_any_element() } @@ -3962,7 +2894,7 @@ impl AgentPanel { } }, ) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .with_handle(self.agent_panel_menu_handle.clone()) .menu({ move |window, cx| { @@ -4016,47 +2948,6 @@ impl AgentPanel { }) } - fn render_recent_entries_menu( - &self, - icon: IconName, - corner: Corner, - cx: &mut Context, - ) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); - - PopoverMenu::new("agent-nav-menu") - .trigger_with_tooltip( - IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small), - { - move |_window, cx| { - Tooltip::for_action_in( - "Toggle Recently Updated Threads", - &ToggleNavigationMenu, - &focus_handle, - cx, - ) - } - }, - ) - .anchor(corner) - .with_handle(self.agent_navigation_menu_handle.clone()) - .menu({ - let menu = self.agent_navigation_menu.clone(); - move |window, cx| { - telemetry::event!("View Thread History Clicked"); - - if let Some(menu) = menu.as_ref() { - menu.update(cx, |_, cx| { - cx.defer_in(window, |menu, window, cx| { - menu.rebuild(window, cx); - }); - }) - } - menu.clone() - } - }) - } - fn render_toolbar_back_button(&self, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); @@ -4072,121 +2963,9 @@ impl AgentPanel { }) } - fn project_has_git_repository(&self, cx: &App) -> bool { - !self.project.read(cx).repositories(cx).is_empty() - } - - fn is_active_view_creating_worktree(&self, _cx: &App) -> bool { - match &self.worktree_creation_status { - Some((view_id, WorktreeCreationStatus::Creating(_))) => { - self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id) - } - _ => false, - } - } - - fn is_active_view_loading_worktree(&self, _cx: &App) -> bool { - match &self.worktree_creation_status { - Some((view_id, WorktreeCreationStatus::Loading(_))) => { - self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id) - } - _ => false, - } - } - - fn current_worktree_label(&self, cx: &App) -> SharedString { - let project = self.project.read(cx); - - if let Some(repo) = project.active_repository(cx) { - let repo = repo.read(cx); - let main_path = &repo.original_repo_abs_path; - let current_path = &repo.work_directory_abs_path; - - return linked_worktree_short_name(main_path, current_path) - .unwrap_or_else(|| "main worktree".into()); - } - - project - .visible_worktrees(cx) - .next() - .and_then(|wt| { - wt.read(cx) - .abs_path() - .file_name() - .and_then(|name| name.to_str()) - .map(|name| SharedString::from(name.to_string())) - }) - .unwrap_or_else(|| "Worktree".into()) - } - - fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement { - let is_creating = self.is_active_view_creating_worktree(cx); - let is_loading = self.is_active_view_loading_worktree(cx); - let is_busy = is_creating || is_loading; - - let label = match &self.worktree_creation_status { - Some((view_id, WorktreeCreationStatus::Creating(name))) - if self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id) => - { - SharedString::from(format!("Creating {name}…")) - } - Some((view_id, WorktreeCreationStatus::Loading(name))) - if self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id) => - { - SharedString::from(format!("Loading {name}…")) - } - _ => self.current_worktree_label(cx), - }; - - let chevron_icon = if self.start_thread_in_menu_handle.is_deployed() { - IconName::ChevronUp - } else { - IconName::ChevronDown - }; - - let focus_handle = self.focus_handle(cx); - - let trigger_button = Button::new("thread-target-trigger", label) - .disabled(is_busy) - .loading(is_busy) - .start_icon( - Icon::new(IconName::GitWorktree) - .size(IconSize::Small) - .color(Color::Muted), - ) - .end_icon( - Icon::new(chevron_icon) - .size(IconSize::XSmall) - .color(Color::Muted), - ); - - let project = self.project.clone(); - - PopoverMenu::new("thread-target-selector") - .trigger_with_tooltip(trigger_button, { - move |_window, cx| { - Tooltip::for_action_in( - "Select Worktree…", - &ToggleWorktreeSelector, - &focus_handle, - cx, - ) - } - }) - .menu(move |window, cx| { - Some(cx.new(|cx| ThreadWorktreePicker::new(project.clone(), window, cx))) - }) - .with_handle(self.start_thread_in_menu_handle.clone()) - .anchor(Corner::TopLeft) - .offset(gpui::Point { - x: px(1.0), - y: px(1.0), - }) - } - fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let agent_server_store = self.project.read(cx).agent_server_store().clone(); - let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some(); + let focus_handle = self.focus_handle(cx); let (selected_agent_custom_icon, selected_agent_label) = @@ -4267,15 +3046,13 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.selected_agent = Agent::NativeAgent; - let id = panel.create_thread( - "agent_panel", + panel.new_external_agent_thread( + &NewExternalAgentThread { + agent: Some(Agent::NativeAgent), + }, window, cx, ); - panel.activate_retained_thread( - id, true, window, cx, - ); }); } }); @@ -4356,17 +3133,15 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.selected_agent = Agent::Custom { - id: agent_id.clone(), - }; - let id = panel.create_thread( - "agent_panel", + panel.new_external_agent_thread( + &NewExternalAgentThread { + agent: Some(Agent::Custom { + id: agent_id.clone(), + }), + }, window, cx, ); - panel.activate_retained_thread( - id, true, window, cx, - ); }); } }); @@ -4442,11 +3217,9 @@ impl AgentPanel { selected_agent.into_any_element() }; - let show_history_menu = self.has_history_for_selected_agent(cx); - let agent_v2_enabled = agent_v2_enabled(cx); let is_empty_state = !self.active_thread_has_messages(cx); - let is_in_history_or_config = self.is_history_or_configuration_visible(); + let is_in_history_or_config = self.is_overlay_open(); let is_full_screen = self.is_zoomed(window, cx); let full_screen_button = if is_full_screen { @@ -4465,15 +3238,14 @@ impl AgentPanel { })) }; - let use_v2_empty_toolbar = agent_v2_enabled && is_empty_state && !is_in_history_or_config; + let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config; let max_content_width = AgentSettings::get_global(cx).max_content_width; let base_container = h_flex() .size_full() - // TODO: This is only until we remove Agent settings from the panel. .when(!is_in_history_or_config, |this| { - this.max_w(max_content_width).mx_auto() + this.when_some(max_content_width, |this, max_w| this.max_w(max_w).mx_auto()) }) .flex_none() .justify_between() @@ -4521,7 +3293,7 @@ impl AgentPanel { move |window, cx| builder(window, cx) }) .with_handle(self.new_thread_menu_handle.clone()) - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .offset(gpui::Point { x: px(1.0), y: px(1.0), @@ -4533,13 +3305,7 @@ impl AgentPanel { .size_full() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) - .child(agent_selector_menu) - .when( - agent_v2_enabled - && has_visible_worktrees - && self.project_has_git_repository(cx), - |this| this.child(self.render_start_thread_in_selector(cx)), - ), + .child(agent_selector_menu), ) .child( h_flex() @@ -4548,13 +3314,6 @@ impl AgentPanel { .gap_1() .pl_1() .pr_1() - .when(show_history_menu && !agent_v2_enabled, |this| { - this.child(self.render_recent_entries_menu( - IconName::MenuAltTemp, - Corner::TopRight, - cx, - )) - }) .child(full_screen_button) .child(self.render_panel_options_menu(window, cx)), ) @@ -4575,7 +3334,7 @@ impl AgentPanel { } }, ) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .with_handle(self.new_thread_menu_handle.clone()) .menu(move |window, cx| new_thread_menu_builder(window, cx)); @@ -4600,13 +3359,6 @@ impl AgentPanel { .pl_1() .pr_1() .child(new_thread_menu) - .when(show_history_menu && !agent_v2_enabled, |this| { - this.child(self.render_recent_entries_menu( - IconName::MenuAltTemp, - Corner::TopRight, - cx, - )) - }) .child(full_screen_button) .child(self.render_panel_options_menu(window, cx)), ) @@ -4624,35 +3376,6 @@ impl AgentPanel { .child(toolbar_content) } - fn render_worktree_creation_status(&self, cx: &mut Context) -> Option { - let (view_id, status) = self.worktree_creation_status.as_ref()?; - let active_view_id = self.active_conversation_view().map(|v| v.entity_id()); - if active_view_id != Some(*view_id) { - return None; - } - match status { - WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_) => None, - WorktreeCreationStatus::Error(message) => Some( - Callout::new() - .icon(IconName::XCircleFilled) - .severity(Severity::Error) - .title("Worktree Creation Error") - .description(message.clone()) - .border_position(ui::BorderPosition::Bottom) - .dismiss_action( - IconButton::new("dismiss-worktree-error", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss")) - .on_click(cx.listener(|this, _, _, cx| { - this.worktree_creation_status = None; - cx.notify(); - })), - ) - .into_any_element(), - ), - } - } - fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool { if TrialEndUpsell::dismissed(cx) { return false; @@ -4723,17 +3446,13 @@ impl AgentPanel { match &self.base_view { BaseView::Uninitialized => false, - BaseView::AgentThread { conversation_view } - if conversation_view.read(cx).as_native_thread(cx).is_none() => - { - false - } BaseView::AgentThread { conversation_view } => { - let history_is_empty = conversation_view - .read(cx) - .history() - .is_none_or(|h| h.read(cx).is_empty()); - history_is_empty || !has_configured_non_zed_providers + if conversation_view.read(cx).as_native_thread(cx).is_some() { + let history_is_empty = ThreadStore::global(cx).read(cx).is_empty(); + history_is_empty || !has_configured_non_zed_providers + } else { + false + } } } } @@ -4921,16 +3640,12 @@ impl Render for AgentPanel { .on_action(cx.listener(|this, action: &NewThread, window, cx| { this.new_thread(action, window, cx); })) - .on_action(cx.listener(|this, _: &OpenHistory, window, cx| { - this.open_history(window, cx); - })) .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { this.open_configuration(window, cx); })) .on_action(cx.listener(Self::open_active_thread_as_markdown)) .on_action(cx.listener(Self::deploy_rules_library)) .on_action(cx.listener(Self::go_back)) - .on_action(cx.listener(Self::toggle_navigation_menu)) .on_action(cx.listener(Self::toggle_options_menu)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) @@ -4951,12 +3666,10 @@ impl Render for AgentPanel { VisibleSurface::AgentThread(conversation_view) => parent .child(conversation_view.clone()) .child(self.render_drag_target(cx)), - VisibleSurface::History(view) => parent.child(view.clone()), VisibleSurface::Configuration(configuration) => { parent.children(configuration.cloned()) } }) - .children(self.render_worktree_creation_status(cx)) .children(self.render_trial_end_upsell(window, cx)); match self.visible_font_size() { @@ -4996,13 +3709,6 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { let Some(panel) = workspace.read(cx).panel::(cx) else { return; }; - let history = panel - .read(cx) - .connection_store() - .read(cx) - .entry(&crate::Agent::NativeAgent) - .and_then(|s| s.read(cx).history()) - .map(|h| h.downgrade()); let project = workspace.read(cx).project().downgrade(); let panel = panel.read(cx); let thread_store = panel.thread_store().clone(); @@ -5012,7 +3718,6 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { project, thread_store, None, - history, initial_prompt, window, cx, @@ -5078,38 +3783,41 @@ impl AgentPanel { self.set_base_view(thread.into(), true, window, cx); } - /// Returns the currently active thread view, if any. - /// - /// This is a test-only accessor that exposes the private `active_thread_view()` - /// method for test assertions. Not compiled into production builds. - pub fn active_thread_view_for_tests(&self) -> Option<&Entity> { - self.active_conversation_view() - } - - /// Opens the history view. - /// - /// This is a test-only helper that exposes the private `open_history()` - /// method for visual tests. - pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context) { - self.open_history(window, cx); - } - - /// Opens the start_thread_in selector popover menu. + /// Opens a restored external thread with an arbitrary AgentServer and + /// a specific `resume_session_id` — as if we just restored from the KVP. /// - /// This is a test-only helper for visual tests. - pub fn open_start_thread_in_menu_for_tests( + /// Test-only helper. Not compiled into production builds. + pub fn open_restored_thread_with_server( &mut self, + server: Rc, + resume_session_id: acp::SessionId, window: &mut Window, cx: &mut Context, ) { - self.start_thread_in_menu_handle.show(window, cx); + let ext_agent = Agent::Custom { + id: server.agent_id(), + }; + + let thread = self.create_agent_thread_with_server( + ext_agent, + Some(server), + Some(resume_session_id), + None, + None, + None, + "agent_panel", + window, + cx, + ); + self.set_base_view(thread.into(), true, window, cx); } - /// Dismisses the start_thread_in dropdown menu. + /// Returns the currently active thread view, if any. /// - /// This is a test-only helper for visual tests. - pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context) { - self.start_thread_in_menu_handle.hide(cx); + /// This is a test-only accessor that exposes the private `active_thread_view()` + /// method for test assertions. Not compiled into production builds. + pub fn active_thread_view_for_tests(&self) -> Option<&Entity> { + self.active_conversation_view() } /// Creates a draft thread using a stub server and sets it as the active view. @@ -5142,6 +3850,7 @@ impl AgentPanel { #[cfg(test)] mod tests { use super::*; + use crate::NewWorktreeBranchTarget; use crate::conversation_view::tests::{StubAgentServer, init_test}; use crate::test_support::{ active_session_id, active_thread_id, open_thread_with_connection, @@ -5149,8 +3858,7 @@ mod tests { }; use acp_thread::{AgentConnection, StubAgentConnection, ThreadStatus, UserMessageId}; use action_log::ActionLog; - use agent_servers::CODEX_ID; - use anyhow::Result; + use anyhow::{Result, anyhow}; use feature_flags::FeatureFlagAppExt; use fs::FakeFs; use gpui::{App, TestAppContext, VisualTestContext}; @@ -5497,6 +4205,115 @@ mod tests { }); } + #[gpui::test] + async fn test_serialize_preserves_session_id_in_load_error(cx: &mut TestAppContext) { + use crate::conversation_view::tests::FlakyAgentServer; + use crate::thread_metadata_store::{ThreadId, ThreadMetadata}; + use chrono::Utc; + use project::{AgentId as ProjectAgentId, WorktreePaths}; + + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + let workspace_id = workspace + .read_with(cx, |workspace, _cx| workspace.database_id()) + .expect("workspace should have a database id"); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Simulate a previous run that persisted metadata for this session. + let resume_session_id = acp::SessionId::new("persistent-session"); + cx.update(|_window, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + thread_id: ThreadId::new(), + session_id: Some(resume_session_id.clone()), + agent_id: ProjectAgentId::new("Flaky"), + title: Some("Persistent chat".into()), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + interacted_at: None, + worktree_paths: WorktreePaths::from_folder_paths(&PathList::default()), + remote_connection: None, + archived: false, + }, + cx, + ); + }); + }); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + cx.new(|cx| AgentPanel::new(workspace, None, window, cx)) + }); + + // Open a restored thread using a flaky server so the initial connect + // fails and the view lands in LoadError — mirroring the cold-start + // race against a custom agent over SSH. + let (server, _fail) = + FlakyAgentServer::new(StubAgentConnection::new().with_supports_load_session(true)); + panel.update_in(cx, |panel, window, cx| { + panel.open_restored_thread_with_server( + Rc::new(server), + resume_session_id.clone(), + window, + cx, + ); + }); + cx.run_until_parked(); + + // Sanity: the view couldn't connect, so no live AcpThread exists. + panel.read_with(cx, |panel, cx| { + assert!( + panel.active_agent_thread(cx).is_none(), + "active_agent_thread should be None while the flaky server is failing" + ); + let conversation_view = panel + .active_conversation_view() + .expect("panel should still have an active ConversationView"); + assert_eq!( + conversation_view.read(cx).root_session_id.as_ref(), + Some(&resume_session_id), + "ConversationView should still hold the restored session id" + ); + }); + + // Serialize while in LoadError. Before the fix this wrote + // `session_id=None` to the KVP and permanently lost the session. + panel.update(cx, |panel, cx| panel.serialize(cx)); + cx.run_until_parked(); + + let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)); + let serialized: Option = cx + .background_spawn(async move { read_serialized_panel(workspace_id, &kvp) }) + .await; + let serialized_session_id = serialized + .as_ref() + .and_then(|p| p.last_active_thread.as_ref()) + .and_then(|t| t.session_id.clone()); + assert_eq!( + serialized_session_id, + Some(resume_session_id.0.to_string()), + "serialize() must preserve the restored session id even while the \ + ConversationView is in LoadError; otherwise the bug survives a \ + restart because the KVP has been wiped" + ); + } + /// Extracts the text from a Text content block, panicking if it's not Text. fn expect_text_block(block: &acp::ContentBlock) -> &str { match block { @@ -5856,7 +4673,7 @@ mod tests { )]); panel.update_in(cx, |panel, window, cx| { panel.selected_agent = Agent::Stub; - panel.activate_draft(true, window, cx); + panel.activate_draft(true, "agent_panel", window, cx); }); cx.run_until_parked(); @@ -6145,7 +4962,7 @@ mod tests { // Load thread A back via load_agent_thread — should promote from background. panel.update_in(&mut cx, |panel, window, cx| { panel.load_agent_thread( - panel.selected_agent().expect("selected agent must be set"), + panel.selected_agent(cx), session_id_a.clone(), None, None, @@ -6316,444 +5133,122 @@ mod tests { let mut loadable_thread_ids = Vec::new(); for _ in 0..7 { - let (session_id, thread_id) = open_generating_thread_with_loadable_connection( - &panel, - &loadable_connection, - &mut cx, - ); - loadable_session_ids.push(session_id); - loadable_thread_ids.push(thread_id); - } - - let base_time = Instant::now(); - - for session_id in loadable_session_ids.iter().take(6) { - loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn); - } - cx.run_until_parked(); - - panel.update(&mut cx, |panel, cx| { - for (index, thread_id) in loadable_thread_ids.iter().take(6).enumerate() { - let conversation_view = panel - .retained_threads - .get(thread_id) - .expect("retained thread should exist") - .clone(); - conversation_view.update(cx, |view, cx| { - view.set_updated_at(base_time + Duration::from_secs(index as u64), cx); - }); - } - panel.cleanup_retained_threads(cx); - }); - - panel.read_with(&cx, |panel, _cx| { - assert_eq!( - panel.retained_threads.len(), - 6, - "cleanup should keep the non-loadable idle thread in addition to five loadable ones" - ); - assert!( - panel.retained_threads.contains_key(&non_loadable_thread_id), - "idle non-loadable retained threads should not be cleanup candidates" - ); - assert!( - !panel.retained_threads.contains_key(&loadable_thread_ids[0]), - "oldest idle loadable retained thread should still be removed" - ); - for thread_id in &loadable_thread_ids[1..6] { - assert!( - panel.retained_threads.contains_key(thread_id), - "more recent idle loadable retained threads should be retained" - ); - } - assert!( - !panel.retained_threads.contains_key(&loadable_thread_ids[6]), - "the active loadable thread should not also be stored as a retained thread" - ); - }); - } - - #[test] - fn test_deserialize_agent_variants() { - // PascalCase (legacy AgentType format, persisted in panel state) - assert_eq!( - serde_json::from_str::(r#""NativeAgent""#).unwrap(), - Agent::NativeAgent, - ); - assert_eq!( - serde_json::from_str::(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(), - Agent::Custom { - id: "my-agent".into(), - }, - ); - - // Legacy TextThread variant deserializes to NativeAgent - assert_eq!( - serde_json::from_str::(r#""TextThread""#).unwrap(), - Agent::NativeAgent, - ); - - // snake_case (canonical format) - assert_eq!( - serde_json::from_str::(r#""native_agent""#).unwrap(), - Agent::NativeAgent, - ); - assert_eq!( - serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), - Agent::Custom { - id: "my-agent".into(), - }, - ); - - // Serialization uses snake_case - assert_eq!( - serde_json::to_string(&Agent::NativeAgent).unwrap(), - r#""native_agent""#, - ); - assert_eq!( - serde_json::to_string(&Agent::Custom { - id: "my-agent".into() - }) - .unwrap(), - r#"{"custom":{"name":"my-agent"}}"#, - ); - } - - #[gpui::test] - fn test_resolve_worktree_branch_target() { - let resolved = - AgentPanel::resolve_worktree_branch_target(&NewWorktreeBranchTarget::CreateBranch { - name: "new-branch".to_string(), - from_ref: Some("main".to_string()), - }); - assert_eq!( - resolved, - (Some("new-branch".to_string()), Some("main".to_string())) - ); - - let resolved = - AgentPanel::resolve_worktree_branch_target(&NewWorktreeBranchTarget::CreateBranch { - name: "new-branch".to_string(), - from_ref: None, - }); - assert_eq!(resolved, (Some("new-branch".to_string()), None)); - - let resolved = - AgentPanel::resolve_worktree_branch_target(&NewWorktreeBranchTarget::ExistingBranch { - name: "feature".to_string(), - }); - assert_eq!( - resolved, - (Some("feature".to_string()), Some("feature".to_string())) - ); - - let resolved = - AgentPanel::resolve_worktree_branch_target(&NewWorktreeBranchTarget::CurrentBranch); - assert_eq!(resolved, (None, None)); - } - - #[gpui::test] - async fn test_worktree_dir_name_is_random_when_using_existing_branch(cx: &mut TestAppContext) { - init_test(cx); - - let app_state = cx.update(|cx| { - agent::ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - - let app_state = workspace::AppState::test(cx); - workspace::init(app_state.clone(), cx); - app_state - }); - - let fs = app_state.fs.as_fake(); - fs.insert_tree( - "/project", - json!({ - ".git": {}, - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - // Put the main worktree on "develop" so that "main" is NOT - // occupied by any worktree. - fs.set_branch_name(Path::new("/project/.git"), Some("develop")); - fs.insert_branches(Path::new("/project/.git"), &["main", "develop"]); - - let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await; - - let multi_workspace = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - multi_workspace - .update(cx, |multi_workspace, _, cx| { - multi_workspace.open_sidebar(cx); - }) - .unwrap(); - - let workspace = multi_workspace - .read_with(cx, |multi_workspace, _cx| { - multi_workspace.workspace().clone() - }) - .unwrap(); - - workspace.update(cx, |workspace, _cx| { - workspace.set_random_database_id(); - }); - - cx.update(|cx| { - cx.observe_new( - |workspace: &mut Workspace, - window: Option<&mut Window>, - cx: &mut Context| { - if let Some(window) = window { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel, window, cx); - } - }, - ) - .detach(); - }); - - let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - cx.run_until_parked(); - - let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }); - - cx.run_until_parked(); - - panel.update_in(cx, |panel, window, cx| { - panel.open_external_thread_with_server( - Rc::new(StubAgentServer::default_response()), - window, - cx, - ); - }); - - cx.run_until_parked(); - - // Select "main" as an existing branch — this should NOT make the - // worktree directory named "main"; it should get a random name. - let content = vec![acp::ContentBlock::Text(acp::TextContent::new( - "Hello from test", - ))]; - panel.update_in(cx, |panel, window, cx| { - panel.handle_worktree_requested( - content, - WorktreeCreationArgs::New { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::ExistingBranch { - name: "main".to_string(), - }, - }, - PreviousWorkspaceState::empty(), - window, - cx, - ); - }); - - cx.run_until_parked(); - - // Find the new workspace and check its worktree path. - let new_worktree_path = multi_workspace - .read_with(cx, |multi_workspace, cx| { - let new_workspace = multi_workspace - .workspaces() - .find(|ws| ws.entity_id() != workspace.entity_id()) - .expect("a new workspace should have been created"); - - let new_project = new_workspace.read(cx).project().clone(); - let worktree = new_project - .read(cx) - .visible_worktrees(cx) - .next() - .expect("new workspace should have a worktree"); - worktree.read(cx).abs_path().to_path_buf() - }) - .unwrap(); - - // The worktree directory path should contain a random adjective-noun - // name, NOT the branch name "main". - let path_str = new_worktree_path.to_string_lossy(); - assert!( - !path_str.contains("/main/"), - "worktree directory should use a random name, not the branch name. \ - Got path: {path_str}", - ); - // Verify it looks like an adjective-noun pair (contains a hyphen in - // the directory component above the project name). - let parent = new_worktree_path - .parent() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - .expect("should have a parent directory name"); - assert!( - parent.contains('-'), - "worktree parent directory should be an adjective-noun pair (e.g. 'swift-falcon'), \ - got: {parent}", - ); - } - - #[gpui::test] - async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) { - init_test(cx); - - let app_state = cx.update(|cx| { - agent::ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - - let app_state = workspace::AppState::test(cx); - workspace::init(app_state.clone(), cx); - app_state - }); - - let fs = app_state.fs.as_fake(); - fs.insert_tree( - "/project", - json!({ - ".git": {}, - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - fs.set_branch_name(Path::new("/project/.git"), Some("main")); - - let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await; - - let multi_workspace = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - multi_workspace - .update(cx, |multi_workspace, _, cx| { - multi_workspace.open_sidebar(cx); - }) - .unwrap(); - - let workspace = multi_workspace - .read_with(cx, |multi_workspace, _cx| { - multi_workspace.workspace().clone() - }) - .unwrap(); - - workspace.update(cx, |workspace, _cx| { - workspace.set_random_database_id(); - }); - - // Register a callback so new workspaces also get an AgentPanel. - cx.update(|cx| { - cx.observe_new( - |workspace: &mut Workspace, - window: Option<&mut Window>, - cx: &mut Context| { - if let Some(window) = window { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel, window, cx); - } - }, - ) - .detach(); - }); - - let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - - // Wait for the project to discover the git repository. - cx.run_until_parked(); - - let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }); - - cx.run_until_parked(); - - // Open a thread (needed so there's an active thread view). - panel.update_in(cx, |panel, window, cx| { - panel.open_external_thread_with_server( - Rc::new(StubAgentServer::default_response()), - window, - cx, + let (session_id, thread_id) = open_generating_thread_with_loadable_connection( + &panel, + &loadable_connection, + &mut cx, ); - }); + loadable_session_ids.push(session_id); + loadable_thread_ids.push(thread_id); + } + let base_time = Instant::now(); + + for session_id in loadable_session_ids.iter().take(6) { + loadable_connection.end_turn(session_id.clone(), acp::StopReason::EndTurn); + } cx.run_until_parked(); - // Set the selected agent to Codex (a custom agent). We do this AFTER - // opening the thread because open_external_thread_with_server overrides - // selected_agent. - panel.update_in(cx, |panel, _window, cx| { - panel.selected_agent = Agent::Custom { - id: CODEX_ID.into(), - }; - cx.notify(); + panel.update(&mut cx, |panel, cx| { + for (index, thread_id) in loadable_thread_ids.iter().take(6).enumerate() { + let conversation_view = panel + .retained_threads + .get(thread_id) + .expect("retained thread should exist") + .clone(); + conversation_view.update(cx, |view, cx| { + view.set_updated_at(base_time + Duration::from_secs(index as u64), cx); + }); + } + panel.cleanup_retained_threads(cx); }); - // Verify the panel has the Codex agent selected. - panel.read_with(cx, |panel, _cx| { + panel.read_with(&cx, |panel, _cx| { assert_eq!( - panel.selected_agent, - Agent::Custom { - id: CODEX_ID.into() - }, + panel.retained_threads.len(), + 6, + "cleanup should keep the non-loadable idle thread in addition to five loadable ones" ); - }); - - // Directly call handle_worktree_requested to trigger worktree creation. - let content = vec![acp::ContentBlock::Text(acp::TextContent::new( - "Hello from test", - ))]; - panel.update_in(cx, |panel, window, cx| { - panel.handle_worktree_requested( - content, - WorktreeCreationArgs::New { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::default(), - }, - PreviousWorkspaceState::empty(), - window, - cx, + assert!( + panel.retained_threads.contains_key(&non_loadable_thread_id), + "idle non-loadable retained threads should not be cleanup candidates" ); - }); - - // Let the async worktree creation + workspace setup complete. - cx.run_until_parked(); - - // Find the new workspace's AgentPanel and verify it used the Codex agent. - let found_codex = multi_workspace - .read_with(cx, |multi_workspace, cx| { - // There should be more than one workspace now (the original + the new worktree). + assert!( + !panel.retained_threads.contains_key(&loadable_thread_ids[0]), + "oldest idle loadable retained thread should still be removed" + ); + for thread_id in &loadable_thread_ids[1..6] { assert!( - multi_workspace.workspaces().count() > 1, - "expected a new workspace to have been created, found {}", - multi_workspace.workspaces().count(), + panel.retained_threads.contains_key(thread_id), + "more recent idle loadable retained threads should be retained" ); + } + assert!( + !panel.retained_threads.contains_key(&loadable_thread_ids[6]), + "the active loadable thread should not also be stored as a retained thread" + ); + }); + } - // Check the newest workspace's panel for the correct agent. - let new_workspace = multi_workspace - .workspaces() - .find(|ws| ws.entity_id() != workspace.entity_id()) - .expect("should find the new workspace"); - let new_panel = new_workspace - .read(cx) - .panel::(cx) - .expect("new workspace should have an AgentPanel"); + #[test] + fn test_deserialize_agent_variants() { + // PascalCase (legacy AgentType format, persisted in panel state) + assert_eq!( + serde_json::from_str::(r#""NativeAgent""#).unwrap(), + Agent::NativeAgent, + ); + assert_eq!( + serde_json::from_str::(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(), + Agent::Custom { + id: "my-agent".into(), + }, + ); - new_panel.read(cx).selected_agent.clone() - }) - .unwrap(); + // Legacy TextThread variant deserializes to NativeAgent + assert_eq!( + serde_json::from_str::(r#""TextThread""#).unwrap(), + Agent::NativeAgent, + ); + // snake_case (canonical format) + assert_eq!( + serde_json::from_str::(r#""native_agent""#).unwrap(), + Agent::NativeAgent, + ); assert_eq!( - found_codex, + serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), Agent::Custom { - id: CODEX_ID.into() + id: "my-agent".into(), + }, + ); + + // Serialization uses snake_case + assert_eq!( + serde_json::to_string(&Agent::NativeAgent).unwrap(), + r#""native_agent""#, + ); + assert_eq!( + serde_json::to_string(&Agent::Custom { + id: "my-agent".into() + }) + .unwrap(), + r#"{"custom":{"name":"my-agent"}}"#, + ); + } + + #[gpui::test] + fn test_resolve_worktree_branch_target() { + let resolved = git_ui::worktree_service::resolve_worktree_branch_target( + &NewWorktreeBranchTarget::ExistingBranch { + name: "feature".to_string(), }, - "the new worktree workspace should use the same agent (Codex) that was selected in the original panel", ); + assert_eq!(resolved, Some("feature".to_string())); + + let resolved = git_ui::worktree_service::resolve_worktree_branch_target( + &NewWorktreeBranchTarget::CurrentBranch, + ); + assert_eq!(resolved, None); } #[gpui::test] @@ -7097,14 +5592,163 @@ mod tests { } #[gpui::test] - async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) { + async fn test_new_thread_uses_workspace_selected_agent(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let custom_agent = Agent::Custom { + id: "my-custom-agent".into(), + }; + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + // Set selected_agent to a custom agent + panel.update(cx, |panel, _cx| { + panel.selected_agent = custom_agent.clone(); + }); + + // Call new_thread, which internally calls external_thread(None, ...) + // This resolves the agent from self.selected_agent + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + + panel.read_with(cx, |panel, _cx| { + assert_eq!( + panel.selected_agent, custom_agent, + "selected_agent should remain the custom agent after new_thread" + ); + assert!( + panel.active_conversation_view().is_some(), + "a thread should have been created" + ); + }); + } + + #[gpui::test] + async fn test_draft_replaced_when_selected_agent_changes(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + ::set_global(fs.clone(), cx); + }); + + let project = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + // Create a draft with the default NativeAgent. + panel.update_in(cx, |panel, window, cx| { + panel.activate_draft(true, "agent_panel", window, cx); + }); + + let first_draft_id = panel.read_with(cx, |panel, cx| { + assert!(panel.draft_thread.is_some()); + assert_eq!(panel.selected_agent, Agent::NativeAgent); + let draft = panel.draft_thread.as_ref().unwrap(); + assert_eq!(*draft.read(cx).agent_key(), Agent::NativeAgent); + draft.entity_id() + }); + + // Switch selected_agent to a custom agent, then activate_draft again. + // The stale NativeAgent draft should be replaced. + let custom_agent = Agent::Custom { + id: "my-custom-agent".into(), + }; + panel.update_in(cx, |panel, window, cx| { + panel.selected_agent = custom_agent.clone(); + panel.activate_draft(true, "agent_panel", window, cx); + }); + + panel.read_with(cx, |panel, cx| { + let draft = panel.draft_thread.as_ref().expect("draft should exist"); + assert_ne!( + draft.entity_id(), + first_draft_id, + "a new draft should have been created" + ); + assert_eq!( + *draft.read(cx).agent_key(), + custom_agent, + "the new draft should use the custom agent" + ); + }); + + // Calling activate_draft again with the same agent should return the + // cached draft (no replacement). + let second_draft_id = panel.read_with(cx, |panel, _cx| { + panel.draft_thread.as_ref().unwrap().entity_id() + }); + + panel.update_in(cx, |panel, window, cx| { + panel.activate_draft(true, "agent_panel", window, cx); + }); + + panel.read_with(cx, |panel, _cx| { + assert_eq!( + panel.draft_thread.as_ref().unwrap().entity_id(), + second_draft_id, + "draft should be reused when the agent has not changed" + ); + }); + } + + #[gpui::test] + async fn test_activate_draft_preserves_typed_content(cx: &mut TestAppContext) { init_test(cx); + let fs = FakeFs::new(cx.executor()); cx.update(|cx| { agent::ThreadStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); + ::set_global(fs.clone(), cx); }); - let fs = FakeFs::new(cx.executor()); let project = Project::test(fs.clone(), [], cx).await; let multi_workspace = @@ -7122,41 +5766,55 @@ mod tests { let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - let custom_agent = Agent::Custom { - id: "my-custom-agent".into(), - }; - let panel = workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); workspace.add_panel(panel.clone(), window, cx); panel }); - // Set selected_agent to a custom agent - panel.update(cx, |panel, _cx| { - panel.selected_agent = custom_agent.clone(); + // Create a draft using the Stub agent, which connects synchronously. + panel.update_in(cx, |panel, window, cx| { + panel.selected_agent = Agent::Stub; + panel.activate_draft(true, "agent_panel", window, cx); }); + cx.run_until_parked(); - // Call new_thread, which internally calls external_thread(None, ...) - // This resolves the agent from self.selected_agent - panel.update_in(cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); + let initial_draft_id = panel.read_with(cx, |panel, _cx| { + panel.draft_thread.as_ref().unwrap().entity_id() + }); + + // Type some text into the draft editor. + let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Don't lose me!", window, cx); }); + // Press cmd-n (activate_draft again with the same agent). + cx.dispatch_action(NewExternalAgentThread { agent: None }); + cx.run_until_parked(); + + // The draft entity should not have changed. panel.read_with(cx, |panel, _cx| { assert_eq!( - panel.selected_agent, custom_agent, - "selected_agent should remain the custom agent after new_thread" - ); - assert!( - panel.active_conversation_view().is_some(), - "a thread should have been created" + panel.draft_thread.as_ref().unwrap().entity_id(), + initial_draft_id, + "cmd-n should not replace the draft when already on it" ); }); + + // The editor content should be preserved. + let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap()); + let text = panel.read_with(cx, |panel, cx| panel.editor_text(thread_id, cx)); + assert_eq!( + text.as_deref(), + Some("Don't lose me!"), + "typed content should be preserved when pressing cmd-n on the draft" + ); } #[gpui::test] - async fn test_draft_replaced_when_selected_agent_changes(cx: &mut TestAppContext) { + async fn test_draft_content_carried_over_when_switching_agents(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); cx.update(|cx| { @@ -7188,60 +5846,57 @@ mod tests { panel }); - // Create a draft with the default NativeAgent. + // Create a draft with a custom stub server that connects synchronously. panel.update_in(cx, |panel, window, cx| { - panel.activate_draft(true, window, cx); + panel.open_draft_with_server( + Rc::new(StubAgentServer::new(StubAgentConnection::new())), + window, + cx, + ); }); + cx.run_until_parked(); - let first_draft_id = panel.read_with(cx, |panel, cx| { - assert!(panel.draft_thread.is_some()); - assert_eq!(panel.selected_agent, Agent::NativeAgent); - let draft = panel.draft_thread.as_ref().unwrap(); - assert_eq!(*draft.read(cx).agent_key(), Agent::NativeAgent); - draft.entity_id() + let initial_draft_id = panel.read_with(cx, |panel, _cx| { + panel.draft_thread.as_ref().unwrap().entity_id() }); - // Switch selected_agent to a custom agent, then activate_draft again. - // The stale NativeAgent draft should be replaced. - let custom_agent = Agent::Custom { - id: "my-custom-agent".into(), - }; - panel.update_in(cx, |panel, window, cx| { - panel.selected_agent = custom_agent.clone(); - panel.activate_draft(true, window, cx); + // Type text into the first draft's editor. + let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("carry me over", window, cx); }); + // Switch to a different agent. ensure_draft should extract the typed + // content from the old draft and pre-fill the new one. + cx.dispatch_action(NewExternalAgentThread { + agent: Some(Agent::Stub), + }); + cx.run_until_parked(); + + // A new draft should have been created for the Stub agent. panel.read_with(cx, |panel, cx| { let draft = panel.draft_thread.as_ref().expect("draft should exist"); assert_ne!( draft.entity_id(), - first_draft_id, - "a new draft should have been created" + initial_draft_id, + "a new draft should have been created for the new agent" ); assert_eq!( *draft.read(cx).agent_key(), - custom_agent, - "the new draft should use the custom agent" + Agent::Stub, + "new draft should use the new agent" ); }); - // Calling activate_draft again with the same agent should return the - // cached draft (no replacement). - let second_draft_id = panel.read_with(cx, |panel, _cx| { - panel.draft_thread.as_ref().unwrap().entity_id() - }); - - panel.update_in(cx, |panel, window, cx| { - panel.activate_draft(true, window, cx); - }); - - panel.read_with(cx, |panel, _cx| { - assert_eq!( - panel.draft_thread.as_ref().unwrap().entity_id(), - second_draft_id, - "draft should be reused when the agent has not changed" - ); - }); + // The new draft's editor should contain the text typed in the old draft. + let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap()); + let text = panel.read_with(cx, |panel, cx| panel.editor_text(thread_id, cx)); + assert_eq!( + text.as_deref(), + Some("carry me over"), + "content should be carried over to the new agent's draft" + ); } #[gpui::test] @@ -7291,7 +5946,12 @@ mod tests { let result = multi_workspace .update(cx, |_, window, cx| { window.spawn(cx, async move |cx| { - AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await + git_ui::worktree_service::await_and_rollback_on_failure( + creation_infos, + fs_clone, + cx, + ) + .await }) }) .unwrap() @@ -7374,7 +6034,12 @@ mod tests { let result = multi_workspace .update(cx, |_, window, cx| { window.spawn(cx, async move |cx| { - AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await + git_ui::worktree_service::await_and_rollback_on_failure( + creation_infos, + fs_clone, + cx, + ) + .await }) }) .unwrap() @@ -7440,7 +6105,12 @@ mod tests { let result = multi_workspace .update(cx, |_, window, cx| { window.spawn(cx, async move |cx| { - AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await + git_ui::worktree_service::await_and_rollback_on_failure( + creation_infos, + fs_clone, + cx, + ) + .await }) }) .unwrap() @@ -7453,478 +6123,80 @@ mod tests { let err_msg = result.unwrap_err().to_string(); assert!( err_msg.contains("canceled"), - "error should mention cancellation: {err_msg}" - ); - } - - #[gpui::test] - async fn test_rollback_cleans_up_orphan_directories(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| { - cx.update_flags(true, vec!["agent-v2".to_string()]); - agent::ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - ::set_global(fs.clone(), cx); - }); - - fs.insert_tree( - "/project", - json!({ - ".git": {}, - "src": { "main.rs": "fn main() {}" } - }), - ) - .await; - - let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; - cx.executor().run_until_parked(); - - let repository = project.read_with(cx, |project, cx| { - project.repositories(cx).values().next().unwrap().clone() - }); - - let multi_workspace = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - - // Simulate the orphan state: create_dir_all was called but git - // worktree add failed, leaving a directory with leftover files. - let orphan_path = PathBuf::from("/worktrees/branch/orphan_project"); - fs.insert_tree( - "/worktrees/branch/orphan_project", - json!({ "leftover.txt": "junk" }), - ) - .await; - - assert!( - fs.is_dir(&orphan_path).await, - "orphan dir should exist before rollback" - ); - - let (sender, receiver) = futures::channel::oneshot::channel::>(); - sender.send(Err(anyhow!("hook failed"))).unwrap(); - - let creation_infos = vec![(repository.clone(), orphan_path.clone(), receiver)]; - - let fs_clone = fs.clone(); - let result = multi_workspace - .update(cx, |_, window, cx| { - window.spawn(cx, async move |cx| { - AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await - }) - }) - .unwrap() - .await; - - cx.executor().run_until_parked(); - - assert!(result.is_err()); - assert!( - !fs.is_dir(&orphan_path).await, - "orphan worktree directory should be removed by filesystem cleanup" - ); - } - - #[gpui::test] - async fn test_worktree_creation_for_remote_project( - cx: &mut TestAppContext, - server_cx: &mut TestAppContext, - ) { - init_test(cx); - - let app_state = cx.update(|cx| { - agent::ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - - let app_state = workspace::AppState::test(cx); - workspace::init(app_state.clone(), cx); - app_state - }); - - server_cx.update(|cx| { - release_channel::init(semver::Version::new(0, 0, 0), cx); - }); - - // Set up the remote server side with a git repo. - let server_fs = FakeFs::new(server_cx.executor()); - server_fs - .insert_tree( - "/project", - json!({ - ".git": {}, - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - server_fs.set_branch_name(Path::new("/project/.git"), Some("main")); - - // Create a mock remote connection. - let (opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx); - - server_cx.update(remote_server::HeadlessProject::init); - let server_executor = server_cx.executor(); - let _headless = server_cx.new(|cx| { - remote_server::HeadlessProject::new( - remote_server::HeadlessAppState { - session: server_session, - fs: server_fs.clone(), - http_client: Arc::new(http_client::BlockedHttpClient), - node_runtime: node_runtime::NodeRuntime::unavailable(), - languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())), - extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()), - startup_time: Instant::now(), - }, - false, - cx, - ) - }); - - // Connect the client side and build a remote project. - // Use a separate Client to avoid double-registering proto handlers - // (Workspace::test_new creates its own WorkspaceStore from the - // project's client). - let remote_client = remote::RemoteClient::connect_mock(opts, cx).await; - let project = cx.update(|cx| { - let project_client = client::Client::new( - Arc::new(clock::FakeSystemClock::new()), - http_client::FakeHttpClient::with_404_response(), - cx, - ); - let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx)); - project::Project::remote( - remote_client, - project_client, - node_runtime::NodeRuntime::unavailable(), - user_store, - app_state.languages.clone(), - app_state.fs.clone(), - false, - cx, - ) - }); - - // Open the remote path as a worktree in the project. - let worktree_path = Path::new("/project"); - project - .update(cx, |project, cx| { - project.find_or_create_worktree(worktree_path, true, cx) - }) - .await - .expect("should be able to open remote worktree"); - cx.run_until_parked(); - - // Verify the project is indeed remote. - project.read_with(cx, |project, cx| { - assert!(!project.is_local(), "project should be remote, not local"); - assert!( - project.remote_connection_options(cx).is_some(), - "project should have remote connection options" - ); - }); - - // Create the workspace and agent panel. - let multi_workspace = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - multi_workspace - .update(cx, |multi_workspace, _, cx| { - multi_workspace.open_sidebar(cx); - }) - .unwrap(); - - let workspace = multi_workspace - .read_with(cx, |mw, _cx| mw.workspace().clone()) - .unwrap(); - - workspace.update(cx, |workspace, _cx| { - workspace.set_random_database_id(); - }); - - // Register a callback so new workspaces also get an AgentPanel. - cx.update(|cx| { - cx.observe_new( - |workspace: &mut Workspace, - window: Option<&mut Window>, - cx: &mut Context| { - if let Some(window) = window { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel, window, cx); - } - }, - ) - .detach(); - }); - - let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - cx.run_until_parked(); - - let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }); - - cx.run_until_parked(); - - // Open a thread. - panel.update_in(cx, |panel, window, cx| { - panel.open_external_thread_with_server( - Rc::new(StubAgentServer::default_response()), - window, - cx, - ); - }); - cx.run_until_parked(); - - // Trigger worktree creation for a known linked path. - let linked_path = PathBuf::from("/project"); - let content = vec![acp::ContentBlock::Text(acp::TextContent::new( - "Hello from remote test", - ))]; - panel.update_in(cx, |panel, window, cx| { - panel.handle_worktree_requested( - content, - WorktreeCreationArgs::Linked { - worktree_path: linked_path, - display_name: "test-worktree".to_string(), - }, - PreviousWorkspaceState::empty(), - window, - cx, - ); - }); - - // The refactored code uses `find_or_create_workspace`, which - // finds the existing remote workspace (matching paths + host) - // and reuses it instead of creating a new connection. - cx.run_until_parked(); - - // The task should have completed: the existing workspace was - // found and reused. - panel.read_with(cx, |panel, _cx| { - assert!( - panel.worktree_creation_status.is_none(), - "worktree creation should have completed, but status is: {:?}", - panel.worktree_creation_status - ); - }); - - // The existing remote workspace was reused — no new workspace - // should have been created. - multi_workspace - .read_with(cx, |multi_workspace, cx| { - let project = workspace.read(cx).project().clone(); - assert!( - !project.read(cx).is_local(), - "workspace project should still be remote, not local" - ); - assert_eq!( - multi_workspace.workspaces().count(), - 1, - "existing remote workspace should be reused, not a new one created" - ); - }) - .unwrap(); + "error should mention cancellation: {err_msg}" + ); } #[gpui::test] - async fn test_linked_worktree_switch_remaps_open_files(cx: &mut TestAppContext) { + async fn test_rollback_cleans_up_orphan_directories(cx: &mut TestAppContext) { init_test(cx); - - let app_state = cx.update(|cx| { + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); agent::ThreadStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); - - let app_state = workspace::AppState::test(cx); - workspace::init(app_state.clone(), cx); - app_state + ::set_global(fs.clone(), cx); }); - let fs = app_state.fs.as_fake(); fs.insert_tree( "/project", json!({ ".git": {}, - "src": { - "main.rs": "fn main() {}", - "lib.rs": "pub fn hello() {}" - } + "src": { "main.rs": "fn main() {}" } }), ) .await; - fs.set_branch_name(Path::new("/project/.git"), Some("main")); - // Create a linked worktree directory with the same file structure. - let linked_path = PathBuf::from("/linked-worktree"); - fs.add_linked_worktree_for_repo( - Path::new("/project/.git"), - true, - git::repository::Worktree { - path: linked_path.clone(), - ref_name: Some("refs/heads/feature".into()), - sha: "abc123".into(), - is_main: false, - is_bare: false, - }, - ) - .await; - fs.insert_tree( - "/linked-worktree", - json!({ - "src": { - "main.rs": "fn main() { // linked }", - "lib.rs": "pub fn hello() { // linked }" - } - }), - ) - .await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.executor().run_until_parked(); - let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await; + let repository = project.read_with(cx, |project, cx| { + project.repositories(cx).values().next().unwrap().clone() + }); let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - multi_workspace - .update(cx, |multi_workspace, _, cx| { - multi_workspace.open_sidebar(cx); - }) - .unwrap(); - - let workspace = multi_workspace - .read_with(cx, |multi_workspace, _cx| { - multi_workspace.workspace().clone() - }) - .unwrap(); - - workspace.update(cx, |workspace, _cx| { - workspace.set_random_database_id(); - }); - - // Register observer so new workspaces get AgentPanel automatically. - cx.update(|cx| { - cx.observe_new( - |workspace: &mut Workspace, - window: Option<&mut Window>, - cx: &mut Context| { - if let Some(window) = window { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel, window, cx); - } - }, - ) - .detach(); - }); - - let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - cx.run_until_parked(); - - let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }); - - cx.run_until_parked(); - - // Open files in the original workspace. - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_paths( - vec![ - PathBuf::from("/project/src/main.rs"), - PathBuf::from("/project/src/lib.rs"), - ], - workspace::OpenOptions::default(), - None, - window, - cx, - ) - }) - .await; - cx.run_until_parked(); - - // Verify files are open. - workspace.read_with(cx, |workspace, cx| { - let open_paths = workspace.open_item_abs_paths(cx); - assert!( - open_paths.iter().any(|p| p.ends_with("src/main.rs")), - "main.rs should be open, got: {open_paths:?}" - ); - assert!( - open_paths.iter().any(|p| p.ends_with("src/lib.rs")), - "lib.rs should be open, got: {open_paths:?}" - ); - }); - // Open a thread so the panel is in a valid state. - panel.update_in(cx, |panel, window, cx| { - panel.open_external_thread_with_server( - Rc::new(StubAgentServer::default_response()), - window, - cx, - ); - }); - cx.run_until_parked(); + // Simulate the orphan state: create_dir_all was called but git + // worktree add failed, leaving a directory with leftover files. + let orphan_path = PathBuf::from("/worktrees/branch/orphan_project"); + fs.insert_tree( + "/worktrees/branch/orphan_project", + json!({ "leftover.txt": "junk" }), + ) + .await; - // Build a PreviousWorkspaceState with the open files. - let previous_state = - workspace.update_in(cx, |workspace, window, cx| PreviousWorkspaceState { - dock_structure: workspace.capture_dock_state(window, cx), - open_file_paths: vec![ - PathBuf::from("/project/src/main.rs"), - PathBuf::from("/project/src/lib.rs"), - ], - active_file_path: Some(PathBuf::from("/project/src/main.rs")), - }); + assert!( + fs.is_dir(&orphan_path).await, + "orphan dir should exist before rollback" + ); - // Trigger the linked worktree switch. - let content = vec![acp::ContentBlock::Text(acp::TextContent::new( - "Hello from linked worktree test", - ))]; - panel.update_in(cx, |panel, window, cx| { - panel.handle_worktree_requested( - content, - WorktreeCreationArgs::Linked { - worktree_path: linked_path.clone(), - display_name: "feature".to_string(), - }, - previous_state, - window, - cx, - ); - }); + let (sender, receiver) = futures::channel::oneshot::channel::>(); + sender.send(Err(anyhow!("hook failed"))).unwrap(); - cx.run_until_parked(); + let creation_infos = vec![(repository.clone(), orphan_path.clone(), receiver)]; - // Find the new workspace. - let new_workspace = multi_workspace - .read_with(cx, |multi_workspace, _cx| { - multi_workspace - .workspaces() - .find(|ws| ws.entity_id() != workspace.entity_id()) - .cloned() + let fs_clone = fs.clone(); + let result = multi_workspace + .update(cx, |_, window, cx| { + window.spawn(cx, async move |cx| { + git_ui::worktree_service::await_and_rollback_on_failure( + creation_infos, + fs_clone, + cx, + ) + .await + }) }) .unwrap() - .expect("a new workspace should have been created for the linked worktree"); + .await; - // Verify that files were remapped and opened in the new workspace. - // The original /project/src/main.rs should now be /linked-worktree/src/main.rs. - let new_open_paths = - new_workspace.read_with(cx, |workspace, cx| workspace.open_item_abs_paths(cx)); + cx.executor().run_until_parked(); + assert!(result.is_err()); assert!( - new_open_paths - .iter() - .any(|p| p == &linked_path.join("src/main.rs")), - "main.rs should have been remapped to the linked worktree. \ - Open paths: {new_open_paths:?}" - ); - assert!( - new_open_paths - .iter() - .any(|p| p == &linked_path.join("src/lib.rs")), - "lib.rs should have been remapped to the linked worktree. \ - Open paths: {new_open_paths:?}" + !fs.is_dir(&orphan_path).await, + "orphan worktree directory should be removed by filesystem cleanup" ); } @@ -8058,7 +6330,8 @@ mod tests { cx.run_until_parked(); panel.read_with(cx, |panel, cx| { - let (git_repos, non_git_paths) = panel.classify_worktrees(cx); + let (git_repos, non_git_paths) = + git_ui::worktree_service::classify_worktrees(panel.project.read(cx), cx); let git_work_dirs: Vec = git_repos .iter() @@ -8563,4 +6836,192 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_initialize_from_source_transfers_draft_to_fresh_panel(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Set up panel_a with an active thread and type draft text. + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + cx.run_until_parked(); + + panel_a.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + cx.run_until_parked(); + + let thread_view_a = + panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone()); + editor_a.update_in(cx, |editor, window, cx| { + editor.set_text("Draft from workspace A", window, cx); + }); + + // Set up panel_b on workspace_b — starts as a fresh, empty panel. + let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + cx.run_until_parked(); + + // Initializing panel_b from workspace_a should transfer the draft, + // even if panel_b already has an auto-created empty draft thread + // (which set_active creates during add_panel). + let transferred = panel_b.update_in(cx, |panel, window, cx| { + panel.initialize_from_source_workspace_if_needed(workspace_a.downgrade(), window, cx) + }); + assert!( + transferred, + "fresh destination panel should accept source content" + ); + + // Verify the panel was initialized: the base_view should now be an + // AgentThread (not Uninitialized) and a draft_thread should be set. + // We can't check the message editor text directly because the thread + // needs a connected server session (not available in unit tests without + // a stub server). The `transferred == true` return already proves that + // source_panel_initialization read the content successfully. + panel_b.read_with(cx, |panel, _cx| { + assert!( + panel.active_conversation_view().is_some(), + "panel_b should have a conversation view after initialization" + ); + assert!( + panel.draft_thread.is_some(), + "panel_b should have a draft_thread set after initialization" + ); + }); + } + + #[gpui::test] + async fn test_initialize_from_source_does_not_overwrite_existing_content( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Set up panel_a with draft text. + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + cx.run_until_parked(); + + panel_a.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + cx.run_until_parked(); + + let thread_view_a = + panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone()); + editor_a.update_in(cx, |editor, window, cx| { + editor.set_text("Draft from workspace A", window, cx); + }); + + // Set up panel_b with its OWN content — this is a non-fresh panel. + let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + cx.run_until_parked(); + + panel_b.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + cx.run_until_parked(); + + let thread_view_b = + panel_b.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let editor_b = thread_view_b.read_with(cx, |view, _cx| view.message_editor.clone()); + editor_b.update_in(cx, |editor, window, cx| { + editor.set_text("Existing work in workspace B", window, cx); + }); + + // Attempting to initialize panel_b from workspace_a should be rejected + // because panel_b already has meaningful content. + let transferred = panel_b.update_in(cx, |panel, window, cx| { + panel.initialize_from_source_workspace_if_needed(workspace_a.downgrade(), window, cx) + }); + assert!( + !transferred, + "destination panel with existing content should not be overwritten" + ); + + // Verify panel_b still has its original content. + panel_b.read_with(cx, |panel, cx| { + let thread_view = panel + .active_thread_view(cx) + .expect("panel_b should still have its thread view"); + let text = thread_view.read(cx).message_editor.read(cx).text(cx); + assert_eq!( + text, "Existing work in workspace B", + "destination panel's content should be preserved" + ); + }); + } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 9fa038b6e25739..d7a8adf80ec953 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -27,27 +27,23 @@ mod terminal_codegen; mod terminal_inline_assistant; #[cfg(any(test, feature = "test-support"))] pub mod test_support; -mod thread_history; -mod thread_history_view; mod thread_import; pub mod thread_metadata_store; pub mod thread_worktree_archive; -mod thread_worktree_picker; + pub mod threads_archive_view; mod ui; -mod worktree_names; -use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use ::ui::IconName; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_settings::{AgentProfileId, AgentSettings}; use command_palette_hooks::CommandPaletteFilter; use feature_flags::FeatureFlagAppExt as _; use fs::Fs; -use gpui::{Action, App, Context, Entity, SharedString, UpdateGlobal as _, Window, actions}; +use gpui::{Action, App, Context, Entity, SharedString, Window, actions}; use language::{ LanguageRegistry, language_settings::{AllLanguageSettings, EditPredictionProvider}, @@ -57,17 +53,14 @@ use language_model::{ }; use project::{AgentId, DisableAiSettings}; use prompt_store::PromptBuilder; -use release_channel::ReleaseChannel; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{DockPosition, DockSide, LanguageModelSelection, Settings as _, SettingsStore}; +use settings::{LanguageModelSelection, Settings as _, SettingsStore}; use std::any::TypeId; use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; -pub use crate::agent_panel::{ - AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads, WorktreeCreationStatus, -}; +pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use crate::thread_metadata_store::ThreadId; @@ -77,13 +70,12 @@ pub use external_source_prompt::ExternalSourcePrompt; pub(crate) use mode_selector::ModeSelector; pub(crate) use model_selector::ModelSelector; pub(crate) use model_selector_popover::ModelSelectorPopover; -pub(crate) use thread_history::ThreadHistory; -pub(crate) use thread_history_view::*; pub use thread_import::{ AcpThreadImportOnboarding, CrossChannelImportOnboarding, ThreadImportModal, channels_with_threads, import_threads_from_other_channels, }; use zed_actions; +pub use zed_actions::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree}; pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread"; const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled"; @@ -92,10 +84,6 @@ actions!( [ /// Toggles the menu to create new agent threads. ToggleNewThreadMenu, - /// Toggles the worktree selector popover for choosing which worktree to use. - ToggleWorktreeSelector, - /// Toggles the navigation menu for switching between threads and views. - ToggleNavigationMenu, /// Toggles the options menu for agent settings and preferences. ToggleOptionsMenu, /// Toggles the profile or mode selector for switching between agent profiles. @@ -106,10 +94,6 @@ actions!( CycleFavoriteModels, /// Expands the message editor to full size. ExpandMessageEditor, - /// Removes all thread history. - RemoveHistory, - /// Opens the conversation history view. - OpenHistory, /// Adds a context server to the configuration. AddContextServer, /// Archives the currently selected thread. @@ -205,6 +189,16 @@ actions!( ] ); +actions!( + dev, + [ + /// Shows metadata for the currently active thread. + ShowThreadMetadata, + /// Shows metadata for all threads in the sidebar. + ShowAllSidebarThreadMetadata, + ] +); + /// Action to authorize a tool call with a specific permission option. /// This is used by the permission granularity dropdown to authorize tool calls. #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] @@ -261,7 +255,7 @@ pub struct NewExternalAgentThread { #[action(namespace = agent)] #[serde(deny_unknown_fields)] pub struct NewNativeAgentThreadFromSummary { - from_session_id: agent_client_protocol::SessionId, + from_session_id: acp::SessionId, } #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] @@ -338,46 +332,6 @@ impl Agent { } } -/// Describes which branch to use when creating a new git worktree. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case", tag = "kind")] -pub enum NewWorktreeBranchTarget { - /// Create a new randomly named branch from the current HEAD. - /// Will match worktree name if the newly created worktree was also randomly named. - #[default] - CurrentBranch, - /// Check out an existing branch, or create a new branch from it if it's - /// already occupied by another worktree. - ExistingBranch { name: String }, - /// Create a new branch with an explicit name, optionally from a specific ref. - CreateBranch { - name: String, - #[serde(default)] - from_ref: Option, - }, -} - -/// Creates a new git worktree and switches the workspace to it. -/// Dispatched by the unified worktree picker when the user selects a "Create new worktree" entry. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)] -#[action(namespace = agent)] -#[serde(deny_unknown_fields)] -pub struct CreateWorktree { - /// When this is None, Zed will randomly generate a worktree name. - pub worktree_name: Option, - pub branch_target: NewWorktreeBranchTarget, -} - -/// Switches the workspace to an existing linked worktree. -/// Dispatched by the unified worktree picker when the user selects an existing worktree. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)] -#[action(namespace = agent)] -#[serde(deny_unknown_fields)] -pub struct SwitchWorktree { - pub path: PathBuf, - pub display_name: String, -} - /// Content to initialize new external agent with. pub enum AgentInitialContent { ThreadSummary { @@ -385,7 +339,7 @@ pub enum AgentInitialContent { title: Option, }, ContentBlock { - blocks: Vec, + blocks: Vec, auto_submit: bool, }, FromExternalSource(ExternalSourcePrompt), @@ -545,34 +499,7 @@ pub fn init( }) .detach(); - let agent_v2_enabled = agent_v2_enabled(cx); - if agent_v2_enabled { - maybe_backfill_editor_layout(fs, is_new_install, cx); - } - - SettingsStore::update_global(cx, |store, cx| { - store.update_default_settings(cx, |defaults| { - if agent_v2_enabled { - defaults.agent.get_or_insert_default().dock = Some(DockPosition::Left); - defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Right); - defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Right); - defaults.collaboration_panel.get_or_insert_default().dock = - Some(DockPosition::Right); - defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right); - } else { - defaults.agent.get_or_insert_default().dock = Some(DockPosition::Right); - defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Left); - defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Left); - defaults.collaboration_panel.get_or_insert_default().dock = - Some(DockPosition::Left); - defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Left); - } - }); - }); -} - -fn agent_v2_enabled(cx: &App) -> bool { - !matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable)) + maybe_backfill_editor_layout(fs, is_new_install, cx); } fn maybe_backfill_editor_layout(fs: Arc, is_new_install: bool, cx: &mut App) { @@ -600,7 +527,6 @@ fn maybe_backfill_editor_layout(fs: Arc, is_new_install: bool, cx: &mut fn update_command_palette_filter(cx: &mut App) { let disable_ai = DisableAiSettings::get_global(cx).disable_ai; let agent_enabled = AgentSettings::get_global(cx).enabled; - let agent_v2_enabled = agent_v2_enabled(cx); let edit_prediction_provider = AllLanguageSettings::get_global(cx) .edit_predictions @@ -668,12 +594,8 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("zed_predict_onboarding"); filter.show_action_types(&[TypeId::of::()]); - } - if agent_v2_enabled { filter.show_namespace("multi_workspace"); - } else { - filter.hide_namespace("multi_workspace"); } }); } @@ -768,7 +690,7 @@ mod tests { flexible: true, default_width: px(300.), default_height: px(600.), - max_content_width: px(850.), + max_content_width: Some(px(850.)), default_model: None, inline_assistant_model: None, inline_assistant_use_streaming_tools: false, diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 47fd7b0295adbc..59a6cb4c924add 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -5,9 +5,9 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use crate::DEFAULT_THREAD_TITLE; -use crate::ThreadHistory; +use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use acp_thread::MentionUri; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::Result; use editor::{CompletionProvider, Editor, code_context_menus::COMPLETION_MENU_MAX_WIDTH}; use futures::FutureExt as _; @@ -222,7 +222,6 @@ pub struct PromptCompletionProvider { source: Arc, editor: WeakEntity, mention_set: Entity, - history: Option>, prompt_store: Option>, workspace: WeakEntity, } @@ -232,7 +231,6 @@ impl PromptCompletionProvider { source: T, editor: WeakEntity, mention_set: Entity, - history: Option>, prompt_store: Option>, workspace: WeakEntity, ) -> Self { @@ -241,7 +239,6 @@ impl PromptCompletionProvider { editor, mention_set, workspace, - history, prompt_store, } } @@ -918,16 +915,8 @@ impl PromptCompletionProvider { } Some(PromptContextType::Thread) => { - if let Some(history) = self.history.as_ref().and_then(|h| h.upgrade()) { - let sessions = history - .read(cx) - .sessions() - .iter() - .map(|session| SessionMatch { - session_id: session.session_id.clone(), - title: session_title(session.title.clone()), - }) - .collect::>(); + let sessions = collect_session_matches(cx); + if !sessions.is_empty() { let search_task = filter_sessions_by_query(query, cancellation_flag, sessions, cx); cx.spawn(async move |_cx| { @@ -1144,29 +1133,21 @@ impl PromptCompletionProvider { return Task::ready(recent); } - if let Some(history) = self.history.as_ref().and_then(|h| h.upgrade()) { - const RECENT_COUNT: usize = 2; - recent.extend( - history - .read(cx) - .sessions() - .into_iter() - .map(|session| SessionMatch { - session_id: session.session_id.clone(), - title: session_title(session.title.clone()), - }) - .filter(|session| { - let uri = MentionUri::Thread { - id: session.session_id.clone(), - name: session.title.to_string(), - }; - !mentions.contains(&uri) - }) - .take(RECENT_COUNT) - .map(Match::RecentThread), - ); - return Task::ready(recent); - } + let sessions = collect_session_matches(cx); + const RECENT_COUNT: usize = 2; + recent.extend( + sessions + .into_iter() + .filter(|session| { + let uri = MentionUri::Thread { + id: session.session_id.clone(), + name: session.title.to_string(), + }; + !mentions.contains(&uri) + }) + .take(RECENT_COUNT) + .map(Match::RecentThread), + ); Task::ready(recent) } @@ -2030,6 +2011,28 @@ pub(crate) fn search_symbols( }) } +fn collect_session_matches(cx: &App) -> Vec { + let Some(store) = ThreadMetadataStore::try_global(cx) else { + return Vec::new(); + }; + let mut entries: Vec<&ThreadMetadata> = store + .read(cx) + .entries() + .filter(|t| !t.archived && t.agent_id == *agent::ZED_AGENT_ID) + .collect(); + entries.sort_by_key(|t| Reverse(t.updated_at)); + entries + .into_iter() + .map(|metadata| { + let info = acp_thread::AgentSessionInfo::from(metadata); + SessionMatch { + session_id: info.session_id, + title: session_title(info.title), + } + }) + .collect() +} + fn filter_sessions_by_query( query: String, cancellation_flag: Arc, diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index cf2809b87b94ea..c1f9a09c22ff28 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -1,7 +1,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::AgentSessionConfigOptions; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_servers::AgentServer; use collections::HashSet; @@ -381,7 +381,7 @@ impl Render for ConfigOptionSelector { self.picker.clone(), trigger_button, tooltip, - gpui::Corner::BottomRight, + gpui::Anchor::BottomRight, cx, ) .with_handle(self.picker_handle.clone()) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 787fe774c3b786..6577496965a2b2 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -10,7 +10,7 @@ use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; use agent::{ NativeAgentServer, NativeAgentSessionList, NoModelConfiguredError, SharedThread, ThreadStore, }; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; #[cfg(test)] use agent_servers::AgentServerDelegate; use agent_servers::{AgentServer, GEMINI_TERMINAL_AUTH_METHOD_ID}; @@ -74,7 +74,6 @@ use zed_actions::assistant::OpenRulesLibrary; use super::config_options::ConfigOptionsView; use super::entry_view_state::EntryViewState; -use super::thread_history::ThreadHistory; use crate::ModeSelector; use crate::ModelSelectorPopover; use crate::agent_connection_store::{ @@ -85,7 +84,7 @@ use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; -use crate::thread_metadata_store::ThreadId; +use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore}; use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, @@ -396,18 +395,18 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool { match event { AcpThreadEvent::NewEntry | AcpThreadEvent::TitleUpdated - | AcpThreadEvent::EntryUpdated(_) - | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::ToolAuthorizationRequested(_) | AcpThreadEvent::ToolAuthorizationReceived(_) - | AcpThreadEvent::Retry(_) | AcpThreadEvent::Stopped(_) | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) | AcpThreadEvent::Refusal | AcpThreadEvent::WorkingDirectoriesUpdated => true, // -- - AcpThreadEvent::TokenUsageUpdated + AcpThreadEvent::EntryUpdated(_) + | AcpThreadEvent::EntriesRemoved(_) + | AcpThreadEvent::Retry(_) + | AcpThreadEvent::TokenUsageUpdated | AcpThreadEvent::PromptCapabilitiesUpdated | AcpThreadEvent::AvailableCommandsUpdated(_) | AcpThreadEvent::ModeUpdated(_) @@ -556,7 +555,6 @@ pub struct ConnectedServerState { active_id: Option, pub(crate) threads: HashMap>, connection: Rc, - history: Option>, conversation: Entity, _connection_entry_subscription: Subscription, } @@ -701,7 +699,7 @@ impl ConversationView { } fn reset(&mut self, window: &mut Window, cx: &mut Context) { - let (resume_session_id, cwd, title) = self + let (resume_session_id, work_dirs, title) = self .root_thread_view() .map(|thread_view| { let tv = thread_view.read(cx); @@ -712,14 +710,25 @@ impl ConversationView { thread.title(), ) }) - .unwrap_or((None, None, None)); + .unwrap_or_else(|| { + let session_id = self.root_session_id.clone(); + let (work_dirs, title) = session_id + .as_ref() + .and_then(|id| { + let store = ThreadMetadataStore::try_global(cx)?; + let entry = store.read(cx).entry_by_session(id)?; + Some((Some(entry.folder_paths().clone()), entry.title.clone())) + }) + .unwrap_or((None, None)); + (session_id, work_dirs, title) + }); let state = Self::initial_state( self.agent.clone(), self.connection_store.clone(), self.connection_key.clone(), resume_session_id, - cwd, + work_dirs, title, self.project.clone(), None, @@ -791,11 +800,8 @@ impl ConversationView { }; let load_task = cx.spawn_in(window, async move |this, cx| { - let (connection, history) = match connect_result.await { - Ok(AgentConnectedState { - connection, - history, - }) => (connection, history), + let connection = match connect_result.await { + Ok(AgentConnectedState { connection, .. }) => connection, Err(err) => { this.update_in(cx, |this, window, cx| { this.handle_load_error(err, window, cx); @@ -891,7 +897,6 @@ impl ConversationView { conversation.clone(), resumed_without_history, initial_content, - history.clone(), window, cx, ); @@ -912,7 +917,6 @@ impl ConversationView { active_id: Some(root_session_id.clone()), threads: HashMap::from_iter([(root_session_id, current)]), conversation, - history, _connection_entry_subscription: connection_entry_subscription, }), cx, @@ -945,7 +949,6 @@ impl ConversationView { conversation: Entity, resumed_without_history: bool, initial_content: Option, - history: Option>, window: &mut Window, cx: &mut Context, ) -> Entity { @@ -962,7 +965,6 @@ impl ConversationView { self.workspace.clone(), self.project.downgrade(), self.thread_store.clone(), - history.as_ref().map(|h| h.downgrade()), self.prompt_store.clone(), session_capabilities.clone(), self.agent.agent_id(), @@ -1130,7 +1132,6 @@ impl ConversationView { resumed_without_history, self.project.downgrade(), self.thread_store.clone(), - history, self.prompt_store.clone(), initial_content, subscriptions, @@ -1212,7 +1213,6 @@ impl ConversationView { threads: HashMap::default(), connection, conversation: cx.new(|_cx| Conversation::default()), - history: None, _connection_entry_subscription: Subscription::new(|| {}), }), cx, @@ -1787,9 +1787,9 @@ impl ConversationView { cx.spawn_in(window, async move |this, cx| { let subagent_thread = subagent_thread_task.await?; this.update_in(cx, |this, window, cx| { - let Some((conversation, history)) = this + let Some(conversation) = this .as_connected() - .map(|connected| (connected.conversation.clone(), connected.history.clone())) + .map(|connected| connected.conversation.clone()) else { return; }; @@ -1797,15 +1797,8 @@ impl ConversationView { conversation.update(cx, |conversation, cx| { conversation.register_thread(subagent_thread.clone(), cx); }); - let view = this.new_thread_view( - subagent_thread, - conversation, - false, - None, - history, - window, - cx, - ); + let view = + this.new_thread_view(subagent_thread, conversation, false, None, window, cx); let Some(connected) = this.as_connected_mut() else { return; }; @@ -2264,7 +2257,6 @@ impl ConversationView { let Some(connected) = self.as_connected() else { return; }; - let history = connected.history.as_ref().map(|h| h.downgrade()); let Some(thread) = connected.active_view() else { return; }; @@ -2305,7 +2297,6 @@ impl ConversationView { workspace.clone(), project.clone(), None, - history.clone(), None, session_capabilities.clone(), agent_name.clone(), @@ -2560,7 +2551,12 @@ impl ConversationView { .update(cx, |multi_workspace, window, cx| { window.activate_window(); if let Some(workspace) = workspace_handle.upgrade() { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate( + workspace.clone(), + None, + window, + cx, + ); workspace.update(cx, |workspace, cx| { workspace.reveal_panel::(window, cx); if let Some(panel) = @@ -2704,10 +2700,6 @@ impl ConversationView { Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx); }) } - - pub fn history(&self) -> Option<&Entity> { - self.as_connected().and_then(|c| c.history.as_ref()) - } } fn loading_contents_spinner(size: IconSize) -> AnyElement { @@ -2857,12 +2849,9 @@ fn plan_label_markdown_style( #[cfg(test)] pub(crate) mod tests { - use acp_thread::{ - AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection, - }; + use acp_thread::StubAgentConnection; use action_log::ActionLog; use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext}; - use agent_client_protocol::SessionId; use agent_servers::FakeAcpAgentServer; use editor::MultiBufferOffset; use fs::FakeFs; @@ -3024,66 +3013,6 @@ pub(crate) mod tests { ); } - #[gpui::test] - async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) { - init_test(cx); - - let session_a = AgentSessionInfo::new(SessionId::new("session-a")); - let session_b = AgentSessionInfo::new(SessionId::new("session-b")); - - // Use a connection that provides a session list so ThreadHistory is created - let (conversation_view, history, cx) = setup_thread_view_with_history( - StubAgentServer::new(SessionHistoryConnection::new(vec![session_a.clone()])), - cx, - ) - .await; - - // Initially has session_a from the connection's session list - active_thread(&conversation_view, cx).read_with(cx, |view, _cx| { - assert_eq!(view.recent_history_entries.len(), 1); - assert_eq!( - view.recent_history_entries[0].session_id, - session_a.session_id - ); - }); - - // Swap to a different session list - let list_b: Rc = - Rc::new(StubSessionList::new(vec![session_b.clone()])); - history.update(cx, |history, cx| { - history.set_session_list(list_b, cx); - }); - cx.run_until_parked(); - - active_thread(&conversation_view, cx).read_with(cx, |view, _cx| { - assert_eq!(view.recent_history_entries.len(), 1); - assert_eq!( - view.recent_history_entries[0].session_id, - session_b.session_id - ); - }); - } - - #[gpui::test] - async fn test_new_thread_creation_triggers_session_list_refresh(cx: &mut TestAppContext) { - init_test(cx); - - let session = AgentSessionInfo::new(SessionId::new("history-session")); - let (conversation_view, _history, cx) = setup_thread_view_with_history( - StubAgentServer::new(SessionHistoryConnection::new(vec![session.clone()])), - cx, - ) - .await; - - active_thread(&conversation_view, cx).read_with(cx, |view, _cx| { - assert_eq!(view.recent_history_entries.len(), 1); - assert_eq!( - view.recent_history_entries[0].session_id, - session.session_id - ); - }); - } - #[gpui::test] async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) { init_test(cx); @@ -3104,7 +3033,7 @@ pub(crate) mod tests { Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)), connection_store, Agent::Custom { id: "Test".into() }, - Some(SessionId::new("resume-session")), + Some(acp::SessionId::new("resume-session")), None, None, None, @@ -3151,7 +3080,7 @@ pub(crate) mod tests { self, project, "RestoredAvailableCommandsConnection", - SessionId::new("new-session"), + acp::SessionId::new("new-session"), cx, ); Task::ready(Ok(thread)) @@ -3241,7 +3170,7 @@ pub(crate) mod tests { Rc::new(StubAgentServer::new(RestoredAvailableCommandsConnection)), connection_store, Agent::Custom { id: "Test".into() }, - Some(SessionId::new("restored-session")), + Some(acp::SessionId::new("restored-session")), None, None, None, @@ -3323,7 +3252,7 @@ pub(crate) mod tests { Rc::new(StubAgentServer::new(connection)), connection_store, Agent::Custom { id: "Test".into() }, - Some(SessionId::new("session-1")), + Some(acp::SessionId::new("session-1")), None, Some(PathList::new(&[PathBuf::from("/project/subdir")])), None, @@ -3410,6 +3339,122 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_reset_preserves_session_id_after_load_error(cx: &mut TestAppContext) { + use crate::thread_metadata_store::{ThreadId, ThreadMetadata}; + use chrono::Utc; + use project::{AgentId as ProjectAgentId, WorktreePaths}; + use std::sync::atomic::Ordering; + + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); + + // Simulate a previous run that persisted metadata for this session. + let resume_session_id = acp::SessionId::new("persistent-session"); + let stored_title: SharedString = "Persistent chat".into(); + cx.update(|_window, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + thread_id: ThreadId::new(), + session_id: Some(resume_session_id.clone()), + agent_id: ProjectAgentId::new("Flaky"), + title: Some(stored_title.clone()), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + interacted_at: None, + worktree_paths: WorktreePaths::from_folder_paths(&PathList::default()), + remote_connection: None, + archived: false, + }, + cx, + ); + }); + }); + + let connection = StubAgentConnection::new().with_supports_load_session(true); + let (server, fail) = FlakyAgentServer::new(connection); + + let conversation_view = cx.update(|window, cx| { + cx.new(|cx| { + ConversationView::new( + Rc::new(server), + connection_store, + Agent::Custom { id: "Flaky".into() }, + Some(resume_session_id.clone()), + None, + None, + None, + None, + workspace.downgrade(), + project.clone(), + Some(thread_store), + None, + "agent_panel", + window, + cx, + ) + }) + }); + cx.run_until_parked(); + + // The first connect() fails, so we land in LoadError. + conversation_view.read_with(cx, |view, _cx| { + assert!( + matches!(view.server_state, ServerState::LoadError { .. }), + "expected LoadError after failed initial connect" + ); + assert_eq!( + view.root_session_id.as_ref(), + Some(&resume_session_id), + "root_session_id should still hold the original id while in LoadError" + ); + }); + + // Now let the agent come online and emit AgentServersUpdated. This is + // the moment the bug would have stomped on root_session_id. + fail.store(false, Ordering::SeqCst); + project.update(cx, |project, cx| { + project + .agent_server_store() + .update(cx, |_store, cx| cx.emit(project::AgentServersUpdated)); + }); + cx.run_until_parked(); + + // The retry should have resumed the ORIGINAL session, not created a + // brand-new one. + conversation_view.read_with(cx, |view, cx| { + let connected = view + .as_connected() + .expect("should be Connected after flaky server comes online"); + let active_id = connected + .active_id + .as_ref() + .expect("Connected state should have an active_id"); + assert_eq!( + active_id, &resume_session_id, + "reset() must resume the original session id, not call new_session()" + ); + let active_thread = view + .active_thread() + .expect("should have an active thread view"); + let thread_session = active_thread.read(cx).thread.read(cx).session_id().clone(); + assert_eq!( + thread_session, resume_session_id, + "the live AcpThread should hold the resumed session id" + ); + }); + } + #[gpui::test] async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) { init_test(cx); @@ -4035,22 +4080,7 @@ pub(crate) mod tests { agent: impl AgentServer + 'static, cx: &mut TestAppContext, ) -> (Entity, &mut VisualTestContext) { - let (conversation_view, _history, cx) = - setup_conversation_view_with_history_and_initial_content(agent, None, cx).await; - (conversation_view, cx) - } - - async fn setup_thread_view_with_history( - agent: impl AgentServer + 'static, - cx: &mut TestAppContext, - ) -> ( - Entity, - Entity, - &mut VisualTestContext, - ) { - let (conversation_view, history, cx) = - setup_conversation_view_with_history_and_initial_content(agent, None, cx).await; - (conversation_view, history.expect("Missing history"), cx) + setup_conversation_view_with_initial_content_opt(agent, None, cx).await } async fn setup_conversation_view_with_initial_content( @@ -4058,25 +4088,14 @@ pub(crate) mod tests { initial_content: AgentInitialContent, cx: &mut TestAppContext, ) -> (Entity, &mut VisualTestContext) { - let (conversation_view, _history, cx) = - setup_conversation_view_with_history_and_initial_content( - agent, - Some(initial_content), - cx, - ) - .await; - (conversation_view, cx) + setup_conversation_view_with_initial_content_opt(agent, Some(initial_content), cx).await } - async fn setup_conversation_view_with_history_and_initial_content( + async fn setup_conversation_view_with_initial_content_opt( agent: impl AgentServer + 'static, initial_content: Option, cx: &mut TestAppContext, - ) -> ( - Entity, - Option>, - &mut VisualTestContext, - ) { + ) -> (Entity, &mut VisualTestContext) { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; let (multi_workspace, cx) = @@ -4112,14 +4131,7 @@ pub(crate) mod tests { }); cx.run_until_parked(); - let history = cx.update(|_window, cx| { - connection_store - .read(cx) - .entry(&agent_key) - .and_then(|e| e.read(cx).history().cloned()) - }); - - (conversation_view, history, cx) + (conversation_view, cx) } fn add_to_workspace(conversation_view: Entity, cx: &mut VisualTestContext) { @@ -4251,24 +4263,51 @@ pub(crate) mod tests { } } - #[derive(Clone)] - struct StubSessionList { - sessions: Vec, + /// Agent server whose `connect()` fails while `fail` is `true` and + /// returns the wrapped connection otherwise. Used to simulate the + /// race where an external agent isn't yet registered at startup. + pub(crate) struct FlakyAgentServer { + connection: StubAgentConnection, + fail: Arc, } - impl StubSessionList { - fn new(sessions: Vec) -> Self { - Self { sessions } + impl FlakyAgentServer { + pub(crate) fn new( + connection: StubAgentConnection, + ) -> (Self, Arc) { + let fail = Arc::new(std::sync::atomic::AtomicBool::new(true)); + ( + Self { + connection, + fail: fail.clone(), + }, + fail, + ) } } - impl AgentSessionList for StubSessionList { - fn list_sessions( + impl AgentServer for FlakyAgentServer { + fn logo(&self) -> ui::IconName { + ui::IconName::ZedAgent + } + + fn agent_id(&self) -> AgentId { + "Flaky".into() + } + + fn connect( &self, - _request: AgentSessionListRequest, + _delegate: AgentServerDelegate, + _project: Entity, _cx: &mut App, - ) -> Task> { - Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) + ) -> Task>> { + if self.fail.load(std::sync::atomic::Ordering::SeqCst) { + Task::ready(Err(anyhow!( + "Custom agent server `Flaky` is not registered" + ))) + } else { + Task::ready(Ok(Rc::new(self.connection.clone()))) + } } fn into_any(self: Rc) -> Rc { @@ -4276,22 +4315,11 @@ pub(crate) mod tests { } } - #[derive(Clone)] - struct SessionHistoryConnection { - sessions: Vec, - } - - impl SessionHistoryConnection { - fn new(sessions: Vec) -> Self { - Self { sessions } - } - } - fn build_test_thread( connection: Rc, project: Entity, name: &'static str, - session_id: SessionId, + session_id: acp::SessionId, cx: &mut App, ) -> Entity { let action_log = cx.new(|_| ActionLog::new(project.clone())); @@ -4315,67 +4343,6 @@ pub(crate) mod tests { }) } - impl AgentConnection for SessionHistoryConnection { - fn agent_id(&self) -> AgentId { - AgentId::new("history-connection") - } - - fn telemetry_id(&self) -> SharedString { - "history-connection".into() - } - - fn new_session( - self: Rc, - project: Entity, - _work_dirs: PathList, - cx: &mut App, - ) -> Task>> { - let thread = build_test_thread( - self, - project, - "SessionHistoryConnection", - SessionId::new("history-session"), - cx, - ); - Task::ready(Ok(thread)) - } - - fn supports_load_session(&self) -> bool { - true - } - - fn session_list(&self, _cx: &mut App) -> Option> { - Some(Rc::new(StubSessionList::new(self.sessions.clone()))) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate( - &self, - _method_id: acp::AuthMethodId, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(())) - } - - fn prompt( - &self, - _id: acp_thread::UserMessageId, - _params: acp::PromptRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) - } - - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} - - fn into_any(self: Rc) -> Rc { - self - } - } - #[derive(Clone)] struct ResumeOnlyAgentConnection; @@ -4398,7 +4365,7 @@ pub(crate) mod tests { self, project, "ResumeOnlyAgentConnection", - SessionId::new("new-session"), + acp::SessionId::new("new-session"), cx, ); Task::ready(Ok(thread)) @@ -4578,7 +4545,7 @@ pub(crate) mod tests { self, project, action_log, - SessionId::new("test"), + acp::SessionId::new("test"), watch::Receiver::constant( acp::PromptCapabilities::new() .image(true) @@ -4658,7 +4625,7 @@ pub(crate) mod tests { self.clone(), project, action_log, - SessionId::new("new-session"), + acp::SessionId::new("new-session"), watch::Receiver::constant( acp::PromptCapabilities::new() .image(true) @@ -7284,7 +7251,7 @@ pub(crate) mod tests { self, project, action_log, - SessionId::new("close-capable-session"), + acp::SessionId::new("close-capable-session"), watch::Receiver::constant( acp::PromptCapabilities::new() .image(true) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index d0d3ee37447e57..6f134850c1a114 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -2,6 +2,7 @@ use crate::{ DEFAULT_THREAD_TITLE, SelectPermissionGranularity, agent_configuration::configure_context_server_modal::default_markdown_style, }; +use agent_client_protocol::schema as acp; use std::cell::RefCell; use acp_thread::{ContentBlock, PlanEntry}; @@ -11,7 +12,7 @@ use feature_flags::AcpBetaFeatureFlag; use crate::message_editor::SharedSessionCapabilities; -use gpui::{Corner, List}; +use gpui::List; use heapless::Vec as ArrayVec; use language_model::{LanguageModelEffortLevel, Speed}; use settings::{SidebarSide, update_settings_file}; @@ -206,7 +207,7 @@ impl RenderOnce for GeneratingSpinnerElement { } pub enum AcpThreadViewEvent { - MessageSentOrQueued, + Interacted, } impl EventEmitter for ThreadView {} @@ -289,8 +290,8 @@ pub struct ThreadView { pub session_capabilities: SharedSessionCapabilities, /// Tracks which tool calls have their content/output expanded. /// Used for showing/hiding tool call results, terminal output, etc. - pub expanded_tool_calls: HashSet, - pub expanded_tool_call_raw_inputs: HashSet, + pub expanded_tool_calls: HashSet, + pub expanded_tool_call_raw_inputs: HashSet, pub expanded_thinking_blocks: HashSet<(usize, usize)>, auto_expanded_thinking_block: Option<(usize, usize)>, user_toggled_thinking_blocks: HashSet<(usize, usize)>, @@ -306,12 +307,11 @@ pub struct ThreadView { pub queued_message_editor_subscriptions: Vec, pub last_synced_queue_length: usize, pub turn_fields: TurnFields, - pub discarded_partial_edits: HashSet, + pub discarded_partial_edits: HashSet, pub is_loading_contents: bool, pub new_server_version_available: Option, pub resumed_without_history: bool, - pub(crate) permission_selections: - HashMap, + pub(crate) permission_selections: HashMap, pub resume_thread_metadata: Option, pub _cancel_task: Option>, _save_task: Option>, @@ -326,14 +326,10 @@ pub struct ThreadView { pub add_context_menu_handle: PopoverMenuHandle, pub thinking_effort_menu_handle: PopoverMenuHandle, pub project: WeakEntity, - pub recent_history_entries: Vec, - pub hovered_recent_history_item: Option, pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, pub multi_root_callout_dismissed: bool, pub generating_indicator_in_list: bool, - pub history: Option>, - pub _history_subscription: Option, } impl Focusable for ThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { @@ -375,7 +371,6 @@ impl ThreadView { resumed_without_history: bool, project: WeakEntity, thread_store: Option>, - history: Option>, prompt_store: Option>, initial_content: Option, mut subscriptions: Vec, @@ -388,12 +383,6 @@ impl ThreadView { let has_commands = !session_capabilities.read().available_commands().is_empty(); let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands); - let history_subscription = history.as_ref().map(|h| { - cx.observe(h, |this, history, cx| { - this.update_recent_history_from_cache(&history, cx); - }) - }); - let mut should_auto_submit = false; let mut show_external_source_prompt_warning = false; @@ -402,7 +391,6 @@ impl ThreadView { workspace.clone(), project.clone(), thread_store, - history.as_ref().map(|h| h.downgrade()), prompt_store, session_capabilities.clone(), agent_id.clone(), @@ -501,11 +489,6 @@ impl ThreadView { })); })); - let recent_history_entries = history - .as_ref() - .map(|h| h.read(cx).get_recent_sessions(3)) - .unwrap_or_default(); - let mut this = Self { session_id, parent_session_id, @@ -568,11 +551,7 @@ impl ThreadView { add_context_menu_handle: PopoverMenuHandle::default(), thinking_effort_menu_handle: PopoverMenuHandle::default(), project, - recent_history_entries, - hovered_recent_history_item: None, show_external_source_prompt_warning, - history, - _history_subscription: history_subscription, show_codex_windows_warning, multi_root_callout_dismissed: false, generating_indicator_in_list: false, @@ -954,7 +933,6 @@ impl ThreadView { let has_queued = self.has_queued_messages(); if is_editor_empty && self.can_fast_track_queue && has_queued { self.can_fast_track_queue = false; - cx.emit(AcpThreadViewEvent::MessageSentOrQueued); self.send_queued_message_at_index(0, true, window, cx); return; } @@ -964,7 +942,7 @@ impl ThreadView { } if is_generating { - cx.emit(AcpThreadViewEvent::MessageSentOrQueued); + cx.emit(AcpThreadViewEvent::Interacted); self.queue_message(message_editor, window, cx); return; } @@ -1006,7 +984,7 @@ impl ThreadView { } } - cx.emit(AcpThreadViewEvent::MessageSentOrQueued); + cx.emit(AcpThreadViewEvent::Interacted); self.send_impl(message_editor, window, cx) } @@ -1209,6 +1187,8 @@ impl ThreadView { return; } + cx.emit(AcpThreadViewEvent::Interacted); + let message_editor = self.message_editor.clone(); if thread.read(cx).status() == ThreadStatus::Idle { self.send_impl(message_editor, window, cx); @@ -1371,6 +1351,7 @@ impl ThreadView { } let task = thread.update(cx, |thread, cx| thread.retry(cx)); + cx.emit(AcpThreadViewEvent::Interacted); self.sync_generating_indicator(cx); cx.notify(); cx.spawn(async move |this, cx| { @@ -1430,6 +1411,7 @@ impl ThreadView { .update(cx, |thread, cx| thread.rewind(user_message_id, cx)) .await?; this.update_in(cx, |thread, window, cx| { + cx.emit(AcpThreadViewEvent::Interacted); thread.send_impl(message_editor, window, cx); thread.focus_handle(cx).focus(window, cx); })?; @@ -1522,6 +1504,8 @@ impl ThreadView { return; }; + cx.emit(AcpThreadViewEvent::Interacted); + self.message_editor.focus_handle(cx).focus(window, cx); let content = queued.content; @@ -2289,7 +2273,8 @@ impl ThreadView { .justify_center() .child( v_flex() - .flex_basis(max_content_width) + .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) + .when(max_content_width.is_none(), |this| this.w_full()) .flex_shrink() .flex_grow_0() .max_w_full() @@ -3195,8 +3180,7 @@ impl ThreadView { .child( h_flex() .size_full() - .max_w(max_content_width) - .mx_auto() + .when_some(max_content_width, |this, max_w| this.max_w(max_w).mx_auto()) .pl_2() .pr_1() .flex_shrink_0() @@ -3293,7 +3277,8 @@ impl ThreadView { }) .child( v_flex() - .flex_basis(max_content_width) + .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) + .when(max_content_width.is_none(), |this| this.w_full()) .flex_shrink() .flex_grow_0() .when(fills_container, |this| this.h_full()) @@ -3836,12 +3821,22 @@ impl ThreadView { let enable_thinking = !thread.thinking_enabled(); thread.set_thinking_enabled(enable_thinking, cx); + let favorite_key = thread.model().map(|model| { + (model.provider_id().0.to_string(), model.id().0.to_string()) + }); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = agent.default_model.as_mut() - { - default_model.enable_thinking = enable_thinking; + if let Some(agent) = settings.agent.as_mut() { + if let Some(default_model) = agent.default_model.as_mut() { + default_model.enable_thinking = enable_thinking; + } + if let Some((provider_id, model_id)) = &favorite_key { + agent.update_favorite_model( + provider_id, + model_id, + |favorite| favorite.enable_thinking = enable_thinking, + ); + } } }); }); @@ -3972,14 +3967,33 @@ impl ThreadView { cx, ); + let favorite_key = thread.model().map(|model| { + ( + model.provider_id().0.to_string(), + model.id().0.to_string(), + ) + }); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = + if let Some(agent) = settings.agent.as_mut() { + if let Some(default_model) = agent.default_model.as_mut() - { - default_model.effort = - Some(effort.to_string()); + { + default_model.effort = + Some(effort.to_string()); + } + if let Some((provider_id, model_id)) = + &favorite_key + { + agent.update_favorite_model( + provider_id, + model_id, + |favorite| { + favorite.effort = + Some(effort.to_string()) + }, + ); + } } }); }); @@ -3998,7 +4012,7 @@ impl ThreadView { x: px(0.0), y: px(-2.0), }) - .anchor(Corner::BottomLeft) + .anchor(gpui::Anchor::BottomLeft) } fn render_send_button(&self, cx: &mut Context) -> AnyElement { @@ -4102,7 +4116,7 @@ impl ThreadView { } }, ) - .anchor(Corner::BottomLeft) + .anchor(gpui::Anchor::BottomLeft) .with_handle(self.add_context_menu_handle.clone()) .offset(gpui::Point { x: px(0.0), @@ -4481,10 +4495,12 @@ impl ThreadView { fn render_entries(&mut self, cx: &mut Context) -> List { let max_content_width = AgentSettings::get_global(cx).max_content_width; let centered_container = move |content: AnyElement| { - h_flex() - .w_full() - .justify_center() - .child(div().max_w(max_content_width).w_full().child(content)) + h_flex().w_full().justify_center().child( + div() + .when_some(max_content_width, |this, max_w| this.max_w(max_w)) + .w_full() + .child(content), + ) }; list( @@ -5728,15 +5744,15 @@ impl ThreadView { let this = entity.read(cx); let is_at_top = this.list_state.logical_scroll_top().item_ix == 0; - let has_selection = this - .thread - .read(cx) - .entries() - .get(entry_ix) - .and_then(|entry| match &entry { - AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks), - _ => None, - }) + let chunks = + this.thread.read(cx).entries().get(entry_ix).and_then( + |entry| match &entry { + AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks), + _ => None, + }, + ); + + let has_selection = chunks .map(|chunks| { chunks.iter().any(|chunk| { let md = match chunk { @@ -5748,6 +5764,16 @@ impl ThreadView { }) .unwrap_or(false); + let context_menu_link = chunks.and_then(|chunks| { + chunks.iter().find_map(|chunk| { + let md = match chunk { + AssistantMessageChunk::Message { block } => block.markdown(), + AssistantMessageChunk::Thought { block } => block.markdown(), + }; + md.and_then(|m| m.read(cx).context_menu_link().cloned()) + }) + }); + let copy_this_agent_response = ContextMenuEntry::new("Copy This Agent Response").handler({ let entity = entity.clone(); @@ -5799,6 +5825,12 @@ impl ThreadView { }); menu.when_some(focus, |menu, focus| menu.context(focus)) + .when_some(context_menu_link, |menu, url| { + menu.entry("Copy Link", None, move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + }) + .separator() + }) .action_disabled_when( !has_selection, "Copy Selection", @@ -6994,8 +7026,8 @@ impl ThreadView { PopoverMenu::new(("permission-granularity", entry_ix)) .with_handle(permission_dropdown_handle.clone()) - .anchor(Corner::TopRight) - .attach(Corner::BottomRight) + .anchor(gpui::Anchor::TopRight) + .attach(gpui::Anchor::BottomRight) .trigger( Button::new(("granularity-trigger", entry_ix), current_label) .end_icon( @@ -7707,6 +7739,7 @@ impl ThreadView { gpui::ImageFormat::Bmp => "BMP", gpui::ImageFormat::Tiff => "TIFF", gpui::ImageFormat::Ico => "ICO", + gpui::ImageFormat::Pnm => "PNM", }; let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes())) .with_guessed_format() @@ -8628,16 +8661,6 @@ impl ThreadView { .into_any_element() } - fn update_recent_history_from_cache( - &mut self, - history: &Entity, - cx: &mut Context, - ) { - self.recent_history_entries = history.read(cx).get_recent_sessions(3); - self.hovered_recent_history_item = None; - cx.notify(); - } - fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { Callout::new() .icon(IconName::Warning) @@ -8872,12 +8895,20 @@ impl ThreadView { .unwrap_or(Speed::Fast); thread.set_speed(new_speed, cx); + let favorite_key = thread + .model() + .map(|model| (model.provider_id().0.to_string(), model.id().0.to_string())); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = agent.default_model.as_mut() - { - default_model.speed = Some(new_speed); + if let Some(agent) = settings.agent.as_mut() { + if let Some(default_model) = agent.default_model.as_mut() { + default_model.speed = Some(new_speed); + } + if let Some((provider_id, model_id)) = &favorite_key { + agent.update_favorite_model(provider_id, model_id, |favorite| { + favorite.speed = Some(new_speed) + }); + } } }); }); @@ -8918,12 +8949,20 @@ impl ThreadView { thread.update(cx, |thread, cx| { thread.set_thinking_effort(Some(next_effort.clone()), cx); + let favorite_key = thread + .model() + .map(|model| (model.provider_id().0.to_string(), model.id().0.to_string())); let fs = thread.project().read(cx).fs().clone(); update_settings_file(fs, cx, move |settings, _| { - if let Some(agent) = settings.agent.as_mut() - && let Some(default_model) = agent.default_model.as_mut() - { - default_model.effort = Some(next_effort); + if let Some(agent) = settings.agent.as_mut() { + if let Some(default_model) = agent.default_model.as_mut() { + default_model.effort = Some(next_effort.clone()); + } + if let Some((provider_id, model_id)) = &favorite_key { + agent.update_favorite_model(provider_id, model_id, |favorite| { + favorite.effort = Some(next_effort) + }); + } } }); }); diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 8543b3c96199e7..853672142fb843 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -1,9 +1,8 @@ use std::ops::Range; -use super::thread_history::ThreadHistory; use acp_thread::{AcpThread, AgentThreadEntry}; use agent::ThreadStore; -use agent_client_protocol::ToolCallId; +use agent_client_protocol::schema as acp; use collections::HashMap; use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; use gpui::{ @@ -26,7 +25,6 @@ pub struct EntryViewState { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - history: Option>, prompt_store: Option>, entries: Vec, session_capabilities: SharedSessionCapabilities, @@ -38,7 +36,6 @@ impl EntryViewState { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - history: Option>, prompt_store: Option>, session_capabilities: SharedSessionCapabilities, agent_id: AgentId, @@ -47,7 +44,6 @@ impl EntryViewState { workspace, project, thread_store, - history, prompt_store, entries: Vec::new(), session_capabilities, @@ -90,7 +86,6 @@ impl EntryViewState { self.workspace.clone(), self.project.clone(), self.thread_store.clone(), - self.history.clone(), self.prompt_store.clone(), self.session_capabilities.clone(), self.agent_id.clone(), @@ -288,9 +283,9 @@ pub struct EntryViewEvent { } pub enum ViewEvent { - NewDiff(ToolCallId), - NewTerminal(ToolCallId), - TerminalMovedToBackground(ToolCallId), + NewDiff(acp::ToolCallId), + NewTerminal(acp::ToolCallId), + TerminalMovedToBackground(acp::ToolCallId), MessageEditorEvent(Entity, MessageEditorEvent), OpenDiffLocation { path: String, @@ -487,7 +482,7 @@ mod tests { use std::sync::Arc; use acp_thread::{AgentConnection, StubAgentConnection}; - use agent_client_protocol as acp; + use agent_client_protocol::schema as acp; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use editor::RowInfo; use fs::FakeFs; @@ -544,14 +539,12 @@ mod tests { }); let thread_store = None; - let history: Option> = None; let view_state = cx.new(|_cx| { EntryViewState::new( workspace.downgrade(), project.downgrade(), thread_store, - history, None, Arc::new(RwLock::new(SessionCapabilities::default())), "Test Agent".into(), diff --git a/crates/agent_ui/src/favorite_models.rs b/crates/agent_ui/src/favorite_models.rs index aa48ca8d12459b..c655f9b6a55aec 100644 --- a/crates/agent_ui/src/favorite_models.rs +++ b/crates/agent_ui/src/favorite_models.rs @@ -1,27 +1,27 @@ use std::sync::Arc; +use agent_settings::{AgentSettings, language_model_to_selection}; use fs::Fs; use language_model::LanguageModel; -use settings::{LanguageModelSelection, update_settings_file}; +use settings::{Settings as _, update_settings_file}; use ui::App; -fn language_model_to_selection(model: &Arc) -> LanguageModelSelection { - LanguageModelSelection { - provider: model.provider_id().to_string().into(), - model: model.id().0.to_string(), - enable_thinking: false, - effort: None, - speed: None, - } -} - pub fn toggle_in_settings( model: Arc, should_be_favorite: bool, fs: Arc, cx: &mut App, ) { - let selection = language_model_to_selection(&model); + let current_user_selection = AgentSettings::get_global(cx) + .default_model + .as_ref() + .filter(|selection| { + selection.provider.0 == model.provider_id().0.as_ref() + && selection.model == model.id().0.as_ref() + }) + .cloned(); + + let selection = language_model_to_selection(&model, current_user_selection.as_ref()); update_settings_file(fs, cx, move |settings, _| { let agent = settings.agent.get_or_insert_default(); if should_be_favorite { diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index ce74b7f78cda0e..71aa7baf7816b3 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -6,7 +6,6 @@ use std::ops::Range; use std::sync::Arc; use uuid::Uuid; -use crate::ThreadHistory; use crate::context::load_context; use crate::mention_set::MentionSet; use crate::{ @@ -231,11 +230,6 @@ impl InlineAssistant { let prompt_store = agent_panel.prompt_store().as_ref().cloned(); let thread_store = agent_panel.thread_store().clone(); - let history = agent_panel - .connection_store() - .read(cx) - .entry(&crate::Agent::NativeAgent) - .and_then(|s| s.read(cx).history().cloned()); let handle_assist = |window: &mut Window, cx: &mut Context| match inline_assist_target { @@ -247,7 +241,6 @@ impl InlineAssistant { workspace.project().downgrade(), thread_store, prompt_store, - history.as_ref().map(|h| h.downgrade()), action.prompt.clone(), window, cx, @@ -262,7 +255,6 @@ impl InlineAssistant { workspace.project().downgrade(), thread_store, prompt_store, - history.as_ref().map(|h| h.downgrade()), action.prompt.clone(), window, cx, @@ -446,7 +438,6 @@ impl InlineAssistant { project: WeakEntity, thread_store: Entity, prompt_store: Option>, - history: Option>, initial_prompt: Option, window: &mut Window, codegen_ranges: &[Range], @@ -493,7 +484,6 @@ impl InlineAssistant { self.fs.clone(), thread_store.clone(), prompt_store.clone(), - history.clone(), project.clone(), workspace.clone(), window, @@ -585,7 +575,6 @@ impl InlineAssistant { project: WeakEntity, thread_store: Entity, prompt_store: Option>, - history: Option>, initial_prompt: Option, window: &mut Window, cx: &mut App, @@ -604,7 +593,6 @@ impl InlineAssistant { project, thread_store, prompt_store, - history, initial_prompt, window, &codegen_ranges, @@ -630,7 +618,6 @@ impl InlineAssistant { workspace: Entity, thread_store: Entity, prompt_store: Option>, - history: Option>, window: &mut Window, cx: &mut App, ) -> InlineAssistId { @@ -650,7 +637,6 @@ impl InlineAssistant { project, thread_store, prompt_store, - history, Some(initial_prompt), window, &[range], @@ -1975,7 +1961,6 @@ pub mod evals { project.downgrade(), thread_store, None, - None, Some(prompt), window, cx, diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 5d168d410476b1..58a67d2578dd50 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -1,4 +1,3 @@ -use crate::ThreadHistory; use agent::ThreadStore; use agent_settings::AgentSettings; use collections::{HashMap, VecDeque}; @@ -64,7 +63,6 @@ pub struct PromptEditor { pub editor: Entity, mode: PromptEditorMode, mention_set: Entity, - history: Option>, prompt_store: Option>, workspace: WeakEntity, model_selector: Entity, @@ -168,6 +166,7 @@ impl Render for PromptEditor { .child( h_flex() .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::secondary_confirm)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::move_down)) @@ -335,7 +334,6 @@ impl PromptEditor { PromptEditorCompletionProviderDelegate, cx.weak_entity(), self.mention_set.clone(), - self.history.clone(), self.prompt_store.clone(), self.workspace.clone(), )))); @@ -533,6 +531,20 @@ impl PromptEditor { } fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.handle_confirm(false, cx); + } + + fn secondary_confirm( + &mut self, + _: &menu::SecondaryConfirm, + _window: &mut Window, + cx: &mut Context, + ) { + let execute = matches!(self.mode, PromptEditorMode::Terminal { .. }); + self.handle_confirm(execute, cx); + } + + fn handle_confirm(&mut self, execute: bool, cx: &mut Context) { match self.codegen_status(cx) { CodegenStatus::Idle => { self.fire_started_telemetry(cx); @@ -544,7 +556,7 @@ impl PromptEditor { self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } else { - cx.emit(PromptEditorEvent::ConfirmRequested { execute: false }); + cx.emit(PromptEditorEvent::ConfirmRequested { execute }); } } CodegenStatus::Error(_) => { @@ -1227,7 +1239,6 @@ impl PromptEditor { fs: Arc, thread_store: Entity, prompt_store: Option>, - history: Option>, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1274,7 +1285,6 @@ impl PromptEditor { let mut this: PromptEditor = PromptEditor { editor: prompt_editor.clone(), mention_set, - history, prompt_store, workspace, model_selector: cx.new(|cx| { @@ -1386,7 +1396,6 @@ impl PromptEditor { fs: Arc, thread_store: Entity, prompt_store: Option>, - history: Option>, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1428,7 +1437,6 @@ impl PromptEditor { let mut this = Self { editor: prompt_editor.clone(), mention_set, - history, prompt_store, workspace, model_selector: cx.new(|cx| { @@ -1644,3 +1652,205 @@ fn insert_message_creases( editor.fold_creases(creases, false, window, cx); ids } + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal_codegen::TerminalCodegen; + use agent::ThreadStore; + use collections::VecDeque; + use fs::FakeFs; + use gpui::{TestAppContext, VisualTestContext}; + use language::Buffer; + use project::Project; + use settings::SettingsStore; + use std::cell::RefCell; + use std::path::Path; + use std::rc::Rc; + use terminal::TerminalBuilder; + use terminal::terminal_settings::CursorShape; + use util::path; + use util::paths::PathStyle; + use uuid::Uuid; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + } + + fn build_terminal_prompt_editor( + workspace: &Entity, + cx: &mut VisualTestContext, + ) -> Entity> { + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let fs = FakeFs::new(cx.executor()); + + let terminal = cx.update(|_window, cx| { + cx.new(|cx| { + TerminalBuilder::new_display_only( + CursorShape::default(), + settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .unwrap() + .subscribe(cx) + }) + }); + + let session_id = Uuid::new_v4(); + let codegen = + cx.update(|_window, cx| cx.new(|_| TerminalCodegen::new(terminal, session_id))); + + let prompt_buffer = cx.update(|_window, cx| { + cx.new(|cx| MultiBuffer::singleton(cx.new(|cx| Buffer::local("", cx)), cx)) + }); + + let project = workspace.update(cx, |workspace, _cx| workspace.project().downgrade()); + + cx.update(|window, cx| { + cx.new(|cx| { + PromptEditor::new_terminal( + TerminalInlineAssistId::default(), + VecDeque::new(), + prompt_buffer, + codegen, + session_id, + fs, + thread_store, + None, + project, + workspace.downgrade(), + window, + cx, + ) + }) + }) + } + + #[gpui::test] + async fn test_secondary_confirm_emits_execute_true_in_terminal_mode(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", serde_json::json!({"file": ""})) + .await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let prompt_editor = build_terminal_prompt_editor(&workspace, cx); + + // Set the codegen status to Done so that confirm logic emits ConfirmRequested. + prompt_editor.update(cx, |editor, cx| { + editor.codegen().update(cx, |codegen, _| { + codegen.status = CodegenStatus::Done; + }); + editor.edited_since_done = false; + }); + + let events: Rc>> = Rc::new(RefCell::new(Vec::new())); + let events_clone = events.clone(); + cx.update(|_window, cx| { + cx.subscribe(&prompt_editor, move |_, event: &PromptEditorEvent, _cx| { + events_clone.borrow_mut().push(match event { + PromptEditorEvent::ConfirmRequested { execute } => { + PromptEditorEvent::ConfirmRequested { execute: *execute } + } + PromptEditorEvent::StartRequested => PromptEditorEvent::StartRequested, + PromptEditorEvent::StopRequested => PromptEditorEvent::StopRequested, + PromptEditorEvent::CancelRequested => PromptEditorEvent::CancelRequested, + PromptEditorEvent::Resized { height_in_lines } => PromptEditorEvent::Resized { + height_in_lines: *height_in_lines, + }, + }); + }) + .detach(); + }); + + // Dispatch menu::SecondaryConfirm (cmd-enter). + prompt_editor.update(cx, |editor, cx| { + editor.handle_confirm(true, cx); + }); + + let events = events.borrow(); + assert_eq!(events.len(), 1, "Expected exactly one event"); + assert!( + matches!( + events[0], + PromptEditorEvent::ConfirmRequested { execute: true } + ), + "Expected ConfirmRequested with execute: true, got {:?}", + match &events[0] { + PromptEditorEvent::ConfirmRequested { execute } => + format!("ConfirmRequested {{ execute: {} }}", execute), + _ => "other event".to_string(), + } + ); + } + + #[gpui::test] + async fn test_confirm_emits_execute_false_in_terminal_mode(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", serde_json::json!({"file": ""})) + .await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let prompt_editor = build_terminal_prompt_editor(&workspace, cx); + + prompt_editor.update(cx, |editor, cx| { + editor.codegen().update(cx, |codegen, _| { + codegen.status = CodegenStatus::Done; + }); + editor.edited_since_done = false; + }); + + let events: Rc>> = Rc::new(RefCell::new(Vec::new())); + let events_clone = events.clone(); + cx.update(|_window, cx| { + cx.subscribe(&prompt_editor, move |_, event: &PromptEditorEvent, _cx| { + events_clone.borrow_mut().push(match event { + PromptEditorEvent::ConfirmRequested { execute } => { + PromptEditorEvent::ConfirmRequested { execute: *execute } + } + PromptEditorEvent::StartRequested => PromptEditorEvent::StartRequested, + PromptEditorEvent::StopRequested => PromptEditorEvent::StopRequested, + PromptEditorEvent::CancelRequested => PromptEditorEvent::CancelRequested, + PromptEditorEvent::Resized { height_in_lines } => PromptEditorEvent::Resized { + height_in_lines: *height_in_lines, + }, + }); + }) + .detach(); + }); + + // Dispatch menu::Confirm (enter) — should emit execute: false even in terminal mode. + prompt_editor.update(cx, |editor, cx| { + editor.handle_confirm(false, cx); + }); + + let events = events.borrow(); + assert_eq!(events.len(), 1, "Expected exactly one event"); + assert!( + matches!( + events[0], + PromptEditorEvent::ConfirmRequested { execute: false } + ), + "Expected ConfirmRequested with execute: false" + ); + } +} diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 7de58fd54ffd0d..4f870b7cfbd480 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -566,7 +566,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { mod tests { use super::*; use futures::{future::BoxFuture, stream::BoxStream}; - use gpui::{AsyncApp, TestAppContext, http_client}; + use gpui::{AsyncApp, TestAppContext}; use language_model::{ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, @@ -630,14 +630,6 @@ mod tests { 1000 } - fn count_tokens( - &self, - _: LanguageModelRequest, - _: &App, - ) -> BoxFuture<'static, http_client::Result> { - unimplemented!() - } - fn stream_completion( &self, _: LanguageModelRequest, diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 880257e3f942bf..0c7b3eb6baaa37 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -1,7 +1,7 @@ use crate::diagnostics::{DiagnosticsOptions, codeblock_fence_for_path, collect_diagnostics}; use acp_thread::{MentionUri, selection_name}; use agent::{ThreadStore, outline}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_servers::{AgentServer, AgentServerDelegate}; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; @@ -839,7 +839,11 @@ fn image_format_from_external_content(format: image::ImageFormat) -> Option Some(ImageFormat::Bmp), image::ImageFormat::Tiff => Some(ImageFormat::Tiff), image::ImageFormat::Ico => Some(ImageFormat::Ico), - _ => None, + image::ImageFormat::Pnm => Some(ImageFormat::Pnm), + _ => { + debug_panic!("An unhandled image format: {format:?}"); + None + } } } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 3b93439b62305f..0f213cb9f1e365 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1,6 +1,5 @@ use crate::DEFAULT_THREAD_TITLE; use crate::SendImmediately; -use crate::ThreadHistory; use crate::{ ChatWithFollow, completion_provider::{ @@ -11,7 +10,7 @@ use crate::{ }; use acp_thread::MentionUri; use agent::ThreadStore; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::{Result, anyhow}; use editor::{ Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, @@ -394,7 +393,6 @@ impl MessageEditor { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - history: Option>, prompt_store: Option>, session_capabilities: SharedSessionCapabilities, agent_id: AgentId, @@ -458,7 +456,6 @@ impl MessageEditor { }, editor.downgrade(), mention_set.clone(), - history, prompt_store.clone(), workspace.clone(), )); @@ -1910,7 +1907,7 @@ mod tests { use acp_thread::MentionUri; use agent::{ThreadStore, outline}; - use agent_client_protocol as acp; + use agent_client_protocol::schema as acp; use base64::Engine as _; use editor::{ AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, @@ -2053,7 +2050,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -2155,7 +2151,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, session_capabilities.clone(), "Claude Agent".into(), "Test", @@ -2322,7 +2317,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, session_capabilities.clone(), "Test Agent".into(), "Test", @@ -2549,7 +2543,6 @@ mod tests { project.downgrade(), Some(thread_store), None, - None, session_capabilities.clone(), "Test Agent".into(), "Test", @@ -3042,7 +3035,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3144,7 +3136,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3214,7 +3205,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3268,7 +3258,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3326,7 +3315,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3385,7 +3373,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3448,7 +3435,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3609,7 +3595,6 @@ mod tests { project.downgrade(), thread_store.clone(), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3724,7 +3709,6 @@ mod tests { project.downgrade(), Some(thread_store.clone()), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3804,7 +3788,6 @@ mod tests { project.downgrade(), Some(thread_store), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -3903,7 +3886,6 @@ mod tests { project.downgrade(), Some(thread_store), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -4159,7 +4141,6 @@ mod tests { project.downgrade(), Some(thread_store), None, - None, Default::default(), "Test Agent".into(), "Test", @@ -4253,7 +4234,6 @@ mod tests { project.downgrade(), None, None, - None, Default::default(), "Test Agent".into(), "Test", @@ -4403,7 +4383,6 @@ mod tests { project.downgrade(), None, None, - None, Default::default(), "Test Agent".into(), "Test", diff --git a/crates/agent_ui/src/mode_selector.rs b/crates/agent_ui/src/mode_selector.rs index 2b0754e9dc993c..9e4464517c2d4c 100644 --- a/crates/agent_ui/src/mode_selector.rs +++ b/crates/agent_ui/src/mode_selector.rs @@ -1,5 +1,5 @@ use acp_thread::AgentSessionModes; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_servers::AgentServer; use fs::Fs; @@ -197,7 +197,7 @@ impl Render for ModeSelector { } }), ) - .anchor(gpui::Corner::BottomRight) + .anchor(gpui::Anchor::BottomRight) .with_handle(self.menu_handle.clone()) .offset(gpui::Point { x: px(0.0), diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index 89290bd9973216..e1cf7307394571 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -1,7 +1,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector}; -use agent_client_protocol::ModelId; +use agent_client_protocol::schema as acp; use agent_servers::AgentServer; use anyhow::Result; @@ -57,7 +57,7 @@ pub struct ModelPickerDelegate { selected_index: usize, selected_description: Option<(usize, SharedString, bool)>, selected_model: Option, - favorites: HashSet, + favorites: HashSet, _refresh_models_task: Task<()>, _settings_subscription: Subscription, focus_handle: FocusHandle, @@ -424,7 +424,7 @@ impl PickerDelegate for ModelPickerDelegate { fn info_list_to_picker_entries( model_list: AgentModelList, - favorites: &HashSet, + favorites: &HashSet, ) -> Vec { let mut entries = Vec::new(); @@ -530,7 +530,6 @@ async fn fuzzy_search( #[cfg(test)] mod tests { - use agent_client_protocol as acp; use gpui::TestAppContext; use super::*; @@ -592,10 +591,10 @@ mod tests { } } - fn create_favorites(models: Vec<&str>) -> HashSet { + fn create_favorites(models: Vec<&str>) -> HashSet { models .into_iter() - .map(|m| ModelId::new(m.to_string())) + .map(|m| acp::ModelId::new(m.to_string())) .collect() } @@ -791,7 +790,7 @@ mod tests { #[gpui::test] fn test_favorites_count_returns_correct_count(_cx: &mut TestAppContext) { - let empty_favorites: HashSet = HashSet::default(); + let empty_favorites: HashSet = HashSet::default(); assert_eq!(empty_favorites.len(), 0); let one_favorite = create_favorites(vec!["model-a"]); diff --git a/crates/agent_ui/src/model_selector_popover.rs b/crates/agent_ui/src/model_selector_popover.rs index 75ef5ab8cc907c..2396622ef89636 100644 --- a/crates/agent_ui/src/model_selector_popover.rs +++ b/crates/agent_ui/src/model_selector_popover.rs @@ -92,7 +92,7 @@ impl Render for ModelSelectorPopover { }) .end_icon(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)), tooltip, - gpui::Corner::BottomRight, + gpui::Anchor::BottomRight, cx, ) .with_handle(self.menu_handle.clone()) diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 2b62b3121f80d0..2f32d27983589f 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -215,7 +215,7 @@ impl Render for ProfileSelector { picker, trigger_button, tooltip, - gpui::Corner::BottomRight, + gpui::Anchor::BottomRight, cx, ) .with_handle(self.picker_handle.clone()) diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 89c1ec431386e5..c4db6a088da105 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -1,5 +1,4 @@ use crate::{ - ThreadHistory, context::load_context, inline_prompt_editor::{ CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId, @@ -66,7 +65,6 @@ impl TerminalInlineAssistant { project: WeakEntity, thread_store: Entity, prompt_store: Option>, - history: Option>, initial_prompt: Option, window: &mut Window, cx: &mut App, @@ -92,7 +90,6 @@ impl TerminalInlineAssistant { self.fs.clone(), thread_store.clone(), prompt_store.clone(), - history, project.clone(), workspace.clone(), window, diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index a141121dda1432..1f409ebfc74360 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -1,5 +1,5 @@ use acp_thread::{AgentConnection, StubAgentConnection}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_servers::{AgentServer, AgentServerDelegate}; use gpui::{Entity, Task, TestAppContext, VisualTestContext}; use project::AgentId; diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs deleted file mode 100644 index 7b7a3e60211896..00000000000000 --- a/crates/agent_ui/src/thread_history.rs +++ /dev/null @@ -1,772 +0,0 @@ -use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate}; -use agent_client_protocol as acp; -use gpui::{App, Task}; -use std::rc::Rc; -use ui::prelude::*; - -pub struct ThreadHistory { - session_list: Rc, - sessions: Vec, - _refresh_task: Task<()>, - _watch_task: Option>, -} - -impl ThreadHistory { - pub fn new(session_list: Rc, cx: &mut Context) -> Self { - let mut this = Self { - session_list, - sessions: Vec::new(), - _refresh_task: Task::ready(()), - _watch_task: None, - }; - - this.start_watching(cx); - this - } - - #[cfg(any(test, feature = "test-support"))] - pub fn set_session_list( - &mut self, - session_list: Rc, - cx: &mut Context, - ) { - if Rc::ptr_eq(&self.session_list, &session_list) { - return; - } - - self.session_list = session_list; - self.sessions.clear(); - self._refresh_task = Task::ready(()); - self.start_watching(cx); - } - - fn start_watching(&mut self, cx: &mut Context) { - let Some(rx) = self.session_list.watch(cx) else { - self._watch_task = None; - self.refresh_sessions(false, cx); - return; - }; - self.session_list.notify_refresh(); - - self._watch_task = Some(cx.spawn(async move |this, cx| { - while let Ok(first_update) = rx.recv().await { - let mut updates = vec![first_update]; - while let Ok(update) = rx.try_recv() { - updates.push(update); - } - - this.update(cx, |this, cx| { - let needs_refresh = updates - .iter() - .any(|u| matches!(u, SessionListUpdate::Refresh)); - - if needs_refresh { - this.refresh_sessions(false, cx); - } else { - for update in updates { - if let SessionListUpdate::SessionInfo { session_id, update } = update { - this.apply_info_update(session_id, update, cx); - } - } - } - }) - .ok(); - } - })); - } - - pub(crate) fn refresh_full_history(&mut self, cx: &mut Context) { - self.refresh_sessions(true, cx); - } - - fn apply_info_update( - &mut self, - session_id: acp::SessionId, - info_update: acp::SessionInfoUpdate, - cx: &mut Context, - ) { - let Some(session) = self - .sessions - .iter_mut() - .find(|s| s.session_id == session_id) - else { - return; - }; - - match info_update.title { - acp::MaybeUndefined::Value(title) => { - session.title = Some(title.into()); - } - acp::MaybeUndefined::Null => { - session.title = None; - } - acp::MaybeUndefined::Undefined => {} - } - match info_update.updated_at { - acp::MaybeUndefined::Value(date_str) => { - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) { - session.updated_at = Some(dt.with_timezone(&chrono::Utc)); - } - } - acp::MaybeUndefined::Null => { - session.updated_at = None; - } - acp::MaybeUndefined::Undefined => {} - } - if let Some(meta) = info_update.meta { - session.meta = Some(meta); - } - - cx.notify(); - } - - fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context) { - let session_list = self.session_list.clone(); - - self._refresh_task = cx.spawn(async move |this, cx| { - let mut cursor: Option = None; - let mut is_first_page = true; - - loop { - let request = AgentSessionListRequest { - cursor: cursor.clone(), - ..Default::default() - }; - let task = cx.update(|cx| session_list.list_sessions(request, cx)); - let response = match task.await { - Ok(response) => response, - Err(error) => { - log::error!("Failed to load session history: {error:#}"); - return; - } - }; - - let acp_thread::AgentSessionListResponse { - sessions: page_sessions, - next_cursor, - .. - } = response; - - this.update(cx, |this, cx| { - if is_first_page { - this.sessions = page_sessions; - } else { - this.sessions.extend(page_sessions); - } - cx.notify(); - }) - .ok(); - - is_first_page = false; - if !load_all_pages { - break; - } - - match next_cursor { - Some(next_cursor) => { - if cursor.as_ref() == Some(&next_cursor) { - log::warn!( - "Session list pagination returned the same cursor; stopping to avoid a loop." - ); - break; - } - cursor = Some(next_cursor); - } - None => break, - } - } - }); - } - - pub(crate) fn is_empty(&self) -> bool { - self.sessions.is_empty() - } - - pub fn refresh(&mut self, _cx: &mut Context) { - self.session_list.notify_refresh(); - } - - pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option { - self.sessions - .iter() - .find(|entry| &entry.session_id == session_id) - .cloned() - } - - pub(crate) fn sessions(&self) -> &[AgentSessionInfo] { - &self.sessions - } - - pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec { - self.sessions.iter().take(limit).cloned().collect() - } - - pub fn supports_delete(&self) -> bool { - self.session_list.supports_delete() - } - - pub(crate) fn delete_session( - &self, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - self.session_list.delete_session(session_id, cx) - } - - pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task> { - self.session_list.delete_sessions(cx) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use acp_thread::AgentSessionListResponse; - use gpui::TestAppContext; - use std::{ - any::Any, - sync::{Arc, Mutex}, - }; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = settings::SettingsStore::test(cx); - cx.set_global(settings_store); - theme_settings::init(theme::LoadThemes::JustBase, cx); - }); - } - - #[derive(Clone)] - struct TestSessionList { - sessions: Vec, - updates_tx: smol::channel::Sender, - updates_rx: smol::channel::Receiver, - } - - impl TestSessionList { - fn new(sessions: Vec) -> Self { - let (tx, rx) = smol::channel::unbounded(); - Self { - sessions, - updates_tx: tx, - updates_rx: rx, - } - } - - fn send_update(&self, update: SessionListUpdate) { - self.updates_tx.try_send(update).ok(); - } - } - - impl AgentSessionList for TestSessionList { - fn list_sessions( - &self, - _request: AgentSessionListRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) - } - - fn watch(&self, _cx: &mut App) -> Option> { - Some(self.updates_rx.clone()) - } - - fn notify_refresh(&self) { - self.send_update(SessionListUpdate::Refresh); - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - #[derive(Clone)] - struct PaginatedTestSessionList { - first_page_sessions: Vec, - second_page_sessions: Vec, - requested_cursors: Arc>>>, - async_responses: bool, - updates_tx: smol::channel::Sender, - updates_rx: smol::channel::Receiver, - } - - impl PaginatedTestSessionList { - fn new( - first_page_sessions: Vec, - second_page_sessions: Vec, - ) -> Self { - let (tx, rx) = smol::channel::unbounded(); - Self { - first_page_sessions, - second_page_sessions, - requested_cursors: Arc::new(Mutex::new(Vec::new())), - async_responses: false, - updates_tx: tx, - updates_rx: rx, - } - } - - fn with_async_responses(mut self) -> Self { - self.async_responses = true; - self - } - - fn requested_cursors(&self) -> Vec> { - self.requested_cursors.lock().unwrap().clone() - } - - fn clear_requested_cursors(&self) { - self.requested_cursors.lock().unwrap().clear() - } - - fn send_update(&self, update: SessionListUpdate) { - self.updates_tx.try_send(update).ok(); - } - } - - impl AgentSessionList for PaginatedTestSessionList { - fn list_sessions( - &self, - request: AgentSessionListRequest, - cx: &mut App, - ) -> Task> { - let requested_cursors = self.requested_cursors.clone(); - let first_page_sessions = self.first_page_sessions.clone(); - let second_page_sessions = self.second_page_sessions.clone(); - - let respond = move || { - requested_cursors - .lock() - .unwrap() - .push(request.cursor.clone()); - - match request.cursor.as_deref() { - None => AgentSessionListResponse { - sessions: first_page_sessions, - next_cursor: Some("page-2".to_string()), - meta: None, - }, - Some("page-2") => AgentSessionListResponse::new(second_page_sessions), - _ => AgentSessionListResponse::new(Vec::new()), - } - }; - - if self.async_responses { - cx.foreground_executor().spawn(async move { - smol::future::yield_now().await; - Ok(respond()) - }) - } else { - Task::ready(Ok(respond())) - } - } - - fn watch(&self, _cx: &mut App) -> Option> { - Some(self.updates_rx.clone()) - } - - fn notify_refresh(&self) { - self.send_update(SessionListUpdate::Refresh); - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - fn test_session(session_id: &str, title: &str) -> AgentSessionInfo { - AgentSessionInfo { - session_id: acp::SessionId::new(session_id), - work_dirs: None, - title: Some(title.to_string().into()), - updated_at: None, - created_at: None, - meta: None, - } - } - - #[gpui::test] - async fn test_refresh_only_loads_first_page_by_default(cx: &mut TestAppContext) { - init_test(cx); - - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 1); - assert_eq!( - history.sessions[0].session_id, - acp::SessionId::new("session-1") - ); - }); - assert_eq!(session_list.requested_cursors(), vec![None]); - } - - #[gpui::test] - async fn test_enabling_full_pagination_loads_all_pages(cx: &mut TestAppContext) { - init_test(cx); - - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - session_list.clear_requested_cursors(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 2); - assert_eq!( - history.sessions[0].session_id, - acp::SessionId::new("session-1") - ); - assert_eq!( - history.sessions[1].session_id, - acp::SessionId::new("session-2") - ); - }); - assert_eq!( - session_list.requested_cursors(), - vec![None, Some("page-2".to_string())] - ); - } - - #[gpui::test] - async fn test_standard_refresh_replaces_with_first_page_after_full_history_refresh( - cx: &mut TestAppContext, - ) { - init_test(cx); - - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - session_list.clear_requested_cursors(); - - history.update(cx, |history, cx| { - history.refresh(cx); - }); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 1); - assert_eq!( - history.sessions[0].session_id, - acp::SessionId::new("session-1") - ); - }); - assert_eq!(session_list.requested_cursors(), vec![None]); - } - - #[gpui::test] - async fn test_re_entering_full_pagination_reloads_all_pages(cx: &mut TestAppContext) { - init_test(cx); - - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - session_list.clear_requested_cursors(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 2); - }); - assert_eq!( - session_list.requested_cursors(), - vec![None, Some("page-2".to_string())] - ); - } - - #[gpui::test] - async fn test_partial_refresh_batch_drops_non_first_page_sessions(cx: &mut TestAppContext) { - init_test(cx); - - let second_page_session_id = acp::SessionId::new("session-2"); - let session_list = Rc::new(PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - )); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - - session_list.clear_requested_cursors(); - - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: second_page_session_id.clone(), - update: acp::SessionInfoUpdate::new().title("Updated Second"), - }); - session_list.send_update(SessionListUpdate::Refresh); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 1); - assert_eq!( - history.sessions[0].session_id, - acp::SessionId::new("session-1") - ); - assert!( - history - .sessions - .iter() - .all(|session| session.session_id != second_page_session_id) - ); - }); - assert_eq!(session_list.requested_cursors(), vec![None]); - } - - #[gpui::test] - async fn test_full_pagination_works_with_async_page_fetches(cx: &mut TestAppContext) { - init_test(cx); - - let session_list = Rc::new( - PaginatedTestSessionList::new( - vec![test_session("session-1", "First")], - vec![test_session("session-2", "Second")], - ) - .with_async_responses(), - ); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - session_list.clear_requested_cursors(); - - history.update(cx, |history, cx| history.refresh_full_history(cx)); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 2); - }); - assert_eq!( - session_list.requested_cursors(), - vec![None, Some("page-2".to_string())] - ); - } - - #[gpui::test] - async fn test_apply_info_update_title(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: None, - title: Some("Original Title".into()), - updated_at: None, - created_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title("New Title"), - }); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!( - session.unwrap().title.as_ref().map(|s| s.as_ref()), - Some("New Title") - ); - }); - } - - #[gpui::test] - async fn test_apply_info_update_clears_title_with_null(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: None, - title: Some("Original Title".into()), - updated_at: None, - created_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title(None::), - }); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!(session.unwrap().title, None); - }); - } - - #[gpui::test] - async fn test_apply_info_update_ignores_undefined_fields(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: None, - title: Some("Original Title".into()), - updated_at: None, - created_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new(), - }); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!( - session.unwrap().title.as_ref().map(|s| s.as_ref()), - Some("Original Title") - ); - }); - } - - #[gpui::test] - async fn test_multiple_info_updates_applied_in_order(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: None, - title: None, - updated_at: None, - created_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title("First Title"), - }); - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title("Second Title"), - }); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!( - session.unwrap().title.as_ref().map(|s| s.as_ref()), - Some("Second Title") - ); - }); - } - - #[gpui::test] - async fn test_refresh_supersedes_info_updates(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("test-session"); - let sessions = vec![AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: None, - title: Some("Server Title".into()), - updated_at: None, - created_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: session_id.clone(), - update: acp::SessionInfoUpdate::new().title("Local Update"), - }); - session_list.send_update(SessionListUpdate::Refresh); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - let session = history.sessions.iter().find(|s| s.session_id == session_id); - assert_eq!( - session.unwrap().title.as_ref().map(|s| s.as_ref()), - Some("Server Title") - ); - }); - } - - #[gpui::test] - async fn test_info_update_for_unknown_session_is_ignored(cx: &mut TestAppContext) { - init_test(cx); - - let session_id = acp::SessionId::new("known-session"); - let sessions = vec![AgentSessionInfo { - session_id, - work_dirs: None, - title: Some("Original".into()), - updated_at: None, - created_at: None, - meta: None, - }]; - let session_list = Rc::new(TestSessionList::new(sessions)); - - let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx)); - cx.run_until_parked(); - - session_list.send_update(SessionListUpdate::SessionInfo { - session_id: acp::SessionId::new("unknown-session"), - update: acp::SessionInfoUpdate::new().title("Should Be Ignored"), - }); - cx.run_until_parked(); - - history.update(cx, |history, _cx| { - assert_eq!(history.sessions.len(), 1); - assert_eq!( - history.sessions[0].title.as_ref().map(|s| s.as_ref()), - Some("Original") - ); - }); - } -} diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs deleted file mode 100644 index 1cebd175be46ea..00000000000000 --- a/crates/agent_ui/src/thread_history_view.rs +++ /dev/null @@ -1,751 +0,0 @@ -use crate::thread_history::ThreadHistory; -use crate::{DEFAULT_THREAD_TITLE, RemoveHistory, RemoveSelectedThread}; -use acp_thread::AgentSessionInfo; -use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; -use editor::{Editor, EditorEvent}; -use fuzzy::StringMatchCandidate; -use gpui::{ - AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, - UniformListScrollHandle, Window, uniform_list, -}; -use std::{fmt::Display, ops::Range}; -use text::Bias; -use time::{OffsetDateTime, UtcOffset}; -use ui::{ - HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, - prelude::*, -}; - -pub(crate) fn thread_title(entry: &AgentSessionInfo) -> SharedString { - entry - .title - .clone() - .and_then(|title| if title.is_empty() { None } else { Some(title) }) - .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()) -} - -pub struct ThreadHistoryView { - history: Entity, - scroll_handle: UniformListScrollHandle, - selected_index: usize, - hovered_index: Option, - search_editor: Entity, - search_query: SharedString, - visible_items: Vec, - local_timezone: UtcOffset, - confirming_delete_history: bool, - _visible_items_task: Task<()>, - _subscriptions: Vec, -} - -enum ListItemType { - BucketSeparator(TimeBucket), - Entry { - entry: AgentSessionInfo, - format: EntryTimeFormat, - }, - SearchResult { - entry: AgentSessionInfo, - positions: Vec, - }, -} - -impl ListItemType { - fn history_entry(&self) -> Option<&AgentSessionInfo> { - match self { - ListItemType::Entry { entry, .. } => Some(entry), - ListItemType::SearchResult { entry, .. } => Some(entry), - _ => None, - } - } -} - -pub enum ThreadHistoryViewEvent { - Open(AgentSessionInfo), -} - -impl EventEmitter for ThreadHistoryView {} - -impl ThreadHistoryView { - pub fn new( - history: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let search_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search all threads…", window, cx); - editor - }); - - let search_editor_subscription = - cx.subscribe(&search_editor, |this, search_editor, event, cx| { - if let EditorEvent::BufferEdited = event { - let query = search_editor.read(cx).text(cx); - if this.search_query != query { - this.search_query = query.into(); - this.update_visible_items(false, cx); - } - } - }); - - let history_subscription = cx.observe(&history, |this, _, cx| { - this.update_visible_items(true, cx); - }); - - let scroll_handle = UniformListScrollHandle::default(); - - let mut this = Self { - history, - scroll_handle, - selected_index: 0, - hovered_index: None, - visible_items: Default::default(), - search_editor, - local_timezone: UtcOffset::from_whole_seconds( - chrono::Local::now().offset().local_minus_utc(), - ) - .unwrap(), - search_query: SharedString::default(), - confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription, history_subscription], - _visible_items_task: Task::ready(()), - }; - this.update_visible_items(false, cx); - this - } - - pub fn history(&self) -> &Entity { - &self.history - } - - fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let entries = self.history.read(cx).sessions().to_vec(); - let new_list_items = if self.search_query.is_empty() { - self.add_list_separators(entries, cx) - } else { - self.filter_search_results(entries, cx) - }; - let selected_history_entry = if preserve_selected_item { - self.selected_history_entry().cloned() - } else { - None - }; - - self._visible_items_task = cx.spawn(async move |this, cx| { - let new_visible_items = new_list_items.await; - this.update(cx, |this, cx| { - let new_selected_index = if let Some(history_entry) = selected_history_entry { - new_visible_items - .iter() - .position(|visible_entry| { - visible_entry - .history_entry() - .is_some_and(|entry| entry.session_id == history_entry.session_id) - }) - .unwrap_or(0) - } else { - 0 - }; - - this.visible_items = new_visible_items; - this.set_selected_index(new_selected_index, Bias::Right, cx); - cx.notify(); - }) - .ok(); - }); - } - - fn add_list_separators( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - cx.background_spawn(async move { - let mut items = Vec::with_capacity(entries.len() + 1); - let mut bucket = None; - let today = Local::now().naive_local().date(); - - for entry in entries.into_iter() { - let entry_bucket = entry - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::All); - - if Some(entry_bucket) != bucket { - bucket = Some(entry_bucket); - items.push(ListItemType::BucketSeparator(entry_bucket)); - } - - items.push(ListItemType::Entry { - entry, - format: entry_bucket.into(), - }); - } - items - }) - } - - fn filter_search_results( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - let query = self.search_query.clone(); - cx.background_spawn({ - let executor = cx.background_executor().clone(); - async move { - let mut candidates = Vec::with_capacity(entries.len()); - - for (idx, entry) in entries.iter().enumerate() { - candidates.push(StringMatchCandidate::new(idx, &thread_title(entry))); - } - - const MAX_MATCHES: usize = 100; - - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - MAX_MATCHES, - &Default::default(), - executor, - ) - .await; - - matches - .into_iter() - .map(|search_match| ListItemType::SearchResult { - entry: entries[search_match.candidate_id].clone(), - positions: search_match.positions, - }) - .collect() - } - }) - } - - fn search_produced_no_matches(&self) -> bool { - self.visible_items.is_empty() && !self.search_query.is_empty() - } - - fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { - self.get_history_entry(self.selected_index) - } - - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { - self.visible_items.get(visible_items_ix)?.history_entry() - } - - fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { - if self.visible_items.len() == 0 { - self.selected_index = 0; - return; - } - while matches!( - self.visible_items.get(index), - None | Some(ListItemType::BucketSeparator(..)) - ) { - index = match bias { - Bias::Left => { - if index == 0 { - self.visible_items.len() - 1 - } else { - index - 1 - } - } - Bias::Right => { - if index >= self.visible_items.len() - 1 { - 0 - } else { - index + 1 - } - } - }; - } - self.selected_index = index; - self.scroll_handle - .scroll_to_item(index, ScrollStrategy::Top); - cx.notify() - } - - fn select_previous( - &mut self, - _: &menu::SelectPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == 0 { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } else { - self.set_selected_index(self.selected_index - 1, Bias::Left, cx); - } - } - - fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { - if self.selected_index == self.visible_items.len() - 1 { - self.set_selected_index(0, Bias::Right, cx); - } else { - self.set_selected_index(self.selected_index + 1, Bias::Right, cx); - } - } - - fn select_first( - &mut self, - _: &menu::SelectFirst, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_selected_index(0, Bias::Right, cx); - } - - fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } - - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - self.confirm_entry(self.selected_index, cx); - } - - fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(ix) else { - return; - }; - cx.emit(ThreadHistoryViewEvent::Open(entry.clone())); - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - self.remove_thread(self.selected_index, cx) - } - - fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(visible_item_ix) else { - return; - }; - if !self.history.read(cx).supports_delete() { - return; - } - let session_id = entry.session_id.clone(); - self.history.update(cx, |history, cx| { - history - .delete_session(&session_id, cx) - .detach_and_log_err(cx); - }); - } - - fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - if !self.history.read(cx).supports_delete() { - return; - } - self.history.update(cx, |history, cx| { - history.delete_sessions(cx).detach_and_log_err(cx); - }); - self.confirming_delete_history = false; - cx.notify(); - } - - fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = true; - cx.notify(); - } - - fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = false; - cx.notify(); - } - - fn render_list_items( - &mut self, - range: Range, - _window: &mut Window, - cx: &mut Context, - ) -> Vec { - self.visible_items - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) - .collect() - } - - fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { - match item { - ListItemType::Entry { entry, format } => self - .render_history_entry(entry, *format, ix, Vec::default(), cx) - .into_any(), - ListItemType::SearchResult { entry, positions } => self.render_history_entry( - entry, - EntryTimeFormat::DateAndTime, - ix, - positions.clone(), - cx, - ), - ListItemType::BucketSeparator(bucket) => div() - .px(DynamicSpacing::Base06.rems(cx)) - .pt_2() - .pb_1() - .child( - Label::new(bucket.to_string()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - } - } - - fn render_history_entry( - &self, - entry: &AgentSessionInfo, - format: EntryTimeFormat, - ix: usize, - highlight_positions: Vec, - cx: &Context, - ) -> AnyElement { - let selected = ix == self.selected_index; - let hovered = Some(ix) == self.hovered_index; - let entry_time = entry.updated_at; - let display_text = match (format, entry_time) { - (EntryTimeFormat::DateAndTime, Some(entry_time)) => { - let now = Utc::now(); - let duration = now.signed_duration_since(entry_time); - let days = duration.num_days(); - - format!("{}d", days) - } - (EntryTimeFormat::TimeOnly, Some(entry_time)) => { - format.format_timestamp(entry_time.timestamp(), self.local_timezone) - } - (_, None) => "—".to_string(), - }; - - let title = thread_title(entry); - let full_date = entry_time - .map(|time| { - EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) - }) - .unwrap_or_else(|| "Unknown".to_string()); - - let supports_delete = self.history.read(cx).supports_delete(); - - h_flex() - .w_full() - .pb_1() - .child( - ListItem::new(ix) - .rounded() - .toggle_state(selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child( - HighlightedLabel::new(thread_title(entry), highlight_positions) - .size(LabelSize::Small) - .truncate(), - ) - .child( - Label::new(display_text) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) - }) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_index = Some(ix); - } else if this.hovered_index == Some(ix) { - this.hovered_index = None; - } - - cx.notify(); - })) - .end_slot::(if hovered && supports_delete { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_thread(ix, cx); - cx.stop_propagation() - })), - ) - } else { - None - }) - .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), - ) - .into_any_element() - } -} - -impl Focusable for ThreadHistoryView { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.search_editor.focus_handle(cx) - } -} - -impl Render for ThreadHistoryView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.history.read(cx).is_empty(); - let supports_delete = self.history.read(cx).supports_delete(); - - v_flex() - .key_context("ThreadHistory") - .size_full() - .bg(cx.theme().colors().panel_background) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::remove_selected_thread)) - .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { - this.remove_history(window, cx); - })) - .child( - h_flex() - .h(Tab::container_height(cx)) - .w_full() - .py_1() - .px_2() - .gap_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(self.search_editor.clone()), - ) - .child({ - let view = v_flex() - .id("list-container") - .relative() - .overflow_hidden() - .flex_grow(); - - if has_no_history { - view.justify_center().items_center().child( - Label::new("You don't have any past threads yet.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - } else if self.search_produced_no_matches() { - view.justify_center() - .items_center() - .child(Label::new("No threads match your search.").size(LabelSize::Small)) - } else { - view.child( - uniform_list( - "thread-history", - self.visible_items.len(), - cx.processor(|this, range: Range, window, cx| { - this.render_list_items(range, window, cx) - }), - ) - .p_1() - .pr_4() - .track_scroll(&self.scroll_handle) - .flex_grow(), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - } - }) - .when(!has_no_history && supports_delete, |this| { - this.child( - h_flex() - .p_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .when(!self.confirming_delete_history, |this| { - this.child( - Button::new("delete_history", "Delete All History") - .full_width() - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.prompt_delete_history(window, cx); - })), - ) - }) - .when(self.confirming_delete_history, |this| { - this.w_full() - .gap_2() - .flex_wrap() - .justify_between() - .child( - h_flex() - .flex_wrap() - .gap_1() - .child( - Label::new("Delete all threads?") - .size(LabelSize::Small), - ) - .child( - Label::new("You won't be able to recover them later.") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("cancel_delete", "Cancel") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_delete_history(window, cx); - })), - ) - .child( - Button::new("confirm_delete", "Delete") - .style(ButtonStyle::Tinted(ui::TintColor::Error)) - .color(Color::Error) - .label_size(LabelSize::Small) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action( - Box::new(RemoveHistory), - cx, - ); - })), - ), - ) - }), - ) - }) - } -} - -#[derive(Clone, Copy)] -pub enum EntryTimeFormat { - DateAndTime, - TimeOnly, -} - -impl EntryTimeFormat { - fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { - let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); - - match self { - EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( - timestamp, - OffsetDateTime::now_utc(), - timezone, - time_format::TimestampFormat::EnhancedAbsolute, - ), - EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), - } - } -} - -impl From for EntryTimeFormat { - fn from(bucket: TimeBucket) -> Self { - match bucket { - TimeBucket::Today => EntryTimeFormat::TimeOnly, - TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, - TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, - TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, - TimeBucket::All => EntryTimeFormat::DateAndTime, - } - } -} - -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -enum TimeBucket { - Today, - Yesterday, - ThisWeek, - PastWeek, - All, -} - -impl TimeBucket { - fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { - if date == reference { - return TimeBucket::Today; - } - - if date == reference - TimeDelta::days(1) { - return TimeBucket::Yesterday; - } - - let week = date.iso_week(); - - if reference.iso_week() == week { - return TimeBucket::ThisWeek; - } - - let last_week = (reference - TimeDelta::days(7)).iso_week(); - - if week == last_week { - return TimeBucket::PastWeek; - } - - TimeBucket::All - } -} - -impl Display for TimeBucket { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TimeBucket::Today => write!(f, "Today"), - TimeBucket::Yesterday => write!(f, "Yesterday"), - TimeBucket::ThisWeek => write!(f, "This Week"), - TimeBucket::PastWeek => write!(f, "Past Week"), - TimeBucket::All => write!(f, "All"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::NaiveDate; - - #[test] - fn test_time_bucket_from_dates() { - let today = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(); - - assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today); - - let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap(); - assert_eq!( - TimeBucket::from_dates(today, yesterday), - TimeBucket::Yesterday - ); - - let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(); - assert_eq!( - TimeBucket::from_dates(today, this_week), - TimeBucket::ThisWeek - ); - - let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap(); - assert_eq!( - TimeBucket::from_dates(today, past_week), - TimeBucket::PastWeek - ); - - let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(); - assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All); - } -} diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index a8bd95916a7111..f5d6fa1a657d2f 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -1,6 +1,6 @@ use acp_thread::AgentSessionListRequest; use agent::ThreadStore; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use chrono::Utc; use collections::HashSet; use db::kvp::Dismissable; @@ -11,6 +11,7 @@ use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, SharedString, Task, WeakEntity, Window, }; +use itertools::Itertools as _; use notifications::status_toast::StatusToast; use project::{AgentId, AgentRegistryStore, AgentServerStore}; use release_channel::ReleaseChannel; @@ -138,6 +139,7 @@ impl ThreadImportModal { icon_path, } }) + .sorted_unstable_by_key(|entry| entry.display_name.to_lowercase()) .collect::>(); Self { @@ -501,9 +503,10 @@ fn find_threads_to_import( } } - let mut session_list_tasks = Vec::new(); cx.spawn(async move |cx| { let results = futures::future::join_all(wait_for_connection_tasks).await; + + let mut page_tasks = Vec::new(); for (agent_id, remote_connection, result) in results { let Some(state) = result.log_err() else { continue; @@ -511,28 +514,17 @@ fn find_threads_to_import( let Some(list) = cx.update(|cx| state.connection.session_list(cx)) else { continue; }; - let task = cx.update(|cx| { - list.list_sessions(AgentSessionListRequest::default(), cx) - .map({ - let remote_connection = remote_connection.clone(); - move |response| (agent_id, remote_connection, response) - }) - }); - session_list_tasks.push(task); + page_tasks.push(cx.spawn({ + let list = list.clone(); + async move |cx| collect_all_sessions(agent_id, remote_connection, list, cx).await + })); } - let mut sessions_by_agent = Vec::new(); - let results = futures::future::join_all(session_list_tasks).await; - for (agent_id, remote_connection, result) in results { - let Some(response) = result.log_err() else { - continue; - }; - sessions_by_agent.push(SessionByAgent { - agent_id, - remote_connection, - sessions: response.sessions, - }); - } + let sessions_by_agent = futures::future::join_all(page_tasks) + .await + .into_iter() + .filter_map(|result| result.log_err()) + .collect(); Ok(collect_importable_threads( sessions_by_agent, @@ -541,6 +533,34 @@ fn find_threads_to_import( }) } +async fn collect_all_sessions( + agent_id: AgentId, + remote_connection: Option, + list: std::rc::Rc, + cx: &mut gpui::AsyncApp, +) -> anyhow::Result { + let mut sessions = Vec::new(); + let mut cursor: Option = None; + loop { + let request = AgentSessionListRequest { + cursor: cursor.clone(), + ..Default::default() + }; + let task = cx.update(|cx| list.list_sessions(request, cx)); + let response = task.await?; + sessions.extend(response.sessions); + match response.next_cursor { + Some(next) if Some(&next) != cursor.as_ref() => cursor = Some(next), + _ => break, + } + } + Ok(SessionByAgent { + agent_id, + remote_connection, + sessions, + }) +} + struct SessionByAgent { agent_id: AgentId, remote_connection: Option, diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index c49220f8d44dde..21ac2af0997acc 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -4,7 +4,7 @@ use std::{ }; use agent::{ThreadStore, ZED_AGENT_ID}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::Context as _; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet}; @@ -191,7 +191,7 @@ fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task::default(); let mut remote_path_lists = HashMap::::default(); @@ -1176,7 +1176,9 @@ impl ThreadMetadataStore { .and_then(|t| t.created_at) .unwrap_or_else(|| updated_at); - let interacted_at = existing_thread.and_then(|t| t.interacted_at); + let interacted_at = existing_thread + .map(|t| t.interacted_at) + .unwrap_or(Some(updated_at)); let agent_id = thread_ref.connection().agent_id(); @@ -1678,8 +1680,7 @@ mod tests { use acp_thread::StubAgentConnection; use action_log::ActionLog; use agent::DbThread; - use agent_client_protocol as acp; - + use agent_client_protocol::schema as acp; use gpui::{TestAppContext, VisualTestContext}; use project::FakeFs; use project::Project; diff --git a/crates/agent_ui/src/thread_worktree_picker.rs b/crates/agent_ui/src/thread_worktree_picker.rs deleted file mode 100644 index 93d04fd131d424..00000000000000 --- a/crates/agent_ui/src/thread_worktree_picker.rs +++ /dev/null @@ -1,1036 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use collections::HashSet; -use fuzzy::StringMatchCandidate; -use git::repository::Worktree as GitWorktree; -use gpui::{ - AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, rems, -}; -use picker::{Picker, PickerDelegate, PickerEditorPosition}; -use project::Project; -use project::git_store::RepositoryEvent; -use ui::{Divider, HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*}; -use util::ResultExt as _; -use util::paths::PathExt; - -use crate::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree}; - -pub(crate) struct ThreadWorktreePicker { - picker: Entity>, - focus_handle: FocusHandle, - _subscriptions: Vec, -} - -impl ThreadWorktreePicker { - pub fn new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { - let project_worktree_paths: HashSet = project - .read(cx) - .visible_worktrees(cx) - .map(|wt| wt.read(cx).abs_path().to_path_buf()) - .collect(); - - let has_multiple_repositories = project.read(cx).repositories(cx).len() > 1; - - let current_branch_name = project.read(cx).active_repository(cx).and_then(|repo| { - repo.read(cx) - .branch - .as_ref() - .map(|branch| branch.name().to_string()) - }); - - let repository = if has_multiple_repositories { - None - } else { - project.read(cx).active_repository(cx) - }; - - // Fetch worktrees from the git backend (includes main + all linked) - let all_worktrees_request = repository - .clone() - .map(|repo| repo.update(cx, |repo, _| repo.worktrees())); - - let default_branch_request = repository - .clone() - .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false))); - - let initial_matches = vec![ThreadWorktreeEntry::CreateFromCurrentBranch]; - - let delegate = ThreadWorktreePickerDelegate { - matches: initial_matches, - all_worktrees: Vec::new(), - project_worktree_paths, - selected_index: 0, - project, - current_branch_name, - default_branch_name: None, - has_multiple_repositories, - }; - - let picker = cx.new(|cx| { - Picker::list(delegate, window, cx) - .list_measure_all() - .modal(false) - .max_height(Some(rems(20.).into())) - }); - - let mut subscriptions = Vec::new(); - - // Fetch worktrees and default branch asynchronously - { - let picker_handle = picker.downgrade(); - cx.spawn_in(window, async move |_this, cx| { - let all_worktrees: Vec<_> = match all_worktrees_request { - Some(req) => match req.await { - Ok(Ok(worktrees)) => { - worktrees.into_iter().filter(|wt| !wt.is_bare).collect() - } - Ok(Err(err)) => { - log::warn!("ThreadWorktreePicker: git worktree list failed: {err}"); - return anyhow::Ok(()); - } - Err(_) => { - log::warn!("ThreadWorktreePicker: worktree request was cancelled"); - return anyhow::Ok(()); - } - }, - None => Vec::new(), - }; - - let default_branch = match default_branch_request { - Some(req) => req.await.ok().and_then(Result::ok).flatten(), - None => None, - }; - - picker_handle.update_in(cx, |picker, window, cx| { - picker.delegate.all_worktrees = all_worktrees; - picker.delegate.default_branch_name = - default_branch.map(|branch| branch.to_string()); - picker.refresh(window, cx); - })?; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - // Subscribe to repository events to live-update the worktree list - if let Some(repo) = &repository { - let picker_entity = picker.downgrade(); - subscriptions.push(cx.subscribe_in( - repo, - window, - move |_this, repo, event: &RepositoryEvent, window, cx| { - if matches!(event, RepositoryEvent::GitWorktreeListChanged) { - let worktrees_request = repo.update(cx, |repo, _| repo.worktrees()); - let picker = picker_entity.clone(); - cx.spawn_in(window, async move |_, cx| { - let all_worktrees: Vec<_> = worktrees_request - .await?? - .into_iter() - .filter(|wt| !wt.is_bare) - .collect(); - picker.update_in(cx, |picker, window, cx| { - picker.delegate.all_worktrees = all_worktrees; - picker.refresh(window, cx); - })?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - }, - )); - } - - subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| { - cx.emit(DismissEvent); - })); - - Self { - focus_handle: picker.focus_handle(cx), - picker, - _subscriptions: subscriptions, - } - } -} - -impl Focusable for ThreadWorktreePicker { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl EventEmitter for ThreadWorktreePicker {} - -impl Render for ThreadWorktreePicker { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .w(rems(34.)) - .elevation_3(cx) - .child(self.picker.clone()) - .on_mouse_down_out(cx.listener(|_, _, _, cx| { - cx.emit(DismissEvent); - })) - } -} - -#[derive(Clone)] -enum ThreadWorktreeEntry { - CreateFromCurrentBranch, - CreateFromDefaultBranch { - default_branch_name: String, - }, - Separator, - Worktree { - worktree: GitWorktree, - positions: Vec, - }, - CreateNamed { - name: String, - /// When Some, create from this branch name (e.g. "main"). When None, create from current branch. - from_branch: Option, - disabled_reason: Option, - }, -} - -pub(crate) struct ThreadWorktreePickerDelegate { - matches: Vec, - all_worktrees: Vec, - project_worktree_paths: HashSet, - selected_index: usize, - project: Entity, - current_branch_name: Option, - default_branch_name: Option, - has_multiple_repositories: bool, -} - -impl ThreadWorktreePickerDelegate { - fn build_fixed_entries(&self) -> Vec { - let mut entries = Vec::new(); - - entries.push(ThreadWorktreeEntry::CreateFromCurrentBranch); - - if !self.has_multiple_repositories { - if let Some(ref default_branch) = self.default_branch_name { - let is_different = self - .current_branch_name - .as_ref() - .is_none_or(|current| current != default_branch); - if is_different { - entries.push(ThreadWorktreeEntry::CreateFromDefaultBranch { - default_branch_name: default_branch.clone(), - }); - } - } - } - - entries - } - - fn all_repo_worktrees(&self) -> &[GitWorktree] { - if self.has_multiple_repositories { - &[] - } else { - &self.all_worktrees - } - } - - fn sync_selected_index(&mut self, has_query: bool) { - if !has_query { - return; - } - - // When filtering, prefer selecting the first worktree match - if let Some(index) = self - .matches - .iter() - .position(|entry| matches!(entry, ThreadWorktreeEntry::Worktree { .. })) - { - self.selected_index = index; - } else if let Some(index) = self - .matches - .iter() - .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. })) - { - self.selected_index = index; - } else { - self.selected_index = 0; - } - } -} - -impl PickerDelegate for ThreadWorktreePickerDelegate { - type ListItem = AnyElement; - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select a worktree for this thread…".into() - } - - fn editor_position(&self) -> PickerEditorPosition { - PickerEditorPosition::Start - } - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { - !matches!(self.matches.get(ix), Some(ThreadWorktreeEntry::Separator)) - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let repo_worktrees = self.all_repo_worktrees().to_vec(); - - let normalized_query = query.replace(' ', "-"); - let main_worktree_path = self - .all_worktrees - .iter() - .find(|wt| wt.is_main) - .map(|wt| wt.path.clone()); - let has_named_worktree = self.all_worktrees.iter().any(|worktree| { - worktree.directory_name(main_worktree_path.as_deref()) == normalized_query - }); - let create_named_disabled_reason: Option = if self.has_multiple_repositories { - Some("Cannot create a named worktree in a project with multiple repositories".into()) - } else if has_named_worktree { - Some("A worktree with this name already exists".into()) - } else { - None - }; - - let show_default_branch_create = !self.has_multiple_repositories - && self.default_branch_name.as_ref().is_some_and(|default| { - self.current_branch_name - .as_ref() - .is_none_or(|current| current != default) - }); - let default_branch_name = self.default_branch_name.clone(); - - if query.is_empty() { - let mut matches = self.build_fixed_entries(); - - if !repo_worktrees.is_empty() { - let main_worktree_path = repo_worktrees - .iter() - .find(|wt| wt.is_main) - .map(|wt| wt.path.clone()); - - let mut sorted = repo_worktrees; - let project_paths = &self.project_worktree_paths; - - sorted.sort_by(|a, b| { - let a_is_current = project_paths.contains(&a.path); - let b_is_current = project_paths.contains(&b.path); - b_is_current.cmp(&a_is_current).then_with(|| { - a.directory_name(main_worktree_path.as_deref()) - .cmp(&b.directory_name(main_worktree_path.as_deref())) - }) - }); - - matches.push(ThreadWorktreeEntry::Separator); - for worktree in sorted { - matches.push(ThreadWorktreeEntry::Worktree { - worktree, - positions: Vec::new(), - }); - } - } - - self.matches = matches; - self.sync_selected_index(false); - return Task::ready(()); - } - - // When the user is typing, fuzzy-match worktree names using display_name - let main_worktree_path = repo_worktrees - .iter() - .find(|wt| wt.is_main) - .map(|wt| wt.path.clone()); - let candidates: Vec<_> = repo_worktrees - .iter() - .enumerate() - .map(|(ix, worktree)| { - StringMatchCandidate::new( - ix, - &worktree.directory_name(main_worktree_path.as_deref()), - ) - }) - .collect(); - - let executor = cx.background_executor().clone(); - - let task = cx.background_executor().spawn(async move { - fuzzy::match_strings( - &candidates, - &query, - true, - true, - 10000, - &Default::default(), - executor, - ) - .await - }); - - let repo_worktrees_clone = repo_worktrees; - cx.spawn_in(window, async move |picker, cx| { - let fuzzy_matches = task.await; - - picker - .update_in(cx, |picker, _window, cx| { - let mut new_matches: Vec = Vec::new(); - - for candidate in &fuzzy_matches { - new_matches.push(ThreadWorktreeEntry::Worktree { - worktree: repo_worktrees_clone[candidate.candidate_id].clone(), - positions: candidate.positions.clone(), - }); - } - - if !new_matches.is_empty() { - new_matches.push(ThreadWorktreeEntry::Separator); - } - new_matches.push(ThreadWorktreeEntry::CreateNamed { - name: normalized_query.clone(), - from_branch: None, - disabled_reason: create_named_disabled_reason.clone(), - }); - if show_default_branch_create { - if let Some(ref default_branch) = default_branch_name { - new_matches.push(ThreadWorktreeEntry::CreateNamed { - name: normalized_query.clone(), - from_branch: Some(default_branch.clone()), - disabled_reason: create_named_disabled_reason.clone(), - }); - } - } - - picker.delegate.matches = new_matches; - picker.delegate.sync_selected_index(true); - - cx.notify(); - }) - .log_err(); - }) - } - - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(self.selected_index) else { - return; - }; - - match entry { - ThreadWorktreeEntry::Separator => return, - - ThreadWorktreeEntry::CreateFromCurrentBranch => { - window.dispatch_action( - Box::new(CreateWorktree { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::CurrentBranch, - }), - cx, - ); - } - - ThreadWorktreeEntry::CreateFromDefaultBranch { - default_branch_name, - } => { - window.dispatch_action( - Box::new(CreateWorktree { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::ExistingBranch { - name: default_branch_name.clone(), - }, - }), - cx, - ); - } - - ThreadWorktreeEntry::Worktree { worktree, .. } => { - let is_current = self.project_worktree_paths.contains(&worktree.path); - - if is_current { - // Already in this worktree — just dismiss - } else { - let main_worktree_path = self - .all_worktrees - .iter() - .find(|wt| wt.is_main) - .map(|wt| wt.path.as_path()); - window.dispatch_action( - Box::new(SwitchWorktree { - path: worktree.path.clone(), - display_name: worktree.directory_name(main_worktree_path), - }), - cx, - ); - } - } - - ThreadWorktreeEntry::CreateNamed { - name, - from_branch, - disabled_reason: None, - } => { - let branch_target = match from_branch { - Some(branch) => NewWorktreeBranchTarget::ExistingBranch { - name: branch.clone(), - }, - None => NewWorktreeBranchTarget::CurrentBranch, - }; - window.dispatch_action( - Box::new(CreateWorktree { - worktree_name: Some(name.clone()), - branch_target, - }), - cx, - ); - } - - ThreadWorktreeEntry::CreateNamed { - disabled_reason: Some(_), - .. - } => { - return; - } - } - - cx.emit(DismissEvent); - } - - fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let entry = self.matches.get(ix)?; - let project = self.project.read(cx); - let is_create_disabled = project.repositories(cx).is_empty() || project.is_via_collab(); - - let no_git_reason: SharedString = "Requires a Git repository in the project".into(); - - let create_new_list_item = |id: SharedString, - label: SharedString, - disabled_tooltip: Option, - selected: bool| { - let is_disabled = disabled_tooltip.is_some(); - ListItem::new(id) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_2p5() - .child( - Icon::new(IconName::Plus) - .map(|this| { - if is_disabled { - this.color(Color::Disabled) - } else { - this.color(Color::Muted) - } - }) - .size(IconSize::Small), - ) - .child( - Label::new(label).when(is_disabled, |this| this.color(Color::Disabled)), - ), - ) - .when_some(disabled_tooltip, |this, reason| { - this.tooltip(Tooltip::text(reason)) - }) - .into_any_element() - }; - - match entry { - ThreadWorktreeEntry::Separator => Some( - div() - .py(DynamicSpacing::Base04.rems(cx)) - .child(Divider::horizontal()) - .into_any_element(), - ), - - ThreadWorktreeEntry::CreateFromCurrentBranch => { - let branch_label = if self.has_multiple_repositories { - "current branches".to_string() - } else { - self.current_branch_name - .clone() - .unwrap_or_else(|| "HEAD".to_string()) - }; - - let label = format!("Create new worktree based on {branch_label}"); - - let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone()); - - let item = create_new_list_item( - "create-from-current".to_string().into(), - label.into(), - disabled_tooltip, - selected, - ); - - Some(item.into_any_element()) - } - - ThreadWorktreeEntry::CreateFromDefaultBranch { - default_branch_name, - } => { - let label = format!("Create new worktree based on {default_branch_name}"); - - let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone()); - - let item = create_new_list_item( - "create-from-main".to_string().into(), - label.into(), - disabled_tooltip, - selected, - ); - - Some(item.into_any_element()) - } - - ThreadWorktreeEntry::Worktree { - worktree, - positions, - } => { - let main_worktree_path = self - .all_worktrees - .iter() - .find(|wt| wt.is_main) - .map(|wt| wt.path.as_path()); - let display_name = worktree.directory_name(main_worktree_path); - let first_line = display_name.lines().next().unwrap_or(&display_name); - let positions: Vec<_> = positions - .iter() - .copied() - .filter(|&pos| pos < first_line.len()) - .collect(); - let path = worktree.path.compact().to_string_lossy().to_string(); - let sha = worktree.sha.chars().take(7).collect::(); - - let is_current = self.project_worktree_paths.contains(&worktree.path); - - let entry_icon = if is_current { - IconName::Check - } else { - IconName::GitWorktree - }; - - Some( - ListItem::new(SharedString::from(format!("worktree-{ix}"))) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_2p5() - .child( - Icon::new(entry_icon) - .color(if is_current { - Color::Accent - } else { - Color::Muted - }) - .size(IconSize::Small), - ) - .child( - v_flex() - .w_full() - .min_w_0() - .child( - HighlightedLabel::new(first_line.to_owned(), positions) - .truncate(), - ) - .child( - h_flex() - .w_full() - .min_w_0() - .gap_1p5() - .when_some( - worktree.branch_name().map(|b| b.to_string()), - |this, branch| { - this.child( - Label::new(branch) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("\u{2022}") - .alpha(0.5) - .color(Color::Muted) - .size(LabelSize::Small), - ) - }, - ) - .when(!sha.is_empty(), |this| { - this.child( - Label::new(sha) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("\u{2022}") - .alpha(0.5) - .color(Color::Muted) - .size(LabelSize::Small), - ) - }) - .child( - Label::new(path) - .truncate_start() - .color(Color::Muted) - .size(LabelSize::Small) - .flex_1(), - ), - ), - ), - ) - .into_any_element(), - ) - } - - ThreadWorktreeEntry::CreateNamed { - name, - from_branch, - disabled_reason, - } => { - let branch_label = from_branch - .as_deref() - .unwrap_or(self.current_branch_name.as_deref().unwrap_or("HEAD")); - let label = format!("Create \"{name}\" based on {branch_label}"); - let element_id = match from_branch { - Some(branch) => format!("create-named-from-{branch}"), - None => "create-named-from-current".to_string(), - }; - - let item = create_new_list_item( - element_id.into(), - label.into(), - disabled_reason.clone().map(SharedString::from), - selected, - ); - - Some(item.into_any_element()) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use fs::FakeFs; - use gpui::TestAppContext; - use project::Project; - use settings::SettingsStore; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme_settings::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - release_channel::init("0.0.0".parse().unwrap(), cx); - crate::agent_panel::init(cx); - }); - } - - fn make_worktree(path: &str, branch: &str, is_main: bool) -> GitWorktree { - GitWorktree { - path: PathBuf::from(path), - ref_name: Some(format!("refs/heads/{branch}").into()), - sha: "abc1234".into(), - is_main, - is_bare: false, - } - } - - fn build_delegate( - project: Entity, - all_worktrees: Vec, - project_worktree_paths: HashSet, - current_branch_name: Option, - default_branch_name: Option, - has_multiple_repositories: bool, - ) -> ThreadWorktreePickerDelegate { - ThreadWorktreePickerDelegate { - matches: vec![ThreadWorktreeEntry::CreateFromCurrentBranch], - all_worktrees, - project_worktree_paths, - selected_index: 0, - project, - current_branch_name, - default_branch_name, - has_multiple_repositories, - } - } - - fn entry_names(delegate: &ThreadWorktreePickerDelegate) -> Vec { - delegate - .matches - .iter() - .map(|entry| match entry { - ThreadWorktreeEntry::CreateFromCurrentBranch => { - "CreateFromCurrentBranch".to_string() - } - ThreadWorktreeEntry::CreateFromDefaultBranch { - default_branch_name, - } => format!("CreateFromDefaultBranch({default_branch_name})"), - ThreadWorktreeEntry::Separator => "---".to_string(), - ThreadWorktreeEntry::Worktree { worktree, .. } => { - format!("Worktree({})", worktree.path.display()) - } - ThreadWorktreeEntry::CreateNamed { - name, - from_branch, - disabled_reason, - } => { - let branch = from_branch - .as_deref() - .map(|b| format!("from {b}")) - .unwrap_or_else(|| "from current".to_string()); - if disabled_reason.is_some() { - format!("CreateNamed({name}, {branch}, disabled)") - } else { - format!("CreateNamed({name}, {branch})") - } - } - }) - .collect() - } - - type PickerWindow = gpui::WindowHandle>; - - async fn make_picker( - cx: &mut TestAppContext, - all_worktrees: Vec, - project_worktree_paths: HashSet, - current_branch_name: Option, - default_branch_name: Option, - has_multiple_repositories: bool, - ) -> PickerWindow { - let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs, [], cx).await; - - cx.add_window(|window, cx| { - let delegate = build_delegate( - project, - all_worktrees, - project_worktree_paths, - current_branch_name, - default_branch_name, - has_multiple_repositories, - ); - Picker::list(delegate, window, cx) - .list_measure_all() - .modal(false) - }) - } - - #[gpui::test] - async fn test_empty_query_entries(cx: &mut TestAppContext) { - init_test(cx); - - // When on `main` with default branch also `main`, only CreateFromCurrentBranch - // is shown as a fixed entry. Worktrees are listed with the current one first. - let worktrees = vec![ - make_worktree("/repo", "main", true), - make_worktree("/repo-feature", "feature", false), - make_worktree("/repo-bugfix", "bugfix", false), - ]; - let project_paths: HashSet = [PathBuf::from("/repo")].into_iter().collect(); - - let picker = make_picker( - cx, - worktrees, - project_paths, - Some("main".into()), - Some("main".into()), - false, - ) - .await; - - picker - .update(cx, |picker, window, cx| picker.refresh(window, cx)) - .unwrap(); - cx.run_until_parked(); - - let names = picker - .read_with(cx, |picker, _| entry_names(&picker.delegate)) - .unwrap(); - - assert_eq!( - names, - vec![ - "CreateFromCurrentBranch", - "---", - "Worktree(/repo)", - "Worktree(/repo-bugfix)", - "Worktree(/repo-feature)", - ] - ); - - // When current branch differs from default, CreateFromDefaultBranch appears. - picker - .update(cx, |picker, _window, cx| { - picker.delegate.current_branch_name = Some("feature".into()); - picker.delegate.default_branch_name = Some("main".into()); - cx.notify(); - }) - .unwrap(); - picker - .update(cx, |picker, window, cx| picker.refresh(window, cx)) - .unwrap(); - cx.run_until_parked(); - - let names = picker - .read_with(cx, |picker, _| entry_names(&picker.delegate)) - .unwrap(); - - assert!(names.contains(&"CreateFromDefaultBranch(main)".to_string())); - } - - #[gpui::test] - async fn test_query_filtering_and_create_entries(cx: &mut TestAppContext) { - init_test(cx); - - let picker = make_picker( - cx, - vec![ - make_worktree("/repo", "main", true), - make_worktree("/repo-feature", "feature", false), - make_worktree("/repo-bugfix", "bugfix", false), - make_worktree("/my-worktree", "experiment", false), - ], - HashSet::default(), - Some("dev".into()), - Some("main".into()), - false, - ) - .await; - - // Partial match filters to matching worktrees and offers to create - // from both current branch and default branch. - picker - .update(cx, |picker, window, cx| { - picker.set_query("feat", window, cx) - }) - .unwrap(); - cx.run_until_parked(); - - let names = picker - .read_with(cx, |picker, _| entry_names(&picker.delegate)) - .unwrap(); - assert!(names.contains(&"Worktree(/repo-feature)".to_string())); - assert!( - names.contains(&"CreateNamed(feat, from current)".to_string()), - "should offer to create from current branch, got: {names:?}" - ); - assert!( - names.contains(&"CreateNamed(feat, from main)".to_string()), - "should offer to create from default branch, got: {names:?}" - ); - assert!(!names.contains(&"Worktree(/repo-bugfix)".to_string())); - - // Exact match: both create entries appear but are disabled. - picker - .update(cx, |picker, window, cx| { - picker.set_query("repo-feature", window, cx) - }) - .unwrap(); - cx.run_until_parked(); - - let names = picker - .read_with(cx, |picker, _| entry_names(&picker.delegate)) - .unwrap(); - assert!( - names.contains(&"CreateNamed(repo-feature, from current, disabled)".to_string()), - "exact name match should show disabled create entries, got: {names:?}" - ); - - // Spaces are normalized to hyphens: "my worktree" matches "my-worktree". - picker - .update(cx, |picker, window, cx| { - picker.set_query("my worktree", window, cx) - }) - .unwrap(); - cx.run_until_parked(); - - let names = picker - .read_with(cx, |picker, _| entry_names(&picker.delegate)) - .unwrap(); - assert!( - names.contains(&"CreateNamed(my-worktree, from current, disabled)".to_string()), - "spaces should normalize to hyphens and detect existing worktree, got: {names:?}" - ); - } - - #[gpui::test] - async fn test_multi_repo_hides_worktrees_and_disables_create_named(cx: &mut TestAppContext) { - init_test(cx); - - let picker = make_picker( - cx, - vec![ - make_worktree("/repo", "main", true), - make_worktree("/repo-feature", "feature", false), - ], - HashSet::default(), - Some("main".into()), - Some("main".into()), - true, - ) - .await; - - picker - .update(cx, |picker, window, cx| picker.refresh(window, cx)) - .unwrap(); - cx.run_until_parked(); - - let names = picker - .read_with(cx, |picker, _| entry_names(&picker.delegate)) - .unwrap(); - assert_eq!(names, vec!["CreateFromCurrentBranch"]); - - picker - .update(cx, |picker, window, cx| { - picker.set_query("new-thing", window, cx) - }) - .unwrap(); - cx.run_until_parked(); - - let names = picker - .read_with(cx, |picker, _| entry_names(&picker.delegate)) - .unwrap(); - assert!( - names.contains(&"CreateNamed(new-thing, from current, disabled)".to_string()), - "multi-repo should disable create named, got: {names:?}" - ); - } -} diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 6547187547c839..72b03692761742 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -10,7 +10,7 @@ use crate::thread_metadata_store::{ use crate::{Agent, ArchiveSelectedThread, DEFAULT_THREAD_TITLE, RemoveSelectedThread}; use agent::ThreadStore; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use collections::HashMap; @@ -31,8 +31,9 @@ use project::{AgentId, AgentServerStore}; use settings::Settings as _; use theme::ActiveTheme; use ui::{ - AgentThreadStatus, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tab, - ThreadItem, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, + AgentThreadStatus, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, ScrollAxes, + Scrollbars, Tab, ThreadItem, Tooltip, WithScrollbar, prelude::*, + utils::platform_title_bar_height, }; use ui_input::ErasedEditor; use util::ResultExt; @@ -320,26 +321,20 @@ impl ThreadsArchiveView { let preserve = self.preserve_selection_on_next_update; self.preserve_selection_on_next_update = false; - let saved_scroll = if preserve { - Some(self.list_state.logical_scroll_top()) - } else { - None - }; + let saved_scroll = self.list_state.logical_scroll_top(); self.list_state.reset(items.len()); self.items = items; - if !preserve { - self.hovered_index = None; - } else if let Some(ix) = self.hovered_index { + if let Some(ix) = self.hovered_index { if ix >= self.items.len() || !self.is_selectable_item(ix) { self.hovered_index = None; } } - if let Some(scroll_top) = saved_scroll { - self.list_state.scroll_to(scroll_top); + self.list_state.scroll_to(saved_scroll); + if preserve { if let Some(ix) = self.selection { let next = self.find_next_selectable(ix).or_else(|| { ix.checked_sub(1) @@ -653,12 +648,15 @@ impl ThreadsArchiveView { .focused(is_focused) .hovered(is_hovered) .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_index = Some(ix); - } else if this.hovered_index == Some(ix) { - this.hovered_index = None; + let previously_hovered = this.hovered_index; + this.hovered_index = if *is_hovered { + Some(ix) + } else { + previously_hovered.filter(|&i| i != ix) + }; + if this.hovered_index != previously_hovered { + cx.notify(); } - cx.notify(); })); if is_restoring { @@ -917,6 +915,7 @@ impl ThreadsArchiveView { ) .child( h_flex() + .gap_1() .child( IconButton::new("thread-import", IconName::Download) .icon_size(IconSize::Small) @@ -1014,7 +1013,13 @@ impl Render for ThreadsArchiveView { .flex_1() .size_full(), ) - .vertical_scrollbar_for(&self.list_state, window, cx) + .custom_scrollbars( + Scrollbars::new(ScrollAxes::Vertical) + .tracked_scroll_handle(&self.list_state) + .width_sm(), + window, + cx, + ) .into_any_element() }; @@ -1082,7 +1087,7 @@ impl ProjectPickerModal { let db = WorkspaceDb::global(cx); cx.spawn_in(window, async move |this, cx| { let workspaces = db - .recent_workspaces_on_disk(fs.as_ref()) + .recent_project_workspaces(fs.as_ref()) .await .log_err() .unwrap_or_default(); diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 9fa245516f950b..e3059ab87247dd 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -1,7 +1,7 @@ use std::{ops::RangeInclusive, path::PathBuf, time::Duration}; use acp_thread::MentionUri; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use gpui::{ Animation, AnimationExt, AnyView, Context, IntoElement, WeakEntity, Window, pulsating_between, diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml index 458f9bfae7da47..3001b5801c067e 100644 --- a/crates/anthropic/Cargo.toml +++ b/crates/anthropic/Cargo.toml @@ -28,6 +28,3 @@ serde.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true -tiktoken-rs.workspace = true - - diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 488802d7db3fe1..ac94daba71194e 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -1000,71 +1000,6 @@ pub fn parse_prompt_too_long(message: &str) -> Option { .ok() } -/// Request body for the token counting API. -/// Similar to `Request` but without `max_tokens` since it's not needed for counting. -#[derive(Debug, Serialize)] -pub struct CountTokensRequest { - pub model: String, - pub messages: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub system: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tools: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub thinking: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, -} - -/// Response from the token counting API. -#[derive(Debug, Deserialize)] -pub struct CountTokensResponse { - pub input_tokens: u64, -} - -/// Count the number of tokens in a message without creating it. -pub async fn count_tokens( - client: &dyn HttpClient, - api_url: &str, - api_key: &str, - request: CountTokensRequest, -) -> Result { - let uri = format!("{api_url}/v1/messages/count_tokens"); - - let request_builder = HttpRequest::builder() - .method(Method::POST) - .uri(uri) - .header("Anthropic-Version", "2023-06-01") - .header("X-Api-Key", api_key.trim()) - .header("Content-Type", "application/json"); - - let serialized_request = - serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; - let http_request = request_builder - .body(AsyncBody::from(serialized_request)) - .map_err(AnthropicError::BuildRequestBody)?; - - let mut response = client - .send(http_request) - .await - .map_err(AnthropicError::HttpSend)?; - - let rate_limits = RateLimitInfo::from_headers(response.headers()); - - if response.status().is_success() { - let mut body = String::new(); - response - .body_mut() - .read_to_string(&mut body) - .await - .map_err(AnthropicError::ReadResponse)?; - - serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) - } else { - Err(handle_error_response(response, rate_limits).await) - } -} - // -- Conversions from/to `language_model_core` types -- impl From for Speed { diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index a6175a4f7c24b3..16bb5012e583f4 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -11,9 +11,9 @@ use std::pin::Pin; use std::str::FromStr; use crate::{ - AnthropicError, AnthropicModelMode, CacheControl, CacheControlType, ContentDelta, - CountTokensRequest, Event, ImageSource, Message, RequestContent, ResponseContent, - StringOrContents, Thinking, Tool, ToolChoice, ToolResultContent, ToolResultPart, Usage, + AnthropicError, AnthropicModelMode, CacheControl, CacheControlType, ContentDelta, Event, + ImageSource, Message, RequestContent, ResponseContent, StringOrContents, Thinking, Tool, + ToolChoice, ToolResultContent, ToolResultPart, Usage, }; fn to_anthropic_content(content: MessageContent) -> Option { @@ -92,152 +92,6 @@ fn to_anthropic_content(content: MessageContent) -> Option { } } -/// Convert a LanguageModelRequest to an Anthropic CountTokensRequest. -pub fn into_anthropic_count_tokens_request( - request: LanguageModelRequest, - model: String, - mode: AnthropicModelMode, -) -> CountTokensRequest { - let mut new_messages: Vec = Vec::new(); - let mut system_message = String::new(); - - for message in request.messages { - if message.contents_empty() { - continue; - } - - match message.role { - Role::User | Role::Assistant => { - let anthropic_message_content: Vec = message - .content - .into_iter() - .filter_map(to_anthropic_content) - .collect(); - let anthropic_role = match message.role { - Role::User => crate::Role::User, - Role::Assistant => crate::Role::Assistant, - Role::System => unreachable!("System role should never occur here"), - }; - if anthropic_message_content.is_empty() { - continue; - } - - if let Some(last_message) = new_messages.last_mut() - && last_message.role == anthropic_role - { - last_message.content.extend(anthropic_message_content); - continue; - } - - new_messages.push(Message { - role: anthropic_role, - content: anthropic_message_content, - }); - } - Role::System => { - if !system_message.is_empty() { - system_message.push_str("\n\n"); - } - system_message.push_str(&message.string_contents()); - } - } - } - - CountTokensRequest { - model, - messages: new_messages, - system: if system_message.is_empty() { - None - } else { - Some(StringOrContents::String(system_message)) - }, - thinking: if request.thinking_allowed { - match mode { - AnthropicModelMode::Thinking { budget_tokens } => { - Some(Thinking::Enabled { budget_tokens }) - } - AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive), - AnthropicModelMode::Default => None, - } - } else { - None - }, - tools: 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, - }) - .collect(), - tool_choice: request.tool_choice.map(|choice| match choice { - LanguageModelToolChoice::Auto => ToolChoice::Auto, - LanguageModelToolChoice::Any => ToolChoice::Any, - LanguageModelToolChoice::None => ToolChoice::None, - }), - } -} - -/// Estimate tokens using tiktoken. Used as a fallback when the API is unavailable, -/// or by providers (like Zed Cloud) that don't have direct Anthropic API access. -pub fn count_anthropic_tokens_with_tiktoken(request: LanguageModelRequest) -> Result { - let messages = request.messages; - let mut tokens_from_images = 0; - let mut string_messages = Vec::with_capacity(messages.len()); - - for message in messages { - let mut string_contents = String::new(); - - for content in message.content { - match content { - MessageContent::Text(text) => { - string_contents.push_str(&text); - } - MessageContent::Thinking { .. } => { - // Thinking blocks are not included in the input token count. - } - MessageContent::RedactedThinking(_) => { - // Thinking blocks are not included in the input token count. - } - MessageContent::Image(image) => { - tokens_from_images += image.estimate_tokens(); - } - MessageContent::ToolUse(_tool_use) => { - // TODO: Estimate token usage from tool uses. - } - MessageContent::ToolResult(tool_result) => match &tool_result.content { - LanguageModelToolResultContent::Text(text) => { - string_contents.push_str(text); - } - LanguageModelToolResultContent::Image(image) => { - tokens_from_images += image.estimate_tokens(); - } - }, - } - } - - if !string_contents.is_empty() { - string_messages.push(tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(string_contents), - name: None, - function_call: None, - }); - } - } - - // Tiktoken doesn't yet support these models, so we manually use the - // same tokenizer as GPT-4. - tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages) - .map(|tokens| (tokens + tokens_from_images) as u64) -} - pub fn into_anthropic( request: LanguageModelRequest, model: String, diff --git a/crates/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs index b4bad6d2f350c3..39cb4cd9e3cb90 100644 --- a/crates/call/src/call_impl/mod.rs +++ b/crates/call/src/call_impl/mod.rs @@ -40,7 +40,7 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { &cx.entity(), window, move |multi_workspace, _, event: &MultiWorkspaceEvent, window, cx| { - if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged) + if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged { .. }) && window.is_window_active() { return; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 9c6ec08219437c..459a8266c7fc24 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -642,14 +642,6 @@ fn main() -> Result<()> { } } - // When only diff paths are provided (no regular paths), add the current - // working directory so the workspace opens with the right context. - if paths.is_empty() && urls.is_empty() && !diff_paths.is_empty() { - if let Ok(cwd) = env::current_dir() { - paths.push(cwd.to_string_lossy().into_owned()); - } - } - anyhow::ensure!( args.dev_server_token.is_none(), "Dev servers were removed in v0.157.x please upgrade to SSH remoting: https://zed.dev/docs/remote-development" diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index d5b3af394ea96b..8d1dbeb4394cbb 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -268,18 +268,6 @@ pub struct WebSearchResult { pub text: String, } -#[derive(Serialize, Deserialize)] -pub struct CountTokensBody { - pub provider: LanguageModelProvider, - pub model: String, - pub provider_request: serde_json::Value, -} - -#[derive(Serialize, Deserialize)] -pub struct CountTokensResponse { - pub tokens: usize, -} - #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] pub struct LanguageModelId(pub Arc); diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 8e783f42a86f38..7262a5fa40ff7b 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -261,6 +261,17 @@ impl Database { .chain(Some(channel_id)) .collect::>(); + let channel_has_active_participants = room_participant::Entity::find() + .inner_join(room::Entity) + .filter(room::Column::ChannelId.is_in(channels_to_remove.iter().copied())) + .count(&*tx) + .await? + > 0; + + if channel_has_active_participants { + Err(anyhow!("can't delete channel while a call is in progress"))?; + } + channel::Entity::delete_many() .filter(channel::Column::Id.is_in(channels_to_remove.iter().copied())) .exec(&*tx) diff --git a/crates/collab/tests/integration/db_tests/channel_tests.rs b/crates/collab/tests/integration/db_tests/channel_tests.rs index fc3f8770f839b2..c78fe0f4ef71f7 100644 --- a/crates/collab/tests/integration/db_tests/channel_tests.rs +++ b/crates/collab/tests/integration/db_tests/channel_tests.rs @@ -976,3 +976,68 @@ fn assert_channel_tree_order(actual: Vec, expected: &[(ChannelId, &[Cha .collect::>(); pretty_assertions::assert_eq!(actual, expected, "wrong channel ids and parent paths"); } + +test_both_dbs!( + test_delete_channel_with_active_call, + test_delete_channel_with_active_call_postgres, + test_delete_channel_with_active_call_sqlite +); + +async fn test_delete_channel_with_active_call(db: &Arc) { + let owner_id = db.create_server("test").await.unwrap().0 as u32; + + let user_1 = new_test_user(db, "user1@example.com").await; + let user_2 = new_test_user(db, "user2@example.com").await; + + let parent_channel_id = db + .create_root_channel("parent_channel", user_1) + .await + .unwrap(); + let nested_channel_id = db + .create_sub_channel("nested_channel", parent_channel_id, user_1) + .await + .unwrap(); + + db.invite_channel_member(parent_channel_id, user_2, user_1, ChannelRole::Member) + .await + .unwrap(); + + db.respond_to_channel_invite(parent_channel_id, user_2, true) + .await + .unwrap(); + + let connection_1 = ConnectionId { owner_id, id: 1 }; + let connection_2 = ConnectionId { owner_id, id: 2 }; + + db.join_channel(parent_channel_id, user_1, connection_1) + .await + .unwrap(); + + db.join_channel(nested_channel_id, user_2, connection_2) + .await + .unwrap(); + + // Delete fails - participants in both parent and nested calls + let err = db + .delete_channel(parent_channel_id, user_1) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("call is in progress"), "{err}"); + + // Delete fails - participants in nested calls + db.leave_room(connection_2).await.unwrap(); + let err = db + .delete_channel(parent_channel_id, user_1) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("call is in progress"), "{err}"); + + // Delete succeeds - no participants in calls + db.leave_room(connection_1).await.unwrap(); + db.delete_channel(parent_channel_id, user_1).await.unwrap(); + + assert!(db.get_channel(parent_channel_id, user_1).await.is_err()); + assert!(db.get_channel(parent_channel_id, user_2).await.is_err()); +} diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 2ce3abf48f12b2..4eca02280ebe15 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -1203,6 +1203,13 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte .await; let active_call_a = cx_a.read(ActiveCall::global); cx_b.update(editor::init); + cx_b.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.code_lens = Some(settings::CodeLens::Menu); + }); + }); + }); let command_name = "test_command"; let capabilities = lsp::ServerCapabilities { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index a80d5682eb5652..908d11cd654f9b 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -3693,7 +3693,7 @@ impl Render for CollabPanel { deferred( anchored() .position(*position) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 3b3d974f3e50a9..1781a8e93e0476 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -433,7 +433,7 @@ impl PickerDelegate for ChannelModalDelegate { Some( deferred( anchored() - .anchor(gpui::Corner::TopRight) + .anchor(gpui::Anchor::TopRight) .child(menu.clone()), ) .with_priority(1), diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index df9da6f67e5c2c..1b2af52662cf98 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -21,7 +21,7 @@ client.workspace = true collections.workspace = true command_palette_hooks.workspace = true db.workspace = true -fuzzy.workspace = true +fuzzy_nucleo.workspace = true gpui.workspace = true menu.workspace = true log.workspace = true diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 4a80740c3765f2..68d04537a0261c 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -13,7 +13,7 @@ use command_palette_hooks::{ GlobalCommandPaletteInterceptor, }; -use fuzzy::{StringMatch, StringMatchCandidate}; +use fuzzy_nucleo::{StringMatch, StringMatchCandidate}; use gpui::{ Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ParentElement, Render, Styled, Task, WeakEntity, Window, @@ -33,11 +33,7 @@ pub fn init(cx: &mut App) { cx.observe_new(CommandPalette::register).detach(); } -impl ModalView for CommandPalette { - fn is_command_palette(&self) -> bool { - true - } -} +impl ModalView for CommandPalette {} pub struct CommandPalette { picker: Entity>, @@ -326,7 +322,7 @@ impl CommandPaletteDelegate { }); new_matches.push(StringMatch { candidate_id: commands.len() - 1, - string, + string: string.into(), positions, score: 0.0, }) @@ -358,6 +354,9 @@ impl CommandPaletteDelegate { } fn selected_command(&self) -> Option<&Command> { + if self.matches.is_empty() { + return None; + } let action_ix = self .matches .get(self.selected_ix) @@ -442,7 +441,7 @@ impl PickerDelegate for CommandPaletteDelegate { ) -> gpui::Task<()> { let settings = WorkspaceSettings::get_global(cx); if let Some(alias) = settings.command_aliases.get(&query) { - query = alias.to_string(); + query = alias.as_ref().to_owned(); } let workspace = self.workspace.clone(); @@ -474,11 +473,11 @@ impl PickerDelegate for CommandPaletteDelegate { .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name)) .collect::>(); - let matches = fuzzy::match_strings( + let matches = fuzzy_nucleo::match_strings_async( &candidates, &query, - true, - true, + fuzzy_nucleo::Case::Smart, + fuzzy_nucleo::LengthPenalty::On, 10000, &Default::default(), executor, @@ -560,6 +559,9 @@ impl PickerDelegate for CommandPaletteDelegate { fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { if secondary { + if self.matches.is_empty() { + return; + } let Some(selected_command) = self.selected_command() else { return; }; @@ -863,6 +865,33 @@ mod tests { assert!(palette.delegate.matches.is_empty()) }); } + + #[gpui::test] + async fn test_selected_command_none_when_no_matches(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + cx.simulate_keystrokes("cmd-shift-p"); + let picker = workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .unwrap() + .read(cx) + .picker + .clone() + }); + + cx.simulate_input("definitely-no-command-should-match-this"); + cx.background_executor.run_until_parked(); + + picker.read_with(cx, |picker, _cx| { + assert!(picker.delegate.matches.is_empty()); + assert!(picker.delegate.selected_command().is_none()); + }); + } #[gpui::test] async fn test_normalized_matches(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index 0a9c94a54d7019..dea98bd69e0c28 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -38,7 +38,6 @@ tempfile.workspace = true tiny_http.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true -terminal.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index c3af1aa8745a07..0b5525a3a5af44 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -8,11 +8,10 @@ use futures::{ AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _, }; use gpui::AsyncApp; -use settings::Settings as _; use smol::channel; use smol::process::Child; -use terminal::terminal_settings::TerminalSettings; use util::TryFutureExt as _; +use util::shell::Shell; use util::shell_builder::ShellBuilder; use crate::client::ModelContextServerBinary; @@ -31,8 +30,7 @@ impl StdioTransport { working_directory: &Option, cx: &AsyncApp, ) -> Result { - let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone()); - let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive(); let mut command = builder.build_smol_command(Some(binary.executable.display().to_string()), &binary.args); diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index 850190701e526f..fb89c2e0853f73 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -289,13 +289,8 @@ impl Model { } pub fn supports_response(&self) -> bool { - self.supported_endpoints.len() > 0 - && !self - .supported_endpoints - .contains(&ModelSupportedEndpoint::ChatCompletions) - && self - .supported_endpoints - .contains(&ModelSupportedEndpoint::Responses) + self.supported_endpoints + .contains(&ModelSupportedEndpoint::Responses) } pub fn supports_messages(&self) -> bool { @@ -315,6 +310,7 @@ impl Model { self.supports_thinking() || self.supports_adaptive_thinking() || self.max_thinking_budget().is_some() + || !self.reasoning_effort_levels().is_empty() } pub fn max_thinking_budget(&self) -> Option { @@ -1731,7 +1727,7 @@ mod tests { assert!(!model_with_chat_completions.supports_response()); // Both endpoints (has /chat/completions) -> supports_response = false - assert!(!model_with_both.supports_response()); + assert!(model_with_both.supports_response()); // Only /v1/messages endpoint -> supports_response = false (doesn't have /responses) assert!(!model_with_messages.supports_response()); diff --git a/crates/csv_preview/src/renderer/table_cell.rs b/crates/csv_preview/src/renderer/table_cell.rs index 733488110fbcdb..cc9690b4233c2f 100644 --- a/crates/csv_preview/src/renderer/table_cell.rs +++ b/crates/csv_preview/src/renderer/table_cell.rs @@ -39,13 +39,12 @@ fn create_table_cell( cx: &Context<'_, CsvPreviewView>, ) -> gpui::Stateful
{ div() - .id(ElementId::NamedInteger( + .id(ElementId::Name( format!( "csv-display-cell-{}-{}", *display_cell_id.row, *display_cell_id.col ) .into(), - 0, )) .cursor_pointer() .flex() diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index c364cdd244752a..749a6cd7888301 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -518,7 +518,7 @@ impl Render for DapLogToolbarItemView { .and_then(|session_id| menu_rows.iter().find(|row| row.session_id == session_id)); let dap_menu: PopoverMenu<_> = PopoverMenu::new("DapLogView") - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .trigger(Button::new( "debug_client_menu_header", current_client @@ -1028,9 +1028,14 @@ impl SearchableItem for DapLogView { }) } - fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { + fn query_suggestion( + &mut self, + ignore_settings: bool, + window: &mut Window, + cx: &mut Context, + ) -> String { self.editor - .update(cx, |e, cx| e.query_suggestion(window, cx)) + .update(cx, |e, cx| e.query_suggestion(ignore_settings, window, cx)) } fn activate_match( diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index d727a112e31950..f92b87a773c82d 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -17,7 +17,7 @@ use dap::{client::SessionId, debugger_settings::DebuggerSettings}; use editor::{Editor, MultiBufferOffset, ToPoint}; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag}; use gpui::{ - Action, App, AsyncWindowContext, ClipboardItem, Context, Corner, DismissEvent, Entity, + Action, Anchor, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task, WeakEntity, anchored, deferred, }; @@ -1434,7 +1434,7 @@ impl DebugPanel { )) } }) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) } } @@ -1792,7 +1792,7 @@ impl Render for DebugPanel { deferred( anchored() .position(*position) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) diff --git a/crates/debugger_ui/src/dropdown_menus.rs b/crates/debugger_ui/src/dropdown_menus.rs index e0c3628f4fc0a9..0e07cb8841b08c 100644 --- a/crates/debugger_ui/src/dropdown_menus.rs +++ b/crates/debugger_ui/src/dropdown_menus.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use collections::HashMap; -use gpui::{Corner, Entity, WeakEntity}; +use gpui::{Anchor, Entity, WeakEntity}; use project::debugger::session::{ThreadId, ThreadStatus}; use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*}; use util::{maybe, truncate_and_trailoff}; @@ -211,7 +211,7 @@ impl DebugPanel { this }), ) - .attach(Corner::BottomLeft) + .attach(Anchor::BottomLeft) .style(DropdownStyle::Ghost) .handle(self.session_picker_menu_handle.clone()); @@ -323,7 +323,7 @@ impl DebugPanel { this }), ) - .attach(Corner::BottomLeft) + .attach(Anchor::BottomLeft) .disabled(session_terminated) .style(DropdownStyle::Ghost) .handle(self.thread_picker_menu_handle.clone()), diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 1ea974c4fe2ace..f0d243995f6991 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -523,7 +523,7 @@ impl NewProcessModal { ) .style(ui::DropdownStyle::Outlined) .tab_index(0) - .attach(gpui::Corner::BottomLeft) + .attach(gpui::Anchor::BottomLeft) .offset(gpui::Point { x: px(0.0), y: px(2.0), diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 836f76a73fe69a..c273778ec38527 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -1145,6 +1145,9 @@ impl RunningState { args, ..task.resolved.clone() }; + + Workspace::save_for_task(&weak_workspace, task_with_shell.save, cx).await; + let terminal = project .update(cx, |project, cx| { project.create_terminal_task( diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index d1c53203329d73..5177fb259e7f46 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -12,8 +12,8 @@ use editor::{ }; use fuzzy::StringMatchCandidate; use gpui::{ - Action as _, AppContext, Context, Corner, Entity, FocusHandle, Focusable, HighlightStyle, Hsla, - Render, Subscription, Task, TextStyle, WeakEntity, actions, + Action as _, AppContext, Context, Entity, FocusHandle, Focusable, HighlightStyle, Hsla, Render, + Subscription, Task, TextStyle, WeakEntity, actions, }; use language::{Anchor, Buffer, CharScopeContext, CodeLabel, TextBufferSnapshot, ToOffset}; use menu::{Confirm, SelectNext, SelectPrevious}; @@ -386,7 +386,7 @@ impl Console { }) }, ) - .anchor(Corner::TopRight) + .anchor(gpui::Anchor::TopRight) } fn render_console(&self, cx: &Context) -> impl IntoElement { diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 3c1498113d603a..a344a92eadd826 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -914,7 +914,7 @@ impl Render for MemoryView { deferred( anchored() .position(*position) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index fd8fd736b9e519..991961f627cb0c 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -1579,7 +1579,7 @@ impl Render for VariableList { deferred( anchored() .position(*position) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml index 92c42f97a29eba..d051b51e8bfdcb 100644 --- a/crates/dev_container/Cargo.toml +++ b/crates/dev_container/Cargo.toml @@ -5,11 +5,13 @@ publish.workspace = true edition.workspace = true [dependencies] +anyhow.workspace = true async-tar.workspace = true async-trait.workspace = true serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true +yaml-rust2.workspace = true shlex.workspace = true http_client.workspace = true http.workspace = true diff --git a/crates/dev_container/src/command_json.rs b/crates/dev_container/src/command_json.rs index 9823fec4068f14..8226767f57967d 100644 --- a/crates/dev_container/src/command_json.rs +++ b/crates/dev_container/src/command_json.rs @@ -52,9 +52,8 @@ where if raw.is_empty() || raw.trim() == "[]" || raw.trim() == "{}" { return Ok(None); } - let value = serde_json_lenient::from_str(&raw) - .map_err(|e| format!("Error deserializing from raw json: {e}")); - value + serde_json_lenient::from_str(&raw) + .map_err(|e| format!("Error deserializing from raw json: {e}")) } else { let std_err = String::from_utf8_lossy(&output.stderr); Err(format!( @@ -62,3 +61,47 @@ where )) } } + +#[cfg(test)] +mod tests { + use std::process::ExitStatus; + + use super::*; + + fn success_output(stdout: &str) -> Output { + Output { + status: ExitStatus::default(), + stdout: stdout.as_bytes().to_vec(), + stderr: Vec::new(), + } + } + + #[derive(Debug, Deserialize, PartialEq)] + struct TestItem { + id: String, + } + + #[test] + fn test_deserialize_newline_delimited_json_rejected() { + // Strict single-value contract: NDJSON must be rejected. Commands that + // may legitimately return multiple rows (e.g. `docker ps`) parse their + // output themselves rather than routing through this helper. + let output = success_output("{\"id\":\"first\"}\n{\"id\":\"second\"}\n"); + let result: Result, String> = deserialize_json_output(output); + assert!(result.is_err(), "expected parse error, got {result:?}"); + } + + #[test] + fn test_deserialize_empty_output() { + let output = success_output(""); + let result: Option = deserialize_json_output(output).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn test_deserialize_empty_object() { + let output = success_output("{}"); + let result: Option = deserialize_json_output(output).unwrap(); + assert_eq!(result, None); + } +} diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index 385185aacc1fca..a5ba5edd6f85d8 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -79,6 +79,11 @@ pub enum DevContainerError { FilesystemError, ResourceFetchFailed, NotInValidProject, + /// Multiple existing containers match this project's identifying labels + /// (`devcontainer.local_folder` + `devcontainer.config_file`). The spec + /// expects those labels to be unique per project, so Zed can't choose + /// which one to connect to. The user must remove the duplicate(s). + MultipleMatchingContainers(Vec), } impl Display for DevContainerError { @@ -112,6 +117,12 @@ impl Display for DevContainerError { DevContainerError::ResourceFetchFailed => "Failed to fetch resources from template or feature repository".to_string(), DevContainerError::DevContainerValidationFailed(failure) => failure.to_string(), + DevContainerError::MultipleMatchingContainers(ids) => format!( + "Multiple containers match this project's dev container labels ({}). \ + Zed can't decide which to connect to. Stop and remove the stale one(s) with \ + `docker stop ` and `docker rm `, then try again.", + ids.join(", ") + ), } ) } @@ -285,6 +296,7 @@ pub async fn start_dev_container_with_config( Ok((connection, remote_workspace_folder)) } + Err(err @ DevContainerError::MultipleMatchingContainers(_)) => Err(err), Err(err) => { let message = format!("Failed with nested error: {:?}", err); Err(DevContainerError::DevContainerUpFailed(message)) diff --git a/crates/dev_container/src/devcontainer_json.rs b/crates/dev_container/src/devcontainer_json.rs index 752dcfc037cc75..42e6c6f316ceae 100644 --- a/crates/dev_container/src/devcontainer_json.rs +++ b/crates/dev_container/src/devcontainer_json.rs @@ -19,9 +19,10 @@ pub(crate) enum PortAttributeProtocol { Http, } -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) enum OnAutoForward { + #[default] Notify, OpenBrowser, OpenBrowserOnce, @@ -33,11 +34,16 @@ pub(crate) enum OnAutoForward { #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct PortAttributes { - label: String, + #[serde(default)] + label: Option, + #[serde(default)] on_auto_forward: OnAutoForward, + #[serde(default)] elevate_if_needed: bool, + #[serde(default)] require_local_port: bool, - protocol: PortAttributeProtocol, + #[serde(default)] + protocol: Option, } #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] @@ -135,12 +141,12 @@ pub(crate) struct ZedCustomization { #[serde(rename_all = "camelCase")] pub(crate) struct ContainerBuild { pub(crate) dockerfile: String, - context: Option, + pub(crate) context: Option, pub(crate) args: Option>, - options: Option>, + pub(crate) options: Option>, pub(crate) target: Option, #[serde(default, deserialize_with = "deserialize_string_or_array")] - cache_from: Option>, + pub(crate) cache_from: Option>, } #[derive(Clone, Debug, Serialize, Eq, PartialEq)] @@ -237,14 +243,26 @@ pub(crate) struct DevContainer { host_requirements: Option, } +pub(crate) fn deserialize_devcontainer_json_to_value( + json: &str, +) -> Result { + serde_json_lenient::from_str(json).map_err(|e| { + log::error!("Unable to deserialize json values: {e}"); + DevContainerError::DevContainerParseFailed + }) +} + +pub(crate) fn deserialize_devcontainer_json_from_value( + json: serde_json_lenient::Value, +) -> Result { + serde_json_lenient::from_value(json).map_err(|e| { + log::error!("Unable to deserialize devcontainer from json values: {e}"); + DevContainerError::DevContainerParseFailed + }) +} + pub(crate) fn deserialize_devcontainer_json(json: &str) -> Result { - match serde_json_lenient::from_str(json) { - Ok(devcontainer) => Ok(devcontainer), - Err(e) => { - log::error!("Unable to deserialize devcontainer from json: {e}"); - Err(DevContainerError::DevContainerParseFailed) - } - } + deserialize_devcontainer_json_to_value(json).and_then(deserialize_devcontainer_json_from_value) } impl DevContainer { @@ -824,30 +842,30 @@ mod test { ( "3000".to_string(), PortAttributes { - label: "This Port".to_string(), + label: Some("This Port".to_string()), on_auto_forward: OnAutoForward::Notify, elevate_if_needed: false, require_local_port: true, - protocol: PortAttributeProtocol::Https + protocol: Some(PortAttributeProtocol::Https) } ), ( "db:5432".to_string(), PortAttributes { - label: "This Port too".to_string(), + label: Some("This Port too".to_string()), on_auto_forward: OnAutoForward::Silent, elevate_if_needed: true, require_local_port: false, - protocol: PortAttributeProtocol::Http + protocol: Some(PortAttributeProtocol::Http) } ) ])), other_ports_attributes: Some(PortAttributes { - label: "Other Ports".to_string(), + label: Some("Other Ports".to_string()), on_auto_forward: OnAutoForward::OpenBrowser, elevate_if_needed: true, require_local_port: true, - protocol: PortAttributeProtocol::Https + protocol: Some(PortAttributeProtocol::Https) }), update_remote_user_uid: Some(true), remote_env: Some(HashMap::from([ @@ -1043,30 +1061,30 @@ mod test { ( "3000".to_string(), PortAttributes { - label: "This Port".to_string(), + label: Some("This Port".to_string()), on_auto_forward: OnAutoForward::Notify, elevate_if_needed: false, require_local_port: true, - protocol: PortAttributeProtocol::Https + protocol: Some(PortAttributeProtocol::Https) } ), ( "db:5432".to_string(), PortAttributes { - label: "This Port too".to_string(), + label: Some("This Port too".to_string()), on_auto_forward: OnAutoForward::Silent, elevate_if_needed: true, require_local_port: false, - protocol: PortAttributeProtocol::Http + protocol: Some(PortAttributeProtocol::Http) } ) ])), other_ports_attributes: Some(PortAttributes { - label: "Other Ports".to_string(), + label: Some("Other Ports".to_string()), on_auto_forward: OnAutoForward::OpenBrowser, elevate_if_needed: true, require_local_port: true, - protocol: PortAttributeProtocol::Https + protocol: Some(PortAttributeProtocol::Https) }), update_remote_user_uid: Some(true), remote_env: Some(HashMap::from([ @@ -1271,30 +1289,30 @@ mod test { ( "3000".to_string(), PortAttributes { - label: "This Port".to_string(), + label: Some("This Port".to_string()), on_auto_forward: OnAutoForward::Notify, elevate_if_needed: false, require_local_port: true, - protocol: PortAttributeProtocol::Https + protocol: Some(PortAttributeProtocol::Https) } ), ( "db:5432".to_string(), PortAttributes { - label: "This Port too".to_string(), + label: Some("This Port too".to_string()), on_auto_forward: OnAutoForward::Silent, elevate_if_needed: true, require_local_port: false, - protocol: PortAttributeProtocol::Http + protocol: Some(PortAttributeProtocol::Http) } ) ])), other_ports_attributes: Some(PortAttributes { - label: "Other Ports".to_string(), + label: Some("Other Ports".to_string()), on_auto_forward: OnAutoForward::OpenBrowser, elevate_if_needed: true, require_local_port: true, - protocol: PortAttributeProtocol::Https + protocol: Some(PortAttributeProtocol::Https) }), update_remote_user_uid: Some(true), remote_env: Some(HashMap::from([ @@ -1504,6 +1522,60 @@ mod test { assert_eq!(rendered, "type=tmpfs,target=/tmp,consistency=cached"); } + #[test] + fn should_deserialize_port_attributes_with_missing_optional_fields() { + let json = r#" + { + "image": "nginx", + "portsAttributes": { + "8080": { + "label": "app", + "onAutoForward": "silent" + } + } + } + "#; + + let result = deserialize_devcontainer_json(json); + assert!( + result.is_ok(), + "Expected deserialization to succeed with partial portsAttributes, got: {:?}", + result.err() + ); + + let devcontainer = result.unwrap(); + let port_attrs = devcontainer.ports_attributes.unwrap(); + let attrs = port_attrs.get("8080").unwrap(); + assert_eq!(attrs.elevate_if_needed, false); + assert_eq!(attrs.require_local_port, false); + } + + #[test] + fn should_deserialize_port_attributes_with_all_fields_omitted() { + let json = r#" + { + "image": "nginx", + "portsAttributes": { + "3000": {} + } + } + "#; + + let result = deserialize_devcontainer_json(json); + assert!( + result.is_ok(), + "Expected deserialization to succeed with empty portsAttributes, got: {:?}", + result.err() + ); + + let devcontainer = result.unwrap(); + let port_attrs = devcontainer.ports_attributes.unwrap(); + let attrs = port_attrs.get("3000").unwrap(); + assert_eq!(attrs.on_auto_forward, OnAutoForward::Notify); + assert_eq!(attrs.elevate_if_needed, false); + assert_eq!(attrs.require_local_port, false); + } + #[test] fn should_fail_validation_with_workspace_mount_only() { let given_image_container_json = r#" @@ -1579,6 +1651,7 @@ mod test { )) ); } + #[test] fn should_pass_validation_with_workspace_folder_for_docker_compose() { let given_image_container_json = r#" diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index 9358356ceed52c..79fa05b83bb0bf 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -17,8 +17,9 @@ use crate::{ command_json::{CommandRunner, DefaultCommandRunner}, devcontainer_api::{DevContainerError, DevContainerUp}, devcontainer_json::{ - DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort, MountDefinition, - deserialize_devcontainer_json, + ContainerBuild, DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort, + MountDefinition, deserialize_devcontainer_json, deserialize_devcontainer_json_from_value, + deserialize_devcontainer_json_to_value, }, docker::{ Docker, DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild, @@ -131,40 +132,60 @@ impl DevContainerManifest { labels } - fn parse_nonremote_vars_for_content(&self, content: &str) -> Result { - let mut replaced_content = content - .replace("${devcontainerId}", &self.devcontainer_id()) - .replace( - "${containerWorkspaceFolderBasename}", - &self.remote_workspace_base_name().unwrap_or_default(), - ) - .replace( - "${localWorkspaceFolderBasename}", - &self.local_workspace_base_name()?, - ) - .replace( - "${containerWorkspaceFolder}", - &self - .remote_workspace_folder() - .map(|path| path.display().to_string()) - .unwrap_or_default() - .replace('\\', "/"), - ) - .replace( - "${localWorkspaceFolder}", - &self.local_workspace_folder().replace('\\', "/"), - ); - for (k, v) in &self.local_environment { - let find = format!("${{localEnv:{k}}}"); - replaced_content = replaced_content.replace(&find, &v.replace('\\', "/")); + fn parse_nonremote_vars_for_content( + &self, + content: &str, + ) -> Result { + let mut value = deserialize_devcontainer_json_to_value(content)?; + let mut to_visit = vec![&mut value]; + + while let Some(value) = to_visit.pop() { + use serde_json_lenient::Value; + + match value { + Value::String(string) => { + *string = string + .replace("${devcontainerId}", &self.devcontainer_id()) + .replace( + "${containerWorkspaceFolderBasename}", + &self.remote_workspace_base_name().unwrap_or_default(), + ) + .replace( + "${localWorkspaceFolderBasename}", + &self.local_workspace_base_name()?, + ) + .replace( + "${containerWorkspaceFolder}", + &self + .remote_workspace_folder() + .map(|path| path.display().to_string()) + .unwrap_or_default() + .replace('\\', "/"), + ) + .replace( + "${localWorkspaceFolder}", + &self.local_workspace_folder().replace('\\', "/"), + ); + *string = Self::replace_environment_variables( + string, + "localEnv", + &self.local_environment, + ); + } + + Value::Array(array) => to_visit.extend(array.iter_mut()), + Value::Object(object) => to_visit.extend(object.values_mut()), + + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } } - Ok(replaced_content) + Ok(value) } fn parse_nonremote_vars(&mut self) -> Result<(), DevContainerError> { let replaced_content = self.parse_nonremote_vars_for_content(&self.raw_config)?; - let parsed_config = deserialize_devcontainer_json(&replaced_content)?; + let parsed_config = deserialize_devcontainer_json_from_value(replaced_content)?; self.config = ConfigStatus::VariableParsed(parsed_config); @@ -178,32 +199,62 @@ impl DevContainerManifest { let mut merged_remote_env = container_env.clone(); // HOME is user-specific, and we will often not run as the image user merged_remote_env.remove("HOME"); - if let Some(remote_env) = self.dev_container().remote_env.clone() { - let mut raw = serde_json_lenient::to_string(&remote_env).map_err(|e| { - log::error!( - "Unexpected error serializing dev container remote_env: {e} - {:?}", - remote_env - ); - DevContainerError::DevContainerParseFailed - })?; - for (k, v) in container_env { - raw = raw.replace(&format!("${{containerEnv:{k}}}"), v); - } - let reserialized: HashMap = serde_json_lenient::from_str(&raw) - .map_err(|e| { - log::error!( - "Unexpected error reserializing dev container remote env: {e} - {:?}", - &raw - ); - DevContainerError::DevContainerParseFailed - })?; - for (k, v) in reserialized { + if let Some(mut remote_env) = self.dev_container().remote_env.clone() { + remote_env.values_mut().for_each(|value| { + *value = Self::replace_environment_variables(value, "containerEnv", &container_env) + }); + for (k, v) in remote_env { merged_remote_env.insert(k, v); } } Ok(merged_remote_env) } + fn replace_environment_variables( + mut orig: &str, + environment_source: &str, + environment: &HashMap, + ) -> String { + let mut replaced = String::with_capacity(orig.len()); + let prefix = format!("${{{environment_source}:"); + while let Some(start) = orig.find(&prefix) { + let var_name_start = start + prefix.len(); + let Some(end) = orig[var_name_start..].find('}') else { + // No closing `}` => malformed variable reference => paste as is. + break; + }; + let end = var_name_start + end; + + let (var_name_end, default_start) = + if let Some(var_name_end) = orig[var_name_start..end].find(':') { + let var_name_end = var_name_start + var_name_end; + (var_name_end, var_name_end + 1) + } else { + (end, end) + }; + + let var_name = &orig[var_name_start..var_name_end]; + if var_name.is_empty() { + // Empty variable name => paste as is. + replaced.push_str(&orig[..end + 1]); + orig = &orig[end + 1..]; + continue; + } + let default = &orig[default_start..end]; + + replaced.push_str(&orig[..start]); + replaced.push_str( + environment + .get(var_name) + .map(|value| value.as_str()) + .unwrap_or(default), + ); + orig = &orig[end + 1..]; + } + replaced.push_str(orig); + replaced + } + fn config_file(&self) -> PathBuf { self.config_directory.join(&self.file_name) } @@ -478,7 +529,7 @@ impl DevContainerManifest { let contents_parsed = self.parse_nonremote_vars_for_content(&contents)?; let feature_json: DevContainerFeatureJson = - serde_json_lenient::from_str(&contents_parsed).map_err(|e| { + serde_json_lenient::from_value(contents_parsed).map_err(|e| { log::error!("Failed to parse devcontainer-feature.json: {e}"); DevContainerError::ResourceFetchFailed })?; @@ -797,9 +848,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true let Some(docker_compose_files) = dev_container.docker_compose_file.clone() else { return Err(DevContainerError::DevContainerParseFailed); }; + // Normalize upfront so every downstream consumer of + // `DockerComposeResources.files` (compose fragment reads, project-name + // derivation, `docker compose -f` invocations, …) sees resolved paths. + // `dockerComposeFile` entries are joined verbatim with + // `config_directory`, so raw entries can carry `..` components. let docker_compose_full_paths = docker_compose_files .iter() - .map(|relative| self.config_directory.join(relative)) + .map(|relative| normalize_path(&self.config_directory.join(relative))) .collect::>(); let Some(config) = self @@ -933,8 +989,9 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true docker_compose_resources.files.push(config_location); + let project_name = self.project_name().await?; self.docker_client - .docker_compose_build(&docker_compose_resources.files, &self.project_name()) + .docker_compose_build(&docker_compose_resources.files, &project_name) .await?; ( self.docker_client @@ -1025,8 +1082,9 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true docker_compose_resources.files.push(config_location); + let project_name = self.project_name().await?; self.docker_client - .docker_compose_build(&docker_compose_resources.files, &self.project_name()) + .docker_compose_build(&docker_compose_resources.files, &project_name) .await?; ( @@ -1605,6 +1663,26 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true } } + if let Some(options) = dev_container + .build + .as_ref() + .and_then(|b| b.options.as_ref()) + { + for option in options { + command.arg(option); + } + } + + if let Some(cache_from_images) = dev_container + .build + .as_ref() + .and_then(|b| b.cache_from.as_ref()) + { + for cache_from_image in cache_from_images { + command.args(["--cache-from", cache_from_image]); + } + } + command.args(["--target", "dev_containers_target_stage"]); command.args([ @@ -1614,8 +1692,8 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true command.args(["-t", &features_build_info.image_tag]); - if let DevContainerBuildType::Dockerfile(_) = dev_container.build_type() { - command.arg(self.config_directory.display().to_string()); + if let DevContainerBuildType::Dockerfile(build) = dev_container.build_type() { + command.arg(self.calculate_context_dir(build).display().to_string()); } else { // Use an empty folder as the build context to avoid pulling in unneeded files. // The actual feature content is supplied via the BuildKit build context above. @@ -1630,7 +1708,8 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true resources: DockerComposeResources, ) -> Result { let mut command = Command::new(self.docker_client.docker_cli()); - command.args(&["compose", "--project-name", &self.project_name()]); + let project_name = self.project_name().await?; + command.args(&["compose", "--project-name", &project_name]); for docker_compose_file in resources.files { command.args(&["-f", &docker_compose_file.display().to_string()]); } @@ -2025,15 +2104,80 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true .await } - fn project_name(&self) -> String { - if let Some(name) = &self.dev_container().name { - safe_id_lower(name) - } else { - let alternate_name = &self - .local_workspace_base_name() - .unwrap_or(self.local_workspace_folder()); - safe_id_lower(alternate_name) + /// Matches `@devcontainers/cli`'s `getProjectName` in + /// `src/spec-node/dockerCompose.ts`. See `derive_project_name` for the + /// full precedence. Using the devcontainer.json `name` field here + /// diverges from the reference CLI and creates duplicate compose + /// projects when the same folder is opened by both tools — see #54255. + /// + /// Async because the derivation reads both the workspace `.env` file + /// and the merged compose config — neither of which is available + /// synchronously. + async fn project_name(&self) -> Result { + let workspace_fallback = self + .local_workspace_base_name() + .unwrap_or_else(|_| self.local_workspace_folder()); + let compose_resources = self.docker_compose_manifest().await.ok(); + let first_compose_file = compose_resources + .as_ref() + .and_then(|r| r.files.first()) + .map(PathBuf::as_path); + let compose_config_name = compose_resources + .as_ref() + .and_then(|r| r.config.name.as_deref()); + let mut compose_name_explicitly_declared = false; + if let Some(resources) = &compose_resources { + for file in &resources.files { + // Mirrors the CLI's fragment re-parse (dockerCompose.ts 663-673): + // the whole readFile+yaml.load pair is wrapped in a single + // try/catch that swallows every failure. The comment there + // calls out `!reset` custom tags; the behavior is "on any + // failure, treat the fragment as not-declared and keep + // scanning." Propagating an I/O error here would diverge + // from that policy and fail the whole devcontainer flow for + // a fragment the CLI would have silently skipped. + let contents = match self.fs.load(file).await { + Ok(contents) => contents, + Err(err) => { + log::warn!( + "Ignoring unreadable compose fragment `{}` while deriving project name: {err:?}", + file.display() + ); + continue; + } + }; + if compose_fragment_declares_name(&contents) { + compose_name_explicitly_declared = true; + break; + } + } } + let dotenv_path = self.local_project_directory.join(".env"); + let dotenv_contents = match self.fs.load(&dotenv_path).await { + Ok(contents) => Some(contents), + Err(err) if is_missing_file_error(&err) => None, + Err(err) => { + // Mirrors the CLI: `getProjectName` only swallows `ENOENT`/ + // `EISDIR` on the `.env` read. Any other error (permission + // denied, I/O failure, …) must surface so we don't silently + // fall back to a non-canonical project name and create a + // second compose project for the same repo. + log::error!( + "Failed to read workspace .env `{}` while deriving project name: {err:?}", + dotenv_path.display() + ); + return Err(DevContainerError::FilesystemError); + } + }; + Ok(derive_project_name( + &self.local_environment, + dotenv_contents.as_deref(), + compose_config_name, + compose_name_explicitly_declared, + first_compose_file, + &self.local_project_directory, + &workspace_fallback, + )) } async fn expanded_dockerfile_content(&self) -> Result { @@ -2042,12 +2186,24 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true return Err(DevContainerError::DevContainerParseFailed); }; - let devcontainer_args = self - .dev_container() - .build - .as_ref() - .and_then(|b| b.args.clone()) - .unwrap_or_default(); + // For docker-compose configs the build args live on the primary + // compose service rather than on dev_container.build. + let devcontainer_args = match self.dev_container().build_type() { + DevContainerBuildType::DockerCompose => { + let compose = self.docker_compose_manifest().await?; + find_primary_service(&compose, self)? + .1 + .build + .and_then(|b| b.args) + .unwrap_or_default() + } + _ => self + .dev_container() + .build + .as_ref() + .and_then(|b| b.args.clone()) + .unwrap_or_default(), + }; let contents = self.fs.load(&dockerfile_path).await.map_err(|e| { log::error!("Failed to load Dockerfile: {e}"); DevContainerError::FilesystemError @@ -2094,6 +2250,19 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true Ok(parsed_lines.join("\n")) } + + fn calculate_context_dir(&self, build: ContainerBuild) -> PathBuf { + let Some(context) = build.context else { + return self.config_directory.clone(); + }; + let context_path = PathBuf::from(context); + + if context_path.is_absolute() { + context_path + } else { + self.config_directory.join(context_path) + } + } } /// Holds all the information needed to construct a `docker buildx build` command @@ -2245,6 +2414,132 @@ fn escape_regex_chars(input: &str) -> String { result } +/// Sanitize a string for use as a Docker Compose project name, matching +/// `@devcontainers/cli`'s `toProjectName` (modern Compose branch): lowercase +/// the input and strip any character outside `[-_a-z0-9]`. +fn sanitize_compose_project_name(input: &str) -> String { + input + .chars() + .flat_map(|c| c.to_lowercase()) + .filter(|c| c.is_ascii_digit() || c.is_ascii_lowercase() || *c == '-' || *c == '_') + .collect() +} + +/// Derive the Docker Compose project name, mirroring `getProjectName` in +/// `@devcontainers/cli`'s `src/spec-node/dockerCompose.ts`. Precedence: +/// +/// 1. `COMPOSE_PROJECT_NAME` from the local environment. +/// 2. `COMPOSE_PROJECT_NAME` from the workspace `.env` file. +/// 3. The top-level `name:` field of the merged compose config, but only +/// when at least one compose fragment explicitly declared `name:`. +/// Compose injects a default `name: devcontainer` into its merged +/// output whenever no fragment declared one — that default must NOT be +/// treated as a user-provided name, so rule 4 applies instead. +/// 4. Basename of the first compose file's directory, appending +/// `_devcontainer` only when that directory is +/// `/.devcontainer`. +/// +/// The caller is responsible for computing `compose_name_explicitly_declared` +/// by scanning the original compose fragments for a top-level `name:` key +/// (the reference CLI does the same). This keeps the helper a pure function +/// of its inputs. +/// +/// All branches pass through `sanitize_compose_project_name` — the CLI's +/// final normalization step. +fn derive_project_name( + local_environment: &HashMap, + workspace_dotenv_contents: Option<&str>, + compose_config_name: Option<&str>, + compose_name_explicitly_declared: bool, + first_compose_file: Option<&Path>, + workspace_root: &Path, + workspace_fallback: &str, +) -> String { + if let Some(env_name) = local_environment.get("COMPOSE_PROJECT_NAME") + && !env_name.is_empty() + { + return sanitize_compose_project_name(env_name); + } + if let Some(contents) = workspace_dotenv_contents + && let Some(dotenv_name) = parse_dotenv_compose_project_name(contents) + && !dotenv_name.is_empty() + { + return sanitize_compose_project_name(&dotenv_name); + } + if let Some(name) = compose_config_name + && !name.is_empty() + && compose_name_explicitly_declared + { + return sanitize_compose_project_name(name); + } + let compose_dir = first_compose_file.and_then(Path::parent); + let canonical_devcontainer_dir = normalize_path(&workspace_root.join(".devcontainer")); + let raw = match compose_dir { + Some(dir) if dir == canonical_devcontainer_dir => { + // Matches the CLI's `configDir/.devcontainer` branch: use the + // *workspace root's* basename with the `_devcontainer` suffix, + // NOT the `.devcontainer` dir's basename. + format!("{workspace_fallback}_devcontainer") + } + Some(dir) => dir + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| workspace_fallback.to_string()), + None => format!("{workspace_fallback}_devcontainer"), + }; + sanitize_compose_project_name(&raw) +} + +/// Classify an anyhow error from `Fs::load` as "file does not exist" vs a +/// real I/O failure. Used on the `.env` read in `project_name()`, where the +/// CLI's `getProjectName` catches only `ENOENT`/`EISDIR` and rethrows +/// everything else; any other error must propagate so callers can surface +/// the problem instead of silently falling back to a non-canonical project +/// name. (The fragment-rescan loop uses a different, broader swallow — +/// the CLI wraps its fragment read+parse in one try/catch that ignores +/// every failure.) +fn is_missing_file_error(err: &anyhow::Error) -> bool { + err.downcast_ref::().is_some_and(|e| { + matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::IsADirectory + ) + }) +} + +/// Extract `COMPOSE_PROJECT_NAME` from a `.env` file's contents. Matches +/// the subset of dotenv syntax that `@devcontainers/cli`'s regex parser +/// recognizes: a bare `COMPOSE_PROJECT_NAME=value` line (no `export` prefix, +/// no quoting, no line continuation). Comment lines are skipped. +fn parse_dotenv_compose_project_name(contents: &str) -> Option { + for line in contents.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with('#') { + continue; + } + if let Some(value) = trimmed.strip_prefix("COMPOSE_PROJECT_NAME=") { + return Some(value.trim().to_string()); + } + } + None +} + +/// Detect whether a compose-file fragment declares a top-level `name:` key. +/// Matches the reference CLI's approach: parse the fragment as YAML and check +/// for a `name` key on the root mapping. This handles all valid styles — +/// block mappings, quoted keys (`"name":`), flow-style root mappings, anchors, +/// etc. On parse failure we fall through (return `false`), matching the CLI's +/// own behavior when fragment parsing errors. +fn compose_fragment_declares_name(contents: &str) -> bool { + let Ok(docs) = yaml_rust2::YamlLoader::load_from_str(contents) else { + return false; + }; + let Some(yaml_rust2::Yaml::Hash(h)) = docs.into_iter().next() else { + return false; + }; + h.contains_key(&yaml_rust2::Yaml::String("name".to_string())) +} + /// Extracts the short feature ID from a full feature reference string. /// /// Examples: @@ -2376,12 +2671,58 @@ fn dockerfile_inject_alias( alias: &str, build_target: Option, ) -> String { - match image_from_dockerfile(dockerfile_content.to_string(), &build_target) { - Some(target) => format!( - r#"{dockerfile_content} -FROM {target} AS {alias}"# - ), - None => dockerfile_content.to_string(), + let from_lines: Vec<(usize, &str)> = dockerfile_content + .lines() + .enumerate() + .filter(|(_, line)| line.starts_with("FROM")) + .collect(); + + let target_entry = match &build_target { + Some(target) => from_lines.iter().rfind(|(_, line)| { + let parts: Vec<&str> = line.split_whitespace().collect(); + parts.len() >= 3 + && parts + .get(parts.len() - 2) + .map_or(false, |p| p.eq_ignore_ascii_case("as")) + && parts + .last() + .map_or(false, |p| p.eq_ignore_ascii_case(target)) + }), + None => from_lines.last(), + }; + + let Some(&(line_idx, from_line)) = target_entry else { + return dockerfile_content.to_string(); + }; + + let parts: Vec<&str> = from_line.split_whitespace().collect(); + let has_alias = parts.len() >= 3 + && parts + .get(parts.len() - 2) + .map_or(false, |p| p.eq_ignore_ascii_case("as")); + + if has_alias { + let Some(existing_alias) = parts.last() else { + return dockerfile_content.to_string(); + }; + format!("{dockerfile_content}\nFROM {existing_alias} AS {alias}") + } else { + let lines: Vec<&str> = dockerfile_content.lines().collect(); + let mut result = String::new(); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + result.push('\n'); + } + if i == line_idx { + result.push_str(&format!("{line} AS {alias}")); + } else { + result.push_str(line); + } + } + if dockerfile_content.ends_with('\n') { + result.push('\n'); + } + result } } @@ -2739,7 +3080,9 @@ mod test { image: DockerInspect { id: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(), config: DockerInspectConfig { - labels: DockerConfigLabels { metadata: None }, + labels: DockerConfigLabels { + metadata: None, + }, image_user: None, env: Vec::new(), }, @@ -2872,7 +3215,9 @@ mod test { "REMOTE_WORKSPACE_FOLDER": "${containerWorkspaceFolder}", "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}", "LOCAL_ENV_VAR_1": "${localEnv:local_env_1}", - "LOCAL_ENV_VAR_2": "${localEnv:my_other_env}" + "LOCAL_ENV_VAR_2": "${localEnv:my_other_env}", + "LOCAL_ENV_VAR_3": "before-${localEnv:missing_local_env}-after", + "LOCAL_ENV_VAR_4": "${localEnv:with_defaults:default}" } } @@ -2966,6 +3311,42 @@ mod test { .and_then(|env| env.get("LOCAL_ENV_VAR_2")), Some(&"THISVALUEHERE".to_string()) ); + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("LOCAL_ENV_VAR_3")), + Some(&"before--after".to_string()) + ); + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("LOCAL_ENV_VAR_4")), + Some(&"default".to_string()) + ); + } + + #[test] + fn test_replace_environment_variables() { + let replaced = DevContainerManifest::replace_environment_variables( + "before ${containerEnv:FOUND} middle ${containerEnv:MISSING:default-value} after${containerEnv:MISSING2}", + "containerEnv", + &HashMap::from([("FOUND".to_string(), "value".to_string())]), + ); + + assert_eq!(replaced, "before value middle default-value after"); + } + + #[test] + fn test_replace_environment_variables_supports_defaults_with_colons() { + let replaced = DevContainerManifest::replace_environment_variables( + "before ${containerEnv:MISSING:one:two} after", + "containerEnv", + &HashMap::new(), + ); + + assert_eq!(replaced, "before one:two after"); } #[gpui::test] @@ -3212,7 +3593,7 @@ RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/hom # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. ARG VARIANT="16-bullseye" -FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} +FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} AS dev_container_auto_added_stage_label RUN mkdir -p /workspaces && chown node:node /workspaces @@ -3225,7 +3606,6 @@ RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/hom && mkdir -p /home/$USERNAME/commandhistory \ && touch /home/$USERNAME/commandhistory/.bash_history \ && chown -R $USERNAME /home/$USERNAME/commandhistory -FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} AS dev_container_auto_added_stage_label FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize USER root @@ -3540,6 +3920,28 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + let docker_commands = test_dependencies + .command_runner + .commands_by_program("docker"); + let compose_up = docker_commands + .iter() + .find(|c| { + c.args.first().map(String::as_str) == Some("compose") + && c.args.iter().any(|a| a == "up") + }) + .expect("docker compose up command recorded"); + let project_name_idx = compose_up + .args + .iter() + .position(|a| a == "--project-name") + .expect("compose command has --project-name flag"); + assert_eq!( + compose_up.args[project_name_idx + 1], + "project_devcontainer", + "compose project name should match @devcontainers/cli derivation \ + (${{folderBasename}}_devcontainer), ignoring devcontainer.json `name`" + ); + let files = test_dependencies.fs.files(); let feature_dockerfile = files .iter() @@ -3553,14 +3955,13 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ &feature_dockerfile, r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder -FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label # Include lld linker to improve build times either by using environment variable # RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install clang lld \ && apt-get autoremove -y && apt-get clean -y -FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize USER root @@ -3754,6 +4155,252 @@ ENV DOCKER_BUILDKIT=1 ) } + #[test] + fn derive_project_name_env_wins_over_everything() { + // CLI precedence rule 1: `COMPOSE_PROJECT_NAME` env var short-circuits + // every later source (.env, compose name:, basename fallback). + use crate::devcontainer_manifest::derive_project_name; + + let env = HashMap::from([("COMPOSE_PROJECT_NAME".to_string(), "from_env".to_string())]); + let got = derive_project_name( + &env, + Some("COMPOSE_PROJECT_NAME=from_dotenv\n"), + Some("from_compose_name"), + true, + Some(Path::new( + "/path/to/local/project/.devcontainer/docker-compose.yml", + )), + Path::new("/path/to/local/project"), + "project", + ); + assert_eq!(got, "from_env"); + } + + #[test] + fn derive_project_name_dotenv_wins_over_compose_and_fallback() { + // CLI precedence rule 2: when no env var is set, the workspace .env's + // `COMPOSE_PROJECT_NAME=` line wins over the compose config's `name:` + // field and the basename fallback. + use crate::devcontainer_manifest::derive_project_name; + + let got = derive_project_name( + &HashMap::new(), + Some("# comment\nCOMPOSE_PROJECT_NAME=from_dotenv\n"), + Some("from_compose_name"), + true, + Some(Path::new( + "/path/to/local/project/.devcontainer/docker-compose.yml", + )), + Path::new("/path/to/local/project"), + "project", + ); + assert_eq!(got, "from_dotenv"); + } + + #[test] + fn derive_project_name_compose_name_wins_over_fallback() { + // CLI precedence rule 3: when neither env nor .env provide a name, + // the merged compose config's top-level `name:` field takes precedence + // over the basename fallback. Also covers sanitization (spaces + // stripped, uppercase lowercased). + use crate::devcontainer_manifest::derive_project_name; + + let got = derive_project_name( + &HashMap::new(), + None, + Some("My Compose Project"), + true, + Some(Path::new( + "/path/to/local/project/.devcontainer/docker-compose.yml", + )), + Path::new("/path/to/local/project"), + "project", + ); + assert_eq!(got, "mycomposeproject"); + } + + #[test] + fn derive_project_name_skips_compose_name_when_not_explicitly_declared() { + // CLI precedence rule 3 edge case: `docker compose config` injects a + // default `name: devcontainer` into the merged output whenever no + // compose fragment declared one. `@devcontainers/cli` ignores that + // default by tracking per-fragment whether `name:` was declared and + // skipping rule 3 if none was. The caller conveys that signal via + // `compose_name_explicitly_declared`; when it's `false`, even a + // non-empty `compose_config_name` must be skipped so rule 4 applies. + use crate::devcontainer_manifest::derive_project_name; + + let got = derive_project_name( + &HashMap::new(), + None, + Some("devcontainer"), + false, + Some(Path::new( + "/path/to/myworkspace/.devcontainer/docker-compose.yml", + )), + Path::new("/path/to/myworkspace"), + "myworkspace", + ); + assert_eq!(got, "myworkspace_devcontainer"); + } + + #[test] + fn derive_project_name_omits_suffix_when_compose_file_outside_devcontainer_dir() { + // CLI precedence rule 4: when falling back to the first compose file's + // directory basename, the `_devcontainer` suffix is only appended when + // that directory IS `/.devcontainer`. A compose file at the + // workspace root (as `"dockerComposeFile": "../docker-compose.yml"` + // produces) must derive to the plain dir basename, not + // `project_devcontainer` — otherwise Zed diverges from the CLI. + use crate::devcontainer_manifest::derive_project_name; + + let got = derive_project_name( + &HashMap::new(), + None, + None, + false, + Some(Path::new("/path/to/local/project/docker-compose.yml")), + Path::new("/path/to/local/project"), + "project", + ); + assert_eq!(got, "project"); + } + + #[test] + fn derive_project_name_handles_resolved_paths_from_docker_compose_manifest() { + // `docker_compose_manifest()` normalizes compose file paths upfront + // (resolving `..` components from raw `dockerComposeFile` entries like + // `"subdir/../docker-compose.yml"`) before populating + // `DockerComposeResources.files`. This test pins the resulting + // rule-4/rule-5 behavior on those normalized paths: a file + // semantically under `/.devcontainer` takes rule 4, and + // one that resolves outside it takes rule 5. + use crate::devcontainer_manifest::derive_project_name; + + // Normalized equivalent of `.devcontainer/subdir/../docker-compose.yml`: + // rule 4 applies → `${ws}_devcontainer`. + let got_under = derive_project_name( + &HashMap::new(), + None, + None, + false, + Some(Path::new( + "/path/to/local/project/.devcontainer/docker-compose.yml", + )), + Path::new("/path/to/local/project"), + "project", + ); + assert_eq!(got_under, "project_devcontainer"); + + // Normalized equivalent of `.devcontainer/../docker-compose.yml`: + // the file sits at the workspace root, so rule 5 applies — plain + // basename of the parent dir, no suffix. + let got_escaped = derive_project_name( + &HashMap::new(), + None, + None, + false, + Some(Path::new("/path/to/local/project/docker-compose.yml")), + Path::new("/path/to/local/project"), + "project", + ); + assert_eq!(got_escaped, "project"); + } + + #[test] + fn compose_fragment_declares_name_detects_top_level_name_key() { + // Block-style top-level key — declared. + use crate::devcontainer_manifest::compose_fragment_declares_name; + + assert!(compose_fragment_declares_name( + "name: my-project\nservices:\n app:\n image: foo\n" + )); + // Indented `name:` belongs to a nested mapping (here a service) and + // must NOT count as a top-level declaration. + assert!(!compose_fragment_declares_name( + "services:\n app:\n name: inner\n image: foo\n" + )); + // Comment lines are ignored. + assert!(!compose_fragment_declares_name( + "# name: commented-out\nservices: {}\n" + )); + // Empty fragment — no declaration. + assert!(!compose_fragment_declares_name("")); + // Quoted key — still a top-level declaration. A line scanner that + // looks for bare `name:` at column 0 would miss this. + assert!(compose_fragment_declares_name( + "\"name\": my-project\nservices: {}\n" + )); + // Flow-style root mapping — also a top-level declaration. Again a + // line scanner keyed on block-style layout would miss it. + assert!(compose_fragment_declares_name( + "{name: my-project, services: {app: {image: foo}}}\n" + )); + // Unparsable fragment falls through to "not declared" (matches the + // CLI's behavior on parse failure). + assert!(!compose_fragment_declares_name(": : :\n- - -\n")); + } + + #[test] + fn is_missing_file_error_only_accepts_notfound_and_isadirectory() { + // Mirrors the CLI's narrow `ENOENT`/`EISDIR` swallow in + // `getProjectName`'s `.env` read. Any other `io::Error` — permission + // denied, I/O failure, `ENOTDIR`, etc. — must not be classified as + // "missing" so callers surface the problem instead of silently + // falling back to a non-canonical project name. Non-`io::Error` + // anyhow errors must also not be classified as missing. + use crate::devcontainer_manifest::is_missing_file_error; + + let notfound = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::NotFound)); + assert!(is_missing_file_error(¬found)); + + // EISDIR — `.env` exists as a directory; CLI swallows, so must we. + let is_a_dir = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::IsADirectory)); + assert!(is_missing_file_error(&is_a_dir)); + + // ENOTDIR — a path component isn't a directory; CLI does NOT + // swallow this (its catch is narrow to ENOENT/EISDIR), so we must + // propagate it as a real failure. + let not_a_dir = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::NotADirectory)); + assert!(!is_missing_file_error(¬_a_dir)); + + let permission_denied = + anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + assert!(!is_missing_file_error(&permission_denied)); + + let other_io = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::Other)); + assert!(!is_missing_file_error(&other_io)); + + let non_io: anyhow::Error = anyhow::anyhow!("something else"); + assert!(!is_missing_file_error(&non_io)); + } + + #[test] + fn sanitize_compose_project_name_matches_cli_rules() { + use crate::devcontainer_manifest::sanitize_compose_project_name; + + // Plain lowercase alnum passes through. + assert_eq!( + sanitize_compose_project_name("project_devcontainer"), + "project_devcontainer" + ); + // Hyphens survive (unlike safe_id_lower which would replace them with _). + assert_eq!( + sanitize_compose_project_name("devcontainer-compose-test_devcontainer"), + "devcontainer-compose-test_devcontainer" + ); + // Uppercase letters are lowercased. + assert_eq!( + sanitize_compose_project_name("Makermint-Studio_devcontainer"), + "makermint-studio_devcontainer" + ); + // Characters outside [-_a-z0-9] are stripped. + assert_eq!( + sanitize_compose_project_name("Rust & PostgreSQL_devcontainer"), + "rustpostgresql_devcontainer" + ); + } + #[test] fn test_resolve_compose_dockerfile() { let compose = Path::new("/project/.devcontainer/docker-compose.yml"); @@ -3944,14 +4591,13 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ &feature_dockerfile, r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder -FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label # Include lld linker to improve build times either by using environment variable # RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install clang lld \ && apt-get autoremove -y && apt-get clean -y -FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize USER root @@ -4124,14 +4770,13 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ &feature_dockerfile, r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder -FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label # Include lld linker to improve build times either by using environment variable # RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install clang lld \ && apt-get autoremove -y && apt-get clean -y -FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label FROM dev_container_feature_content_temp as dev_containers_feature_content_source @@ -4388,7 +5033,7 @@ RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/hom && mkdir -p /home/$USERNAME/commandhistory \ && touch /home/$USERNAME/commandhistory/.bash_history \ && chown -R $USERNAME /home/$USERNAME/commandhistory -FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} AS dev_container_auto_added_stage_label +FROM development AS dev_container_auto_added_stage_label FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize USER root @@ -4902,6 +5547,68 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de ) } + #[gpui::test] + async fn test_expands_compose_service_args_in_dockerfile(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + + let given_devcontainer_contents = r#" + { + "dockerComposeFile": "docker-compose-with-args.yml", + "service": "app", + } + "#; + + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"), + "FROM ${BASE_IMAGE}\nUSER root\n".to_string(), + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let expanded = devcontainer_manifest + .expanded_dockerfile_content() + .await + .unwrap(); + + assert_eq!(expanded, "FROM test_image:latest\nUSER root"); + + let base_image = + image_from_dockerfile(expanded, &None).expect("base image resolves from compose args"); + assert_eq!(base_image, "test_image:latest"); + } + + #[cfg(not(target_os = "windows"))] + #[gpui::test] + async fn check_for_existing_container_errors_when_multiple_match(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let (test_dependencies, devcontainer_manifest) = + init_default_devcontainer_manifest(cx, r#"{"image": "image"}"#) + .await + .unwrap(); + test_dependencies + .docker + .set_duplicate_container_ids(vec!["abc123".to_string(), "def456".to_string()]); + + let result = devcontainer_manifest + .check_for_existing_devcontainer() + .await; + + let Err(DevContainerError::MultipleMatchingContainers(ids)) = result else { + panic!("expected MultipleMatchingContainers, got {result:?}"); + }; + assert_eq!(ids, vec!["abc123".to_string(), "def456".to_string()]); + } + #[test] fn test_aliases_dockerfile_with_pre_existing_aliases_for_build() {} @@ -4923,6 +5630,10 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de exec_commands_recorded: Mutex>, podman: bool, has_buildx: bool, + /// When `Some`, `find_process_by_filters` returns + /// `MultipleMatchingContainers` with these IDs. Used to exercise the + /// duplicate-container error path. + duplicate_container_ids: Mutex>>, } impl FakeDocker { @@ -4931,12 +5642,20 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de podman: false, has_buildx: true, exec_commands_recorded: Mutex::new(Vec::new()), + duplicate_container_ids: Mutex::new(None), } } #[cfg(not(target_os = "windows"))] fn set_podman(&mut self, podman: bool) { self.podman = podman; } + #[cfg(not(target_os = "windows"))] + fn set_duplicate_container_ids(&self, ids: Vec) { + *self + .duplicate_container_ids + .lock() + .expect("should be available") = Some(ids); + } } #[async_trait] @@ -5142,6 +5861,35 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de volumes: HashMap::new(), })); } + if config_files.len() == 1 + && config_files.get(0) + == Some( + &project_path + .join(".devcontainer") + .join("docker-compose-with-args.yml"), + ) + { + return Ok(Some(DockerComposeConfig { + name: None, + services: HashMap::from([( + "app".to_string(), + DockerComposeService { + build: Some(DockerComposeServiceBuild { + context: Some(".".to_string()), + dockerfile: Some("Dockerfile".to_string()), + args: Some(HashMap::from([( + "BASE_IMAGE".to_string(), + "test_image:latest".to_string(), + )])), + additional_contexts: None, + target: None, + }), + ..Default::default() + }, + )]), + ..Default::default() + })); + } if config_files.len() == 1 && config_files.get(0) == Some( @@ -5200,6 +5948,14 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de &self, _filters: Vec, ) -> Result, DevContainerError> { + if let Some(ids) = self + .duplicate_container_ids + .lock() + .expect("should be available") + .clone() + { + return Err(DevContainerError::MultipleMatchingContainers(ids)); + } Ok(Some(DockerPs { id: "found_docker_ps".to_string(), })) diff --git a/crates/dev_container/src/docker.rs b/crates/dev_container/src/docker.rs index 6fb5c88c1fff6e..be0fe0ed81b35d 100644 --- a/crates/dev_container/src/docker.rs +++ b/crates/dev_container/src/docker.rs @@ -379,8 +379,28 @@ impl DockerClient for Docker { &self, filters: Vec, ) -> Result, DevContainerError> { - let command = self.create_docker_query_containers(filters); - evaluate_json_command(command).await + let mut command = self.create_docker_query_containers(filters); + let output = command.output().await.map_err(|e| { + log::error!("Error running command {:?}: {e}", command); + DevContainerError::CommandFailed(command.get_program().display().to_string()) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log::error!("Non-success status from docker ps: {stderr}"); + return Err(DevContainerError::CommandFailed( + command.get_program().display().to_string(), + )); + } + let raw = String::from_utf8_lossy(&output.stdout); + parse_find_process_output(&raw).map_err(|e| { + // Preserve the dedicated multi-match error; log and re-wrap other parse failures. + if let DevContainerError::MultipleMatchingContainers(_) = &e { + e + } else { + log::error!("Error parsing docker ps output: {e}"); + DevContainerError::CommandFailed(command.get_program().display().to_string()) + } + }) } fn docker_cli(&self) -> String { @@ -392,6 +412,33 @@ impl DockerClient for Docker { } } +/// Parses output of `docker ps -a --format={{ json . }}`. When a single +/// container matches the label filters, docker emits one JSON object; when +/// multiple match, it emits newline-delimited JSON (one object per line). +/// +/// Returns `Ok(None)` for no matches, `Ok(Some(_))` for exactly one match, +/// and `DevContainerError::MultipleMatchingContainers` for ≥2 matches — the +/// spec expects identifying labels to be unique per project, so the caller +/// can't silently pick one. +fn parse_find_process_output(raw: &str) -> Result, DevContainerError> { + if raw.trim().is_empty() { + return Ok(None); + } + let containers: Vec = serde_json_lenient::Deserializer::from_str(raw) + .into_iter::() + .collect::>() + .map_err(|e| { + DevContainerError::CommandFailed(format!("failed to parse docker ps output: {e}")) + })?; + match containers.len() { + 0 => Ok(None), + 1 => Ok(containers.into_iter().next()), + _ => Err(DevContainerError::MultipleMatchingContainers( + containers.into_iter().map(|c| c.id).collect(), + )), + } +} + #[async_trait] pub(crate) trait DockerClient { async fn inspect(&self, id: &String) -> Result; @@ -532,10 +579,11 @@ mod test { use crate::{ command_json::deserialize_json_output, + devcontainer_api::DevContainerError, devcontainer_json::MountDefinition, docker::{ Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort, - DockerComposeVolume, DockerInspect, DockerPs, + DockerComposeVolume, DockerInspect, DockerPs, parse_find_process_output, }, }; @@ -718,6 +766,33 @@ mod test { assert_eq!(result.id, "abdb6ab59573".to_string()); } + #[test] + fn parse_find_process_output_none() { + assert!(matches!(parse_find_process_output(""), Ok(None))); + assert!(matches!(parse_find_process_output(" \n\n"), Ok(None))); + } + + #[test] + fn parse_find_process_output_single() { + let raw = r#"{"ID":"abc123"}"#; + let result = parse_find_process_output(raw).expect("single match must parse"); + assert_eq!(result.unwrap().id, "abc123"); + } + + #[test] + fn parse_find_process_output_multiple_errors() { + // `docker ps --format={{ json . }}` emits newline-delimited JSON when + // multiple containers match the filters. The spec expects the + // identifying labels to be unique per project, so this is an error. + let raw = "{\"ID\":\"abc\"}\n{\"ID\":\"def\"}\n"; + match parse_find_process_output(raw) { + Err(DevContainerError::MultipleMatchingContainers(ids)) => { + assert_eq!(ids, vec!["abc".to_string(), "def".to_string()]); + } + other => panic!("expected MultipleMatchingContainers, got {other:?}"), + } + } + #[test] fn should_deserialize_object_metadata_from_docker_compose_container() { // The devcontainer CLI writes metadata as a bare JSON object (not an array) diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index eaf414560845ea..21da60b5161ff2 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -206,7 +206,7 @@ impl DiagnosticBlock { (status_colors.warning_background, status_colors.warning) } DiagnosticSeverity::INFORMATION => (status_colors.info_background, status_colors.info), - DiagnosticSeverity::HINT => (status_colors.hint_background, status_colors.info), + DiagnosticSeverity::HINT => (status_colors.hint_background, status_colors.hint), _ => (status_colors.ignored_background, status_colors.ignored), }; let settings = ThemeSettings::get_global(cx); diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index af451d43268568..3eaae6a1d488ee 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -3,7 +3,7 @@ use mdbook::BookItem; use mdbook::book::{Book, Chapter}; use mdbook::preprocess::CmdPreprocessor; use regex::Regex; -use settings::{KeymapFile, SettingsStore}; +use settings::{KeymapFile, SettingsJsonSchemaParams, SettingsStore}; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::io::{self, Read}; @@ -369,7 +369,18 @@ fn find_binding_with_overlay( } fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { - let settings_schema = SettingsStore::json_schema(&Default::default()); + let params = SettingsJsonSchemaParams { + language_names: &[], + font_names: &[], + theme_names: &[], + icon_theme_names: &[], + lsp_adapter_names: &[], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), + }; + let settings_schema = SettingsStore::json_schema(¶ms); let settings_validator = jsonschema::validator_for(&settings_schema) .expect("failed to compile settings JSON schema"); diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index d61cba71922582..16ce0d65990552 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -353,7 +353,7 @@ impl ProjectState { drop(pending_prediction.task); } else { cx.spawn(async move |this, cx| { - let Some(prediction_id) = pending_prediction.task.await else { + let Some((prediction_id, model_version)) = pending_prediction.task.await else { return; }; @@ -362,7 +362,7 @@ impl ProjectState { prediction_id, EditPredictionRejectReason::Canceled, false, - None, + model_version, None, cx, ); @@ -460,7 +460,7 @@ pub enum DiagnosticSearchScope { #[derive(Debug)] struct PendingPrediction { id: usize, - task: Task>, + task: Task)>>, /// If true, the task is dropped immediately on cancel (cancelling the HTTP request). /// If false, the task is awaited to completion so rejection can be reported. drop_on_cancel: bool, @@ -2039,6 +2039,7 @@ impl EditPredictionStore { EditPredictionResult { id: prediction_result.id, prediction: Err(EditPredictionRejectReason::CurrentPreferred), + model_version: prediction_result.model_version, e2e_latency: prediction_result.e2e_latency, } }, @@ -2213,9 +2214,9 @@ impl EditPredictionStore { } let new_prediction_result = do_refresh(this.clone(), cx).await.log_err().flatten(); - let new_prediction_id = new_prediction_result + let new_prediction_metadata = new_prediction_result .as_ref() - .map(|(prediction, _)| prediction.id.clone()); + .map(|(prediction, _)| (prediction.id.clone(), prediction.model_version.clone())); // When a prediction completes, remove it from the pending list, and cancel // any pending predictions that were enqueued before it. @@ -2271,7 +2272,7 @@ impl EditPredictionStore { prediction_result.id, reject_reason, false, - None, + prediction_result.model_version, Some(prediction_result.e2e_latency), cx, ); @@ -2303,7 +2304,7 @@ impl EditPredictionStore { }) .ok(); - new_prediction_id + new_prediction_metadata }); if project_state.pending_predictions.len() < max_pending_predictions { diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 7a0c5f57992e19..0a8cd1b066adad 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1374,7 +1374,8 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { }); let (request, respond_tx) = requests.predict.next().await.unwrap(); - let response = model_response(&request, ""); + let mut response = model_response(&request, ""); + response.model_version = Some("zeta2:test-empty".to_string()); let id = response.request_id.clone(); respond_tx.send(response).unwrap(); @@ -1397,7 +1398,7 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { request_id: id, reason: EditPredictionRejectReason::Empty, was_shown: false, - model_version: None, + model_version: Some("zeta2:test-empty".to_string()), e2e_latency_ms: Some(0), }] ); @@ -1436,7 +1437,8 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { buffer.set_text("Hello!\nHow are you?\nBye", cx); }); - let response = model_response(&request, SIMPLE_DIFF); + let mut response = model_response(&request, SIMPLE_DIFF); + response.model_version = Some("zeta2:test-interpolated-empty".to_string()); let id = response.request_id.clone(); respond_tx.send(response).unwrap(); @@ -1459,7 +1461,7 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { request_id: id, reason: EditPredictionRejectReason::InterpolatedEmpty, was_shown: false, - model_version: None, + model_version: Some("zeta2:test-interpolated-empty".to_string()), e2e_latency_ms: Some(0), }] ); @@ -1611,7 +1613,7 @@ async fn test_current_preferred(cx: &mut TestAppContext) { let (request, respond_tx) = requests.predict.next().await.unwrap(); // worse than current prediction - let second_response = model_response( + let mut second_response = model_response( &request, indoc! { r" --- a/root/foo.md @@ -1623,6 +1625,7 @@ async fn test_current_preferred(cx: &mut TestAppContext) { Bye "}, ); + second_response.model_version = Some("zeta2:test-current-preferred".to_string()); let second_id = second_response.request_id.clone(); respond_tx.send(second_response).unwrap(); @@ -1649,7 +1652,7 @@ async fn test_current_preferred(cx: &mut TestAppContext) { request_id: second_id, reason: EditPredictionRejectReason::CurrentPreferred, was_shown: false, - model_version: None, + model_version: Some("zeta2:test-current-preferred".to_string()), e2e_latency_ms: Some(0), }] ); @@ -1713,7 +1716,8 @@ async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { ); }); - let first_response = model_response(&request1, SIMPLE_DIFF); + let mut first_response = model_response(&request1, SIMPLE_DIFF); + first_response.model_version = Some("zeta2:test-canceled".to_string()); let first_id = first_response.request_id.clone(); respond_first.send(first_response).unwrap(); @@ -1742,7 +1746,7 @@ async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { request_id: first_id, reason: EditPredictionRejectReason::Canceled, was_shown: false, - model_version: None, + model_version: Some("zeta2:test-canceled".to_string()), e2e_latency_ms: None, }] ); @@ -1826,7 +1830,8 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { ); }); - let cancelled_response = model_response(&request2, SIMPLE_DIFF); + let mut cancelled_response = model_response(&request2, SIMPLE_DIFF); + cancelled_response.model_version = Some("zeta2:test-canceled-second".to_string()); let cancelled_id = cancelled_response.request_id.clone(); respond_second.send(cancelled_response).unwrap(); @@ -1874,7 +1879,7 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { request_id: cancelled_id, reason: EditPredictionRejectReason::Canceled, was_shown: false, - model_version: None, + model_version: Some("zeta2:test-canceled-second".to_string()), e2e_latency_ms: None, }, EditPredictionRejection { diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index eb45832d4cccad..b115ad795b12cb 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -25,6 +25,7 @@ impl std::fmt::Display for EditPredictionId { pub struct EditPredictionResult { pub id: EditPredictionId, pub prediction: Result, + pub model_version: Option, pub e2e_latency: std::time::Duration, } @@ -43,8 +44,9 @@ impl EditPredictionResult { if edits.is_empty() { return Self { id, - e2e_latency, prediction: Err(EditPredictionRejectReason::Empty), + model_version, + e2e_latency, }; } @@ -59,8 +61,9 @@ impl EditPredictionResult { else { return Self { id, - e2e_latency, prediction: Err(EditPredictionRejectReason::InterpolatedEmpty), + model_version, + e2e_latency, }; }; @@ -68,7 +71,6 @@ impl EditPredictionResult { Self { id: id.clone(), - e2e_latency, prediction: Ok(EditPrediction { id, edits, @@ -77,8 +79,10 @@ impl EditPredictionResult { edit_preview, inputs, buffer: edited_buffer.clone(), - model_version, + model_version: model_version.clone(), }), + model_version, + e2e_latency, } } } diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 60157781eb7695..7b12453353478d 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -102,7 +102,6 @@ pub fn request_prediction_with_zeta( edits: Vec<(Range, Arc)>, cursor_position: Option, editable_range_in_buffer: Range, - model_version: Option, } let request_task = cx.background_spawn({ @@ -305,7 +304,7 @@ pub fn request_prediction_with_zeta( cursor_offset_in_new_editable_region: cursor_offset_in_output, }) = output else { - return Ok((Some((request_id, None)), None)); + return Ok((Some((request_id, None, model_version)), None)); }; let editable_range_in_buffer = editable_range_in_excerpt.start @@ -343,26 +342,23 @@ pub fn request_prediction_with_zeta( &snapshot, ); - anyhow::Ok(( - Some(( - request_id, - Some(Prediction { - prompt_input, - buffer, - snapshot: snapshot.clone(), - edits, - cursor_position, - editable_range_in_buffer, - model_version, - }), - )), - usage, - )) + let prediction = Some(Prediction { + prompt_input, + buffer, + snapshot: snapshot.clone(), + edits, + cursor_position, + editable_range_in_buffer, + }); + + anyhow::Ok((Some((request_id, prediction, model_version)), usage)) } }); cx.spawn(async move |this, cx| { - let Some((id, prediction)) = handle_api_response(&this, request_task.await, cx)? else { + let Some((id, prediction, model_version)) = + handle_api_response(&this, request_task.await, cx)? + else { return Ok(None); }; let request_duration = cx.background_executor().now() - request_start; @@ -374,13 +370,14 @@ pub fn request_prediction_with_zeta( edits, cursor_position, editable_range_in_buffer, - model_version, + .. }) = prediction else { return Ok(Some(EditPredictionResult { id, - e2e_latency: request_duration, prediction: Err(EditPredictionRejectReason::Empty), + model_version, + e2e_latency: request_duration, })); }; diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index ae0d60ecb508f3..91f6aebe0faf5a 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -7,15 +7,22 @@ use crate::{ }; use anyhow::{Context as _, Result, anyhow}; use gpui::AsyncApp; -use similar::DiffableStr; use std::ops::Range; use std::sync::Arc; -use zeta_prompt::udiff; use zeta_prompt::{ - ZetaFormat, encode_patch_as_output_for_format, excerpt_range_for_format, format_zeta_prompt, - multi_region, output_end_marker_for_format, resolve_cursor_region, + ZetaFormat, format_expected_output, format_zeta_prompt, multi_region, resolve_cursor_region, }; +fn resolved_excerpt_ranges_for_format( + input: &zeta_prompt::ZetaPromptInput, + format: ZetaFormat, +) -> (Range, Range) { + let (_, editable_range_in_context, context_range, _) = resolve_cursor_region(input, format); + let editable_range = (context_range.start + editable_range_in_context.start) + ..(context_range.start + editable_range_in_context.end); + (editable_range, context_range) +} + pub async fn run_format_prompt( example: &mut Example, args: &FormatPromptArgs, @@ -33,12 +40,12 @@ pub async fn run_format_prompt( .context("prompt_inputs must be set after context retrieval")?; match args.provider { - PredictionProvider::Teacher(_) | PredictionProvider::TeacherNonBatching(_) => { + PredictionProvider::Teacher(_, zeta_format) + | PredictionProvider::TeacherNonBatching(_, zeta_format) => { step_progress.set_substatus("formatting teacher prompt"); - let zeta_format = ZetaFormat::default(); let (editable_range, context_range) = - excerpt_range_for_format(zeta_format, &prompt_inputs.excerpt_ranges); + resolved_excerpt_ranges_for_format(prompt_inputs, zeta_format); let prompt = TeacherPrompt::format_prompt(example, editable_range, context_range); example.prompt = Some(ExamplePrompt { @@ -55,7 +62,7 @@ pub async fn run_format_prompt( let zeta_format = ZetaFormat::default(); let (editable_range, context_range) = - excerpt_range_for_format(zeta_format, &prompt_inputs.excerpt_ranges); + resolved_excerpt_ranges_for_format(prompt_inputs, zeta_format); let prompt = TeacherMultiRegionPrompt::format_prompt(example, editable_range, context_range); @@ -78,17 +85,17 @@ pub async fn run_format_prompt( .into_iter() .next() .and_then(|(expected_patch, expected_cursor_offset)| { - zeta2_output_for_patch( + format_expected_output( prompt_inputs, + zeta_format, &expected_patch, expected_cursor_offset, - zeta_format, ) .ok() }); let rejected_output = example.spec.rejected_patch.as_ref().and_then(|patch| { - zeta2_output_for_patch(prompt_inputs, patch, None, zeta_format).ok() + format_expected_output(prompt_inputs, zeta_format, patch, None).ok() }); example.prompt = prompt.map(|prompt| ExamplePrompt { @@ -106,112 +113,6 @@ pub async fn run_format_prompt( Ok(()) } -pub fn zeta2_output_for_patch( - input: &zeta_prompt::ZetaPromptInput, - patch: &str, - cursor_offset: Option, - version: ZetaFormat, -) -> Result { - let (context, editable_range, _, _) = resolve_cursor_region(input, version); - let mut old_editable_region = context[editable_range].to_string(); - - if !old_editable_region.ends_with_newline() { - old_editable_region.push('\n'); - } - - if let Some(encoded_output) = - encode_patch_as_output_for_format(version, &old_editable_region, patch, cursor_offset)? - { - return Ok(encoded_output); - } - - let (result, first_hunk_offset) = - udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable_region).with_context( - || { - format!( - "Patch:\n```\n{}```\n\nEditable region:\n```\n{}```", - patch, old_editable_region - ) - }, - )?; - - if version == ZetaFormat::V0317SeedMultiRegions { - let cursor_in_new = cursor_offset.map(|cursor_offset| { - let hunk_start = first_hunk_offset.unwrap_or(0); - result.floor_char_boundary((hunk_start + cursor_offset).min(result.len())) - }); - return multi_region::encode_from_old_and_new_v0317( - &old_editable_region, - &result, - cursor_in_new, - zeta_prompt::CURSOR_MARKER, - multi_region::V0317_END_MARKER, - ); - } - - if version == ZetaFormat::V0318SeedMultiRegions { - let cursor_in_new = cursor_offset.map(|cursor_offset| { - let hunk_start = first_hunk_offset.unwrap_or(0); - result.floor_char_boundary((hunk_start + cursor_offset).min(result.len())) - }); - return multi_region::encode_from_old_and_new_v0318( - &old_editable_region, - &result, - cursor_in_new, - zeta_prompt::CURSOR_MARKER, - multi_region::V0318_END_MARKER, - ); - } - - if version == ZetaFormat::V0316SeedMultiRegions { - let cursor_in_new = cursor_offset.map(|cursor_offset| { - let hunk_start = first_hunk_offset.unwrap_or(0); - result.floor_char_boundary((hunk_start + cursor_offset).min(result.len())) - }); - return multi_region::encode_from_old_and_new_v0316( - &old_editable_region, - &result, - cursor_in_new, - zeta_prompt::CURSOR_MARKER, - multi_region::V0316_END_MARKER, - ); - } - - if version == ZetaFormat::V0306SeedMultiRegions { - let cursor_in_new = cursor_offset.map(|cursor_offset| { - let hunk_start = first_hunk_offset.unwrap_or(0); - result.floor_char_boundary((hunk_start + cursor_offset).min(result.len())) - }); - return multi_region::encode_from_old_and_new( - &old_editable_region, - &result, - cursor_in_new, - zeta_prompt::CURSOR_MARKER, - zeta_prompt::seed_coder::END_MARKER, - zeta_prompt::seed_coder::NO_EDITS, - ); - } - - let mut result = result; - if let Some(cursor_offset) = cursor_offset { - // The cursor_offset is relative to the start of the hunk's new text (context + additions). - // We need to add where the hunk context matched in the editable region to compute - // the actual cursor position in the result. - let hunk_start = first_hunk_offset.unwrap_or(0); - let offset = result.floor_char_boundary((hunk_start + cursor_offset).min(result.len())); - result.insert_str(offset, zeta_prompt::CURSOR_MARKER); - } - - if let Some(end_marker) = output_end_marker_for_format(version) { - if !result.ends_with('\n') { - result.push('\n'); - } - result.push_str(end_marker); - } - - Ok(result) -} - pub struct TeacherPrompt; impl TeacherPrompt { @@ -440,8 +341,7 @@ impl TeacherMultiRegionPrompt { .context("example is missing prompt inputs")?; let zeta_format = ZetaFormat::default(); - let (editable_range, _) = - excerpt_range_for_format(zeta_format, &prompt_inputs.excerpt_ranges); + let (editable_range, _) = resolved_excerpt_ranges_for_format(prompt_inputs, zeta_format); let excerpt = prompt_inputs.cursor_excerpt.as_ref(); let old_editable_region = &excerpt[editable_range.clone()]; let marker_offsets = multi_region::compute_marker_offsets(old_editable_region); @@ -926,4 +826,84 @@ mod tests { assert!(parsed.0.is_empty()); assert!(parsed.1.is_none()); } + + #[test] + fn test_v0327_teacher_prompt_uses_resolved_ranges() { + let excerpt = (0..80) + .map(|index| format!("line{index:02}\n")) + .collect::(); + let cursor_offset = excerpt.find("line40").expect("cursor line exists"); + let prompt_inputs = zeta_prompt::ZetaPromptInput { + cursor_path: std::path::Path::new("src/main.rs").into(), + cursor_excerpt: excerpt.clone().into(), + cursor_offset_in_excerpt: cursor_offset, + excerpt_start_row: None, + events: Vec::new(), + related_files: Some(Vec::new()), + active_buffer_diagnostics: Vec::new(), + excerpt_ranges: zeta_prompt::ExcerptRanges { + editable_150: 0..32, + editable_180: 0..32, + editable_350: 0..32, + editable_512: None, + editable_150_context_350: 0..48, + editable_180_context_350: 0..48, + editable_350_context_150: 20..50, + editable_350_context_512: None, + editable_350_context_1024: None, + context_4096: None, + context_8192: Some(30..excerpt.len()), + }, + syntax_ranges: None, + in_open_source_repo: false, + can_collect_data: false, + repo_url: None, + }; + + let (stored_editable_range, stored_context_range) = zeta_prompt::excerpt_range_for_format( + ZetaFormat::V0327SingleFile, + &prompt_inputs.excerpt_ranges, + ); + assert!(stored_context_range.start > stored_editable_range.start); + + let (editable_range, context_range) = + resolved_excerpt_ranges_for_format(&prompt_inputs, ZetaFormat::V0327SingleFile); + assert_eq!(context_range, 0..excerpt.len()); + assert!(editable_range.start < cursor_offset); + assert!(editable_range.end > cursor_offset); + + let prompt = TeacherPrompt::format_prompt( + &Example { + spec: edit_prediction::example_spec::ExampleSpec { + name: "test".to_string(), + repository_url: "https://github.com/zed-industries/zed.git".to_string(), + revision: "HEAD".to_string(), + tags: Vec::new(), + reasoning: None, + uncommitted_diff: String::new(), + cursor_path: std::sync::Arc::from(std::path::Path::new("src/main.rs")), + cursor_position: "0:0".to_string(), + edit_history: String::new(), + expected_patches: Vec::new(), + rejected_patch: None, + telemetry: None, + human_feedback: Vec::new(), + rating: None, + }, + prompt_inputs: Some(prompt_inputs), + prompt: None, + predictions: Vec::new(), + score: Vec::new(), + qa: Vec::new(), + zed_version: None, + state: None, + }, + editable_range, + context_range, + ); + + assert!(prompt.contains(TeacherPrompt::EDITABLE_REGION_START)); + assert!(prompt.contains(TeacherPrompt::USER_CURSOR_MARKER)); + assert!(prompt.contains("line40")); + } } diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index d144f998ff27b9..b4951ae9d9f117 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -46,6 +46,7 @@ use std::fmt::Display; use std::fs::{File, OpenOptions}; use std::hash::{Hash, Hasher}; use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::str::FromStr; use std::sync::Mutex; use std::{path::PathBuf, sync::Arc}; @@ -363,9 +364,9 @@ enum PredictionProvider { Zeta1, Zeta2(ZetaFormat), Baseten(ZetaFormat), - Teacher(TeacherBackend), + Teacher(TeacherBackend, ZetaFormat), TeacherMultiRegion(TeacherBackend), - TeacherNonBatching(TeacherBackend), + TeacherNonBatching(TeacherBackend, ZetaFormat), TeacherMultiRegionNonBatching(TeacherBackend), Repair, } @@ -383,12 +384,14 @@ impl std::fmt::Display for PredictionProvider { PredictionProvider::Zeta1 => write!(f, "zeta1"), PredictionProvider::Zeta2(format) => write!(f, "zeta2:{format}"), PredictionProvider::Baseten(format) => write!(f, "baseten:{format}"), - PredictionProvider::Teacher(backend) => write!(f, "teacher:{backend}"), + PredictionProvider::Teacher(backend, format) => { + write!(f, "teacher:{backend}:{format:?}") + } PredictionProvider::TeacherMultiRegion(backend) => { write!(f, "teacher-multi-region:{backend}") } - PredictionProvider::TeacherNonBatching(backend) => { - write!(f, "teacher-non-batching:{backend}") + PredictionProvider::TeacherNonBatching(backend, format) => { + write!(f, "teacher-non-batching:{backend}:{format:?}") } PredictionProvider::TeacherMultiRegionNonBatching(backend) => { write!(f, "teacher-multi-region-non-batching:{backend}") @@ -413,11 +416,12 @@ impl std::str::FromStr for PredictionProvider { Ok(PredictionProvider::Zeta2(format)) } "teacher" => { - let backend = arg - .map(|a| a.parse()) - .transpose()? - .unwrap_or(TeacherBackend::default()); - Ok(PredictionProvider::Teacher(backend)) + let (backend, format) = parse_teacher_args(arg)?; + Ok(PredictionProvider::Teacher(backend, format)) + } + "teacher-non-batching" | "teacher_non_batching" => { + let (backend, format) = parse_teacher_args(arg)?; + Ok(PredictionProvider::TeacherNonBatching(backend, format)) } "teacher-multi-region" | "teacher_multi_region" => { let backend = arg @@ -426,13 +430,6 @@ impl std::str::FromStr for PredictionProvider { .unwrap_or(TeacherBackend::default()); Ok(PredictionProvider::TeacherMultiRegion(backend)) } - "teacher-non-batching" | "teacher_non_batching" => { - let backend = arg - .map(|a| a.parse()) - .transpose()? - .unwrap_or(TeacherBackend::default()); - Ok(PredictionProvider::TeacherNonBatching(backend)) - } "teacher-multi-region-non-batching" | "teacher_multi_region_non_batching" => { let backend = arg .map(|a| a.parse()) @@ -461,6 +458,27 @@ impl std::str::FromStr for PredictionProvider { } } +fn parse_teacher_args(arg: Option<&str>) -> Result<(TeacherBackend, ZetaFormat), anyhow::Error> { + let mut backend = TeacherBackend::default(); + let mut format = ZetaFormat::default(); + + for arg in arg.unwrap_or_default().split(':') { + if arg.is_empty() { + continue; + } + + if let Ok(parsed_backend) = TeacherBackend::from_str(arg) { + backend = parsed_backend; + } else if let Ok(parsed_format) = ZetaFormat::parse(arg) { + format = parsed_format; + } else { + anyhow::bail!("unknown teacher backend or zeta format `{arg}`"); + } + } + + Ok((backend, format)) +} + impl Serialize for PredictionProvider { fn serialize(&self, serializer: S) -> Result where diff --git a/crates/edit_prediction_cli/src/openai_client.rs b/crates/edit_prediction_cli/src/openai_client.rs index e35848aa1ccbd4..205b339226f34d 100644 --- a/crates/edit_prediction_cli/src/openai_client.rs +++ b/crates/edit_prediction_cli/src/openai_client.rs @@ -485,6 +485,7 @@ impl BatchingOpenAiClient { "assistant" => RequestMessage::Assistant { content: Some(MessageContent::Plain(msg.content)), tool_calls: Vec::new(), + reasoning_content: None, }, "system" => RequestMessage::System { content: MessageContent::Plain(msg.content), diff --git a/crates/edit_prediction_cli/src/parse_output.rs b/crates/edit_prediction_cli/src/parse_output.rs index fc85afa371a4ed..c8e0fa7568cb2b 100644 --- a/crates/edit_prediction_cli/src/parse_output.rs +++ b/crates/edit_prediction_cli/src/parse_output.rs @@ -37,7 +37,7 @@ pub fn parse_prediction_output( provider: PredictionProvider, ) -> Result<(String, Option)> { match provider { - PredictionProvider::Teacher(_) | PredictionProvider::TeacherNonBatching(_) => { + PredictionProvider::Teacher(_, _) | PredictionProvider::TeacherNonBatching(_, _) => { TeacherPrompt::parse(example, actual_output) } PredictionProvider::TeacherMultiRegion(_) diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 99d90f0f4e5242..c925527feb65fd 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -57,10 +57,16 @@ pub async fn run_prediction( ); }; - if let PredictionProvider::Teacher(backend) - | PredictionProvider::TeacherMultiRegion(backend) - | PredictionProvider::TeacherNonBatching(backend) - | PredictionProvider::TeacherMultiRegionNonBatching(backend) = provider + if matches!( + provider, + PredictionProvider::TeacherMultiRegion(..) + | PredictionProvider::TeacherMultiRegionNonBatching(..) + ) { + anyhow::bail!("Teacher multi-region providers are not supported for prediction."); + } + + if let PredictionProvider::Teacher(backend, _) + | PredictionProvider::TeacherNonBatching(backend, _) = provider { run_context_retrieval(example, app_state.clone(), example_progress, cx.clone()).await?; run_format_prompt( @@ -416,14 +422,14 @@ async fn predict_anthropic( .prompt .as_ref() .map(|prompt| prompt.provider) - .unwrap_or(PredictionProvider::Teacher(backend)) + .unwrap_or(PredictionProvider::Teacher(backend, ZetaFormat::default())) } else { match example.prompt.as_ref().map(|prompt| prompt.provider) { Some(PredictionProvider::TeacherMultiRegion(_)) | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { PredictionProvider::TeacherMultiRegionNonBatching(backend) } - _ => PredictionProvider::TeacherNonBatching(backend), + _ => PredictionProvider::TeacherNonBatching(backend, ZetaFormat::default()), } }; @@ -445,7 +451,7 @@ async fn predict_anthropic( Some(PredictionProvider::TeacherMultiRegion(_)) => { PredictionProvider::TeacherMultiRegion(backend) } - _ => PredictionProvider::Teacher(backend), + _ => PredictionProvider::Teacher(backend, ZetaFormat::default()), } } else { match example.prompt.as_ref().map(|prompt| prompt.provider) { @@ -453,7 +459,7 @@ async fn predict_anthropic( | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { PredictionProvider::TeacherMultiRegionNonBatching(backend) } - _ => PredictionProvider::TeacherNonBatching(backend), + _ => PredictionProvider::TeacherNonBatching(backend, ZetaFormat::default()), } }, cumulative_logprob: None, @@ -535,14 +541,14 @@ async fn predict_openai( .prompt .as_ref() .map(|prompt| prompt.provider) - .unwrap_or(PredictionProvider::Teacher(backend)) + .unwrap_or(PredictionProvider::Teacher(backend, ZetaFormat::default())) } else { match example.prompt.as_ref().map(|prompt| prompt.provider) { Some(PredictionProvider::TeacherMultiRegion(_)) | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { PredictionProvider::TeacherMultiRegionNonBatching(backend) } - _ => PredictionProvider::TeacherNonBatching(backend), + _ => PredictionProvider::TeacherNonBatching(backend, ZetaFormat::default()), } }; @@ -564,7 +570,7 @@ async fn predict_openai( Some(PredictionProvider::TeacherMultiRegion(_)) => { PredictionProvider::TeacherMultiRegion(backend) } - _ => PredictionProvider::Teacher(backend), + _ => PredictionProvider::Teacher(backend, ZetaFormat::default()), } } else { match example.prompt.as_ref().map(|prompt| prompt.provider) { @@ -572,7 +578,7 @@ async fn predict_openai( | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { PredictionProvider::TeacherMultiRegionNonBatching(backend) } - _ => PredictionProvider::TeacherNonBatching(backend), + _ => PredictionProvider::TeacherNonBatching(backend, ZetaFormat::default()), } }, cumulative_logprob: None, @@ -671,7 +677,7 @@ pub async fn predict_baseten( pub async fn sync_batches(provider: Option<&PredictionProvider>) -> anyhow::Result<()> { match provider { - Some(PredictionProvider::Teacher(backend)) + Some(PredictionProvider::Teacher(backend, _)) | Some(PredictionProvider::TeacherMultiRegion(backend)) => match backend { TeacherBackend::Sonnet45 | TeacherBackend::Sonnet46 => { let llm_client = ANTHROPIC_CLIENT.get_or_init(|| { @@ -703,7 +709,7 @@ pub async fn reprocess_after_batch_wait( examples: &mut [Example], args: &PredictArgs, ) -> anyhow::Result<()> { - let Some(PredictionProvider::Teacher(backend)) = args.provider else { + let Some(PredictionProvider::Teacher(backend, _)) = args.provider else { return Ok(()); }; @@ -762,7 +768,7 @@ pub async fn wait_for_batches(provider: Option<&PredictionProvider>) -> anyhow:: fn pending_batch_count(provider: Option<&PredictionProvider>) -> anyhow::Result { match provider { - Some(PredictionProvider::Teacher(backend)) => match backend { + Some(PredictionProvider::Teacher(backend, _)) => match backend { TeacherBackend::Sonnet45 | TeacherBackend::Sonnet46 => { let llm_client = ANTHROPIC_CLIENT.get_or_init(|| { AnthropicClient::batch(&crate::paths::LLM_CACHE_DB) diff --git a/crates/edit_prediction_cli/src/repair.rs b/crates/edit_prediction_cli/src/repair.rs index e8fb36eae28bc6..2ae62fd70f89ba 100644 --- a/crates/edit_prediction_cli/src/repair.rs +++ b/crates/edit_prediction_cli/src/repair.rs @@ -134,6 +134,18 @@ fn build_score_feedback(example: &Example) -> Option { ); } + if score.discarded_chars.unwrap_or(0) > 80 && score.exact_lines_fp > 5 { + issues.push( + "Automated analysis detected that this prediction might be too large or speculative. \ + Please review it and think if we should keep it or generate a more focused prediction. \ + Examples of more focused predictions: \ + - Predicting a function outline but not its body. \ + - Predicting only the first logical step and not speculating about further steps. + In general, the smaller the prediction you make, the higher the chance it will be correct." + .to_string(), + ); + } + if issues.is_empty() { return None; } @@ -376,6 +388,7 @@ pub async fn run_repair( open_ai::RequestMessage::Assistant { content: Some(open_ai::MessageContent::Plain(teacher_response.clone())), tool_calls: vec![], + reasoning_content: None, }, // Turn 3: Repair critique and instructions open_ai::RequestMessage::User { @@ -525,6 +538,7 @@ mod tests { use crate::{PredictionProvider, TeacherBackend}; use edit_prediction::example_spec::ExampleSpec; use std::{path::Path, sync::Arc}; + use zeta_prompt::ZetaFormat; fn example_with_previous_prediction() -> Example { Example { @@ -557,7 +571,10 @@ mod tests { editable_region_offset: Some(4), }), error: None, - provider: PredictionProvider::Teacher(TeacherBackend::Sonnet45), + provider: PredictionProvider::Teacher( + TeacherBackend::Sonnet45, + ZetaFormat::default(), + ), cumulative_logprob: None, avg_logprob: None, }], diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs index 38329c8c3329fa..5e7721e84f7892 100644 --- a/crates/edit_prediction_cli/src/score.rs +++ b/crates/edit_prediction_cli/src/score.rs @@ -52,7 +52,7 @@ pub async fn run_scoring( let old_editable_region = if let Some(p) = example.prompt.as_ref() { if matches!( p.provider, - PredictionProvider::Teacher(_) | PredictionProvider::TeacherNonBatching(_) + PredictionProvider::Teacher(_, _) | PredictionProvider::TeacherNonBatching(_, _) ) { Some( TeacherPrompt::extract_editable_region(&p.input)? diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index a44ff8b2e3e873..261d3383d0a4a4 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -66,10 +66,14 @@ struct Identifier { enum DefinitionTask { CacheHit(Arc), - CacheMiss { - definitions: Task>>>, - type_definitions: Task>>>, - }, + CacheMiss( + Task< + Option<( + Task>>>, + Task>>>, + )>, + >, + ), } #[derive(Debug)] @@ -270,39 +274,37 @@ impl RelatedExcerptStore { let futures = this.update(cx, |this, cx| { identifiers_with_distance .into_iter() - .filter_map(|(identifier, _)| { + .map(|(identifier, _)| { let task = if let Some(entry) = this.cache.get(&identifier) { DefinitionTask::CacheHit(entry.clone()) } else { - let definitions = this - .project - .update(cx, |project, cx| { - project.definitions(&buffer, identifier.range.start, cx) - }) - .ok()?; - let type_definitions = this - .project - .update(cx, |project, cx| { - project.type_definitions(&buffer, identifier.range.start, cx) - }) - .ok()?; - DefinitionTask::CacheMiss { - definitions, - type_definitions, - } + let project = this.project.clone(); + let buffer = buffer.downgrade(); + DefinitionTask::CacheMiss(cx.spawn(async move |_, cx| { + let buffer = buffer.upgrade()?; + let definitions = project + .update(cx, |project, cx| { + project.definitions(&buffer, identifier.range.start, cx) + }) + .ok()?; + let type_definitions = project + .update(cx, |project, cx| { + project.type_definitions(&buffer, identifier.range.start, cx) + }) + .ok()?; + Some((definitions, type_definitions)) + })) }; let cx = async_cx.clone(); let project = project.clone(); - Some(async move { + async move { match task { DefinitionTask::CacheHit(cache_entry) => { Some((identifier, cache_entry, None)) } - DefinitionTask::CacheMiss { - definitions, - type_definitions, - } => { + DefinitionTask::CacheMiss(task) => { + let (definitions, type_definitions) = task.await?; let (definition_locations, type_definition_locations) = futures::join!(definitions, type_definitions); let duration = start_time.elapsed(); @@ -349,7 +351,7 @@ impl RelatedExcerptStore { })) } } - }) + } }) .collect::>() })?; diff --git a/crates/edit_prediction_metrics/Cargo.toml b/crates/edit_prediction_metrics/Cargo.toml index 02181ca3d2456d..62184f2f6f93da 100644 --- a/crates/edit_prediction_metrics/Cargo.toml +++ b/crates/edit_prediction_metrics/Cargo.toml @@ -14,6 +14,7 @@ path = "src/edit_prediction_metrics.rs" [dependencies] language.workspace = true serde.workspace = true +serde_json = "1.0" similar = "2.7.0" tree-sitter.workspace = true zeta_prompt.workspace = true diff --git a/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs b/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs index 4fbaaf71331c28..3afe02fd083076 100644 --- a/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs +++ b/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs @@ -4,9 +4,10 @@ mod reversal; mod tokenize; mod tree_sitter; +pub use kept_rate::AnnotatedToken; pub use kept_rate::KeptRateResult; -#[cfg(test)] pub use kept_rate::TokenAnnotation; +pub use kept_rate::annotate_kept_rate_tokens; pub use kept_rate::compute_kept_rate; pub use patch_metrics::ClassificationMetrics; pub use patch_metrics::Counts; @@ -20,5 +21,6 @@ pub use patch_metrics::exact_lines_match; pub use patch_metrics::extract_changed_lines_from_diff; pub use patch_metrics::has_isolated_whitespace_changes; pub use patch_metrics::is_editable_region_correct; +pub use patch_metrics::reconstruct_texts_from_diff; pub use reversal::compute_prediction_reversal_ratio_from_history; pub use tree_sitter::count_tree_sitter_errors; diff --git a/crates/edit_prediction_metrics/src/kept_rate.rs b/crates/edit_prediction_metrics/src/kept_rate.rs index 117ab743c2b0ef..262c0f85c14e7f 100644 --- a/crates/edit_prediction_metrics/src/kept_rate.rs +++ b/crates/edit_prediction_metrics/src/kept_rate.rs @@ -3,14 +3,20 @@ use serde::Serialize; const MAX_DIRTY_LENGTH_DELTA_CHARS: usize = 512; -#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] pub enum TokenAnnotation { Context, Kept, Discarded, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AnnotatedToken { + pub token: String, + pub annotation: TokenAnnotation, +} + #[allow(dead_code)] #[derive(Debug, Clone, Serialize)] pub struct KeptRateResult { @@ -40,8 +46,7 @@ pub struct KeptRateResult { /// This includes both kept newly introduced characters and correctly /// deleted base characters. pub recall_rate: f64, - /// Per-token classification for candidate tokens used by tests. - #[cfg(test)] + /// Per-token classification for candidate tokens. pub token_annotations: Vec, } @@ -51,9 +56,9 @@ fn dp_index(width: usize, row: usize, column: usize) -> usize { /// Fill masks over `a` and `b` using one-sided LCS tie-breaking for each side /// while sharing a single DP table construction. -fn fill_lcs_keep_masks( - a: &[&str], - b: &[&str], +fn fill_lcs_keep_masks( + a: &[T], + b: &[T], mut keep_a: Option<&mut [bool]>, mut keep_b: Option<&mut [bool]>, ) { @@ -124,10 +129,10 @@ fn fill_lcs_keep_masks( let mut dp = vec![0u32; row_count * column_count]; for i in 1..row_count { - let token_a = a_mid[i - 1]; + let token_a = &a_mid[i - 1]; for j in 1..column_count { let index = dp_index(column_count, i, j); - if token_a == b_mid[j - 1] { + if token_a == &b_mid[j - 1] { dp[index] = dp[dp_index(column_count, i - 1, j - 1)] + 1; } else { let up = dp[dp_index(column_count, i - 1, j)]; @@ -180,41 +185,91 @@ fn fill_lcs_keep_masks( } } -fn lcs_keep_mask(a: &[&str], b: &[&str]) -> Vec { +fn lcs_keep_mask(a: &[T], b: &[T]) -> Vec { let mut keep_a = vec![false; a.len()]; fill_lcs_keep_masks(a, b, Some(&mut keep_a), None); keep_a } -fn lcs_keep_masks(a: &[&str], b: &[&str]) -> (Vec, Vec) { +fn lcs_keep_masks(a: &[T], b: &[T]) -> (Vec, Vec) { let mut keep_a = vec![false; a.len()]; let mut keep_b = vec![false; b.len()]; fill_lcs_keep_masks(a, b, Some(&mut keep_a), Some(&mut keep_b)); (keep_a, keep_b) } -fn analyze_masked_tokens<'a>(tokens: &[&'a str], mask: &[bool]) -> (Vec<&'a str>, usize, usize) { - let mut unmasked_tokens = Vec::with_capacity(tokens.len()); +#[derive(Debug, Clone)] +struct ComparisonUnit { + text: String, + token_start: usize, + token_end: usize, +} + +fn is_identifier_token(token: &str) -> bool { + !token.is_empty() + && token + .chars() + .all(|character| character.is_alphanumeric() || character == '_') +} + +fn build_comparison_units(tokens: &[&str]) -> Vec { + let mut units = Vec::new(); + let mut index = 0; + + while index < tokens.len() { + let token_start = index; + + if is_identifier_token(tokens[index]) { + let mut text = String::new(); + + while index < tokens.len() && is_identifier_token(tokens[index]) { + text.push_str(tokens[index]); + index += 1; + } + + units.push(ComparisonUnit { + text, + token_start, + token_end: index, + }); + } else { + units.push(ComparisonUnit { + text: tokens[index].to_string(), + token_start, + token_end: index + 1, + }); + index += 1; + } + } + + units +} + +fn analyze_masked_units<'a>( + units: &'a [ComparisonUnit], + mask: &[bool], +) -> (Vec<&'a str>, usize, usize) { + let mut unmasked_units = Vec::with_capacity(units.len()); let mut unmasked_chars = 0; let mut masked_chars = 0; - for (&token, &is_masked) in tokens.iter().zip(mask.iter()) { + for (unit, &is_masked) in units.iter().zip(mask.iter()) { if is_masked { - masked_chars += token.len(); + masked_chars += unit.text.len(); } else { - unmasked_tokens.push(token); - unmasked_chars += token.len(); + unmasked_units.push(unit.text.as_str()); + unmasked_chars += unit.text.len(); } } - (unmasked_tokens, unmasked_chars, masked_chars) + (unmasked_units, unmasked_chars, masked_chars) } -fn count_unmasked_chars(tokens: &[&str], mask: &[bool]) -> usize { - tokens +fn count_unmasked_unit_chars(units: &[ComparisonUnit], mask: &[bool]) -> usize { + units .iter() .zip(mask.iter()) - .filter_map(|(&token, &is_masked)| (!is_masked).then_some(token.len())) + .filter_map(|(unit, &is_masked)| (!is_masked).then_some(unit.text.len())) .sum() } @@ -239,7 +294,6 @@ pub fn compute_kept_rate(base: &str, candidate: &str, reference: &str) -> KeptRa context_chars, kept_rate: 1.0, recall_rate: 1.0, - #[cfg(test)] token_annotations: vec![TokenAnnotation::Context; candidate_tokens.len()], }; } @@ -258,7 +312,6 @@ pub fn compute_kept_rate(base: &str, candidate: &str, reference: &str) -> KeptRa context_chars: 0, kept_rate: 0.0, recall_rate: 0.0, - #[cfg(test)] token_annotations: vec![TokenAnnotation::Discarded; tokenize(candidate).len()], }; } @@ -267,29 +320,29 @@ pub fn compute_kept_rate(base: &str, candidate: &str, reference: &str) -> KeptRa let candidate_tokens = tokenize(candidate); let reference_tokens = tokenize(reference); - let (candidate_base_mask, base_candidate_mask) = - lcs_keep_masks(&candidate_tokens, &base_tokens); - let (candidate_reference_mask, reference_candidate_mask) = - lcs_keep_masks(&candidate_tokens, &reference_tokens); - let context_mask: Vec = candidate_base_mask + let candidate_units = build_comparison_units(&candidate_tokens); + let base_units = build_comparison_units(&base_tokens); + let reference_units = build_comparison_units(&reference_tokens); + + let candidate_unit_texts: Vec<&str> = candidate_units .iter() - .zip(candidate_reference_mask.iter()) - .map(|(&in_base, &in_reference)| in_base && in_reference) + .map(|unit| unit.text.as_str()) + .collect(); + let base_unit_texts: Vec<&str> = base_units.iter().map(|unit| unit.text.as_str()).collect(); + let reference_unit_texts: Vec<&str> = reference_units + .iter() + .map(|unit| unit.text.as_str()) .collect(); + let (candidate_base_mask, base_candidate_mask) = + lcs_keep_masks(&candidate_unit_texts, &base_unit_texts); let (stripped_candidate, candidate_new_chars, context_chars) = - analyze_masked_tokens(&candidate_tokens, &context_mask); + analyze_masked_units(&candidate_units, &candidate_base_mask); let (reference_base_mask, base_reference_mask) = - lcs_keep_masks(&reference_tokens, &base_tokens); - let reference_context_mask: Vec = reference_base_mask - .iter() - .zip(reference_candidate_mask.iter()) - .map(|(&in_base, &in_candidate)| in_base && in_candidate) - .collect(); - + lcs_keep_masks(&reference_unit_texts, &base_unit_texts); let (stripped_reference, reference_new_chars, _) = - analyze_masked_tokens(&reference_tokens, &reference_context_mask); + analyze_masked_units(&reference_units, &reference_base_mask); let keep_mask = lcs_keep_mask(&stripped_candidate, &stripped_reference); @@ -299,13 +352,13 @@ pub fn compute_kept_rate(base: &str, candidate: &str, reference: &str) -> KeptRa .filter_map(|(&token, &is_kept)| is_kept.then_some(token.len())) .sum(); - let candidate_deleted_chars = count_unmasked_chars(&base_tokens, &base_candidate_mask); - let reference_deleted_chars = count_unmasked_chars(&base_tokens, &base_reference_mask); - let correctly_deleted_chars: usize = base_tokens + let candidate_deleted_chars = count_unmasked_unit_chars(&base_units, &base_candidate_mask); + let reference_deleted_chars = count_unmasked_unit_chars(&base_units, &base_reference_mask); + let correctly_deleted_chars: usize = base_units .iter() .zip(base_candidate_mask.iter().zip(base_reference_mask.iter())) - .filter_map(|(&token, (&in_candidate, &in_reference))| { - (!in_candidate && !in_reference).then_some(token.len()) + .filter_map(|(unit, (&in_candidate, &in_reference))| { + (!in_candidate && !in_reference).then_some(unit.text.len()) }) .sum(); @@ -326,24 +379,28 @@ pub fn compute_kept_rate(base: &str, candidate: &str, reference: &str) -> KeptRa matched_edit_chars as f64 / reference_edit_chars as f64 }; - #[cfg(test)] let token_annotations = { - let mut token_annotations = Vec::with_capacity(candidate_tokens.len()); + let mut token_annotations = vec![TokenAnnotation::Context; candidate_tokens.len()]; let mut new_index = 0; - for (token_index, _token) in candidate_tokens.iter().enumerate() { - if context_mask[token_index] { - token_annotations.push(TokenAnnotation::Context); + + for (unit_index, unit) in candidate_units.iter().enumerate() { + let annotation = if candidate_base_mask[unit_index] { + TokenAnnotation::Context } else { let annotation = if keep_mask[new_index] { TokenAnnotation::Kept } else { TokenAnnotation::Discarded }; - #[cfg(test)] - token_annotations.push(annotation); new_index += 1; + annotation + }; + + for token_index in unit.token_start..unit.token_end { + token_annotations[token_index] = annotation; } } + token_annotations }; @@ -358,14 +415,30 @@ pub fn compute_kept_rate(base: &str, candidate: &str, reference: &str) -> KeptRa context_chars, kept_rate, recall_rate, - #[cfg(test)] token_annotations, } } +pub fn annotate_kept_rate_tokens( + base: &str, + candidate: &str, + reference: &str, +) -> Vec { + let result = compute_kept_rate(base, candidate, reference); + tokenize(candidate) + .into_iter() + .zip(result.token_annotations) + .map(|(token, annotation)| AnnotatedToken { + token: token.to_string(), + annotation, + }) + .collect() +} + #[cfg(test)] mod test_kept_rate { use super::*; + use indoc::indoc; #[test] fn test_lcs_keep_masks() { @@ -439,16 +512,24 @@ mod test_kept_rate { #[test] fn test_missing_deletion() { - let base = " fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) {\n epr\n"; - let candidate = " fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) {\n epr\neprintln!(\"\");\n"; - let reference = " fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) {\n eprintln!(\"\");\n"; + let base = indoc! {" + fn example() { + epr + "}; + let candidate = indoc! {r#" + fn example() { + epr + eprintln!(""); + "#}; + let reference = indoc! {r#" + fn example() { + eprintln!(""); + "#}; + let result = compute_kept_rate(base, candidate, reference); - assert!( - result.kept_rate < 0.85, - "expected kept_rate < 0.85, got {}", - result.kept_rate - ); - assert!(result.discarded_chars > 0); + assert!((result.kept_rate - (14.0 / 15.0)).abs() < 1e-6); + assert_eq!(result.kept_chars, 14); + assert_eq!(result.discarded_chars, 1); } #[test] @@ -472,8 +553,17 @@ mod test_kept_rate { #[test] fn test_bails_for_dirty_final() { - let base = "fn example() {\n work();\n}\n"; - let candidate = "fn example() {\n work();\n predicted();\n}\n"; + let base = indoc! {" + fn example() { + work(); + } + "}; + let candidate = indoc! {" + fn example() { + work(); + predicted(); + } + "}; let reference = format!( "fn example() {{\n work();\n {}\n}}\n", "settled();\n ".repeat(MAX_DIRTY_LENGTH_DELTA_CHARS / 8 + 64) @@ -488,9 +578,19 @@ mod test_kept_rate { #[test] fn test_eprintln_token_alignment() { - let base = " fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) {\n epr\n"; - let candidate = " fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) {\n eprintln!(\"hello world!\");\n"; - let reference = " fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) {\n eprintln!(\"\");\n"; + let base = indoc! {" + fn example() { + epr + "}; + let candidate = indoc! {r#" + fn example() { + eprintln!("hello world!"); + "#}; + let reference = indoc! {r#" + fn example() { + eprintln!(""); + "#}; + let result = compute_kept_rate(base, candidate, reference); assert!(result.discarded_chars > 0); assert!(result.kept_chars > 0); @@ -499,6 +599,42 @@ mod test_kept_rate { assert_eq!(result.discarded_chars, 12); } + #[test] + fn test_kept_rate_treats_unchanged_stale_text_as_context() { + let base = indoc! {" + a=fomr + b=old + "}; + let candidate = indoc! {" + a=formula; + b=old + "}; + let reference = indoc! {" + a=formula; + b=new + "}; + + let result = compute_kept_rate(base, candidate, reference); + let candidate_tokens = tokenize(candidate); + + assert_eq!(result.candidate_new_chars, "formula".len() + ";".len()); + assert_eq!(result.kept_chars, "formula".len() + ";".len()); + assert_eq!(result.discarded_chars, 0); + assert_eq!(result.candidate_deleted_chars, "fomr".len()); + assert_eq!(result.correctly_deleted_chars, "fomr".len()); + assert!((result.kept_rate - 1.0).abs() < 1e-6); + assert!((result.recall_rate - (2.0 / 3.0)).abs() < 1e-6); + + let old_index = candidate_tokens + .iter() + .position(|&token| token == "old") + .expect("old token not found"); + assert_eq!( + result.token_annotations[old_index], + TokenAnnotation::Context + ); + } + #[test] fn test_annotations_rename() { let base = " foo(old_name)\n"; @@ -514,7 +650,7 @@ mod test_kept_rate { assert_eq!(result.token_annotations.len(), tokenize(candidate).len()); for (&token, &annotation) in tokenize(candidate).iter().zip(&result.token_annotations) { - if token == "new_name" { + if matches!(token, "new" | "_" | "name") { assert_eq!(annotation, TokenAnnotation::Kept); } else { assert_eq!(annotation, TokenAnnotation::Context); @@ -524,9 +660,18 @@ mod test_kept_rate { #[test] fn test_annotations_eprintln_coloring() { - let base = " fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) {\n epr\n"; - let candidate = " fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) {\n eprintln!(\"hello world!\");\n"; - let reference = " fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) {\n eprintln!(\"\");\n"; + let base = indoc! {" + fn example() { + epr + "}; + let candidate = indoc! {r#" + fn example() { + eprintln!("hello world!"); + "#}; + let reference = indoc! {r#" + fn example() { + eprintln!(""); + "#}; let result = compute_kept_rate(base, candidate, reference); let candidate_tokens = tokenize(candidate); diff --git a/crates/edit_prediction_metrics/src/main.rs b/crates/edit_prediction_metrics/src/main.rs new file mode 100644 index 00000000000000..0e557c35e7ff1f --- /dev/null +++ b/crates/edit_prediction_metrics/src/main.rs @@ -0,0 +1,710 @@ +use std::env; +use std::fmt::Write as _; +use std::fs; +use std::path::Path; +use std::process; + +use edit_prediction_metrics::{ + ClassificationMetrics, DeltaChrFMetrics, KeptRateResult, TokenAnnotation, + annotate_kept_rate_tokens, braces_disbalance, compute_kept_rate, count_patch_token_changes, + delta_chr_f, exact_lines_match, extract_changed_lines_from_diff, + has_isolated_whitespace_changes, is_editable_region_correct, +}; +use serde::Deserialize; + +fn main() { + if let Err(error) = run() { + eprintln!("error: {error}"); + process::exit(1); + } +} + +fn run() -> Result<(), String> { + let args: Vec = env::args().skip(1).collect(); + if args.is_empty() { + print_usage(); + return Err("missing arguments".to_string()); + } + + let input = CliInput::parse(&args)?; + let report = match input { + CliInput::Files { + base_path, + expected_patch_path, + actual_patch_path, + } => { + let base = fs::read_to_string(&base_path) + .map_err(|err| format!("failed to read {}: {err}", base_path.display()))?; + let expected_patch = fs::read_to_string(&expected_patch_path).map_err(|err| { + format!("failed to read {}: {err}", expected_patch_path.display()) + })?; + let actual_patch = fs::read_to_string(&actual_patch_path) + .map_err(|err| format!("failed to read {}: {err}", actual_patch_path.display()))?; + + let expected = apply_patch_to_excerpt(&base, &expected_patch, 0)?; + let actual = apply_patch_to_excerpt(&base, &actual_patch, 0)?; + + EvaluationReport::new(base, expected_patch, actual_patch, expected, actual) + } + CliInput::Json { + json_path, + prediction_index, + } => { + let json = fs::read_to_string(&json_path) + .map_err(|err| format!("failed to read {}: {err}", json_path.display()))?; + let example: JsonExample = serde_json::from_str(&json) + .map_err(|err| format!("failed to parse {}: {err}", json_path.display()))?; + + let base = example.prompt_inputs.cursor_excerpt; + let excerpt_start_row = example.prompt_inputs.excerpt_start_row; + let expected_patch = example + .expected_patches + .into_iter() + .next() + .ok_or_else(|| "JSON input is missing expected_patches[0]".to_string())?; + let actual_patch = example + .predictions + .into_iter() + .nth(prediction_index) + .ok_or_else(|| { + format!("JSON input does not contain predictions[{prediction_index}]") + })? + .actual_patch; + + let expected = apply_patch_to_excerpt(&base, &expected_patch, excerpt_start_row)?; + let actual = apply_patch_to_excerpt(&base, &actual_patch, excerpt_start_row)?; + + EvaluationReport::new(base, expected_patch, actual_patch, expected, actual) + } + }; + + print_report(&report); + Ok(()) +} + +fn print_usage() { + eprintln!( + "Usage:\n edit_prediction_metrics --base --expected-patch --actual-patch \n edit_prediction_metrics --json [--prediction-index ]" + ); +} + +enum CliInput { + Files { + base_path: std::path::PathBuf, + expected_patch_path: std::path::PathBuf, + actual_patch_path: std::path::PathBuf, + }, + Json { + json_path: std::path::PathBuf, + prediction_index: usize, + }, +} + +impl CliInput { + fn parse(args: &[String]) -> Result { + let mut base_path = None; + let mut expected_patch_path = None; + let mut actual_patch_path = None; + let mut json_path = None; + let mut prediction_index = 0usize; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--base" => { + index += 1; + base_path = Some(path_arg(args, index, "--base")?); + } + "--expected-patch" => { + index += 1; + expected_patch_path = Some(path_arg(args, index, "--expected-patch")?); + } + "--actual-patch" => { + index += 1; + actual_patch_path = Some(path_arg(args, index, "--actual-patch")?); + } + "--json" => { + index += 1; + json_path = Some(path_arg(args, index, "--json")?); + } + "--prediction-index" => { + index += 1; + let raw = string_arg(args, index, "--prediction-index")?; + prediction_index = raw.parse::().map_err(|err| { + format!("invalid value for --prediction-index ({raw}): {err}") + })?; + } + "--help" | "-h" => { + print_usage(); + process::exit(0); + } + unknown => { + return Err(format!("unrecognized argument: {unknown}")); + } + } + index += 1; + } + + if let Some(json_path) = json_path { + if base_path.is_some() || expected_patch_path.is_some() || actual_patch_path.is_some() { + return Err( + "--json cannot be combined with --base/--expected-patch/--actual-patch" + .to_string(), + ); + } + return Ok(CliInput::Json { + json_path, + prediction_index, + }); + } + + match (base_path, expected_patch_path, actual_patch_path) { + (Some(base_path), Some(expected_patch_path), Some(actual_patch_path)) => { + Ok(CliInput::Files { + base_path, + expected_patch_path, + actual_patch_path, + }) + } + _ => Err( + "expected either --json or all of --base, --expected-patch, and --actual-patch" + .to_string(), + ), + } + } +} + +fn path_arg(args: &[String], index: usize, flag: &str) -> Result { + Ok(Path::new(string_arg(args, index, flag)?).to_path_buf()) +} + +fn string_arg<'a>(args: &'a [String], index: usize, flag: &str) -> Result<&'a str, String> { + args.get(index) + .map(|value| value.as_str()) + .ok_or_else(|| format!("missing value for {flag}")) +} + +#[derive(Debug)] +struct EvaluationReport { + base: String, + expected: String, + actual: String, + kept_rate: KeptRateResult, + exact_lines: ClassificationMetrics, + delta_chr_f: DeltaChrFMetrics, + expected_changed_lines: usize, + actual_changed_lines: usize, + token_changes: edit_prediction_metrics::TokenChangeCounts, + isolated_whitespace_changes: bool, + editable_region_correct: bool, + expected_braces_disbalance: usize, + actual_braces_disbalance: usize, +} + +impl EvaluationReport { + fn new( + base: String, + expected_patch: String, + actual_patch: String, + expected: String, + actual: String, + ) -> Self { + let kept_rate = compute_kept_rate(&base, &actual, &expected); + let exact_lines = exact_lines_match(&expected_patch, &actual_patch); + let delta_chr_f = delta_chr_f(&base, &expected, &actual); + let expected_changed_lines = extract_changed_lines_from_diff(&expected_patch) + .values() + .sum(); + let actual_changed_lines = extract_changed_lines_from_diff(&actual_patch) + .values() + .sum(); + let token_changes = count_patch_token_changes(&actual_patch); + let isolated_whitespace_changes = has_isolated_whitespace_changes(&actual_patch, None); + let editable_region_correct = is_editable_region_correct(&actual_patch); + let expected_braces_disbalance = braces_disbalance(&expected); + let actual_braces_disbalance = braces_disbalance(&actual); + + Self { + base, + expected, + actual, + kept_rate, + exact_lines, + delta_chr_f, + expected_changed_lines, + actual_changed_lines, + token_changes, + isolated_whitespace_changes, + editable_region_correct, + expected_braces_disbalance, + actual_braces_disbalance, + } + } +} + +fn print_report(report: &EvaluationReport) { + println!("Metrics"); + println!("======="); + println!("kept_rate: {:.6}", report.kept_rate.kept_rate); + println!("kept_rate_recall: {:.6}", report.kept_rate.recall_rate); + println!("delta_chr_f: {:.6}", report.delta_chr_f.score); + println!("delta_chr_f_precision: {:.6}", report.delta_chr_f.precision); + println!("delta_chr_f_recall: {:.6}", report.delta_chr_f.recall); + println!("delta_chr_f_beta: {:.6}", report.delta_chr_f.beta); + println!(); + + println!("Exact line match"); + println!("----------------"); + println!("true_positives: {}", report.exact_lines.true_positives); + println!("false_positives: {}", report.exact_lines.false_positives); + println!("false_negatives: {}", report.exact_lines.false_negatives); + println!("precision: {:.6}", report.exact_lines.precision()); + println!("recall: {:.6}", report.exact_lines.recall()); + println!("f1: {:.6}", report.exact_lines.f1()); + println!("expected_changed_lines: {}", report.expected_changed_lines); + println!("actual_changed_lines: {}", report.actual_changed_lines); + println!(); + + println!("Patch structure"); + println!("---------------"); + println!("inserted_tokens: {}", report.token_changes.inserted_tokens); + println!("deleted_tokens: {}", report.token_changes.deleted_tokens); + println!( + "isolated_whitespace_changes: {}", + report.isolated_whitespace_changes + ); + println!( + "editable_region_correct: {}", + report.editable_region_correct + ); + println!(); + + println!("Final text checks"); + println!("-----------------"); + println!( + "expected_braces_disbalance: {}", + report.expected_braces_disbalance + ); + println!( + "actual_braces_disbalance: {}", + report.actual_braces_disbalance + ); + println!(); + + println!("Kept-rate breakdown"); + println!("-------------------"); + println!( + "candidate_new_chars: {}", + report.kept_rate.candidate_new_chars + ); + println!( + "reference_new_chars: {}", + report.kept_rate.reference_new_chars + ); + println!( + "candidate_deleted_chars: {}", + report.kept_rate.candidate_deleted_chars + ); + println!( + "reference_deleted_chars: {}", + report.kept_rate.reference_deleted_chars + ); + println!("kept_chars: {}", report.kept_rate.kept_chars); + println!( + "correctly_deleted_chars: {}", + report.kept_rate.correctly_deleted_chars + ); + println!("discarded_chars: {}", report.kept_rate.discarded_chars); + println!("context_chars: {}", report.kept_rate.context_chars); + println!(); + + print_kept_rate_explanation(&report.base, &report.actual, &report.expected); +} + +fn print_kept_rate_explanation(base: &str, actual: &str, expected: &str) { + println!("Kept-rate explanation"); + println!("---------------------"); + println!("Legend: context = default, kept = green background, discarded = red background"); + println!(); + + let annotated = annotate_kept_rate_tokens(base, actual, expected); + println!("Actual final text with token annotations:"); + println!("{}", render_annotated_tokens(&annotated)); + println!(); +} + +fn render_annotated_tokens(tokens: &[edit_prediction_metrics::AnnotatedToken]) -> String { + const RESET: &str = "\x1b[0m"; + const KEPT_STYLE: &str = "\x1b[30;42m"; + const DISCARDED_STYLE: &str = "\x1b[30;41m"; + + let mut rendered = String::new(); + for token in tokens { + let style = match token.annotation { + TokenAnnotation::Context => "", + TokenAnnotation::Kept => KEPT_STYLE, + TokenAnnotation::Discarded => DISCARDED_STYLE, + }; + + if style.is_empty() { + rendered.push_str(&visualize_whitespace(&token.token)); + } else { + rendered.push_str(style); + rendered.push_str(&visualize_whitespace(&token.token)); + rendered.push_str(RESET); + } + } + rendered +} + +fn visualize_whitespace(token: &str) -> String { + let mut rendered = String::new(); + for ch in token.chars() { + match ch { + ' ' => rendered.push('·'), + '\t' => rendered.push('⇥'), + '\n' => rendered.push_str("↵\n"), + _ => rendered.push(ch), + } + } + rendered +} + +#[derive(Debug, Deserialize)] +struct JsonExample { + prompt_inputs: PromptInputs, + expected_patches: Vec, + predictions: Vec, +} + +#[derive(Debug, Deserialize)] +struct PromptInputs { + cursor_excerpt: String, + excerpt_start_row: u32, +} + +#[derive(Debug, Deserialize)] +struct Prediction { + actual_patch: String, +} + +#[derive(Debug, Clone)] +struct ParsedHunk { + old_start: u32, + lines: Vec, +} + +#[derive(Debug, Clone)] +enum HunkLine { + Context(String), + Addition(String), + Deletion(String), +} + +fn apply_patch_to_excerpt( + base: &str, + patch: &str, + excerpt_start_row: u32, +) -> Result { + let hunks = parse_diff_hunks(patch); + + let result = try_apply_hunks(base, &hunks, excerpt_start_row); + + // Predicted patches may use excerpt-relative line numbers instead of + // file-global ones. When all hunks fall outside the excerpt window the + // result is identical to the base text. Retry with a zero offset so the + // line numbers are interpreted relative to the excerpt. + if excerpt_start_row > 0 && !hunks.is_empty() { + let should_retry = match &result { + Ok(text) => text == base, + Err(_) => true, + }; + + if should_retry { + let fallback = try_apply_hunks(base, &hunks, 0); + if matches!(&fallback, Ok(text) if text != base) { + return fallback; + } + } + } + + result +} + +fn try_apply_hunks( + base: &str, + hunks: &[ParsedHunk], + excerpt_start_row: u32, +) -> Result { + let base_has_trailing_newline = base.ends_with('\n'); + let mut lines = split_preserving_final_empty_line(base); + let original_line_count = lines.len() as u32; + + let excerpt_end_row = excerpt_start_row + original_line_count; + let mut line_delta: i64 = 0; + + for hunk in hunks { + let filtered = match filter_hunk_to_excerpt(hunk, excerpt_start_row, excerpt_end_row) { + Some(filtered) => filtered, + None => continue, + }; + + let local_start = filtered.old_start.saturating_sub(excerpt_start_row) as i64 + line_delta; + if local_start < 0 { + return Err(format!( + "patch application moved before excerpt start at source row {}", + filtered.old_start + )); + } + let local_start = local_start as usize; + + if local_start > lines.len() { + return Err(format!( + "patch application starts past excerpt end at local line {}", + local_start + 1 + )); + } + + let old_len = filtered + .lines + .iter() + .filter(|line| !matches!(line, HunkLine::Addition(_))) + .count(); + let new_len = filtered + .lines + .iter() + .filter(|line| !matches!(line, HunkLine::Deletion(_))) + .count(); + + let old_segment: Vec<&str> = filtered + .lines + .iter() + .filter_map(|line| match line { + HunkLine::Context(text) | HunkLine::Deletion(text) => Some(text.as_str()), + HunkLine::Addition(_) => None, + }) + .collect(); + + let new_segment: Vec = filtered + .lines + .iter() + .filter_map(|line| match line { + HunkLine::Context(text) | HunkLine::Addition(text) => Some(text.clone()), + HunkLine::Deletion(_) => None, + }) + .collect(); + + if local_start + old_len > lines.len() { + return Err(format!( + "patch application exceeds excerpt bounds near source row {}", + filtered.old_start + )); + } + + let current_segment: Vec<&str> = lines[local_start..local_start + old_len] + .iter() + .map(String::as_str) + .collect(); + + if current_segment != old_segment { + let mut details = String::new(); + let _ = write!( + details, + "patch context mismatch near source row {}: expected {:?}, found {:?}", + filtered.old_start, old_segment, current_segment + ); + return Err(details); + } + + lines.splice(local_start..local_start + old_len, new_segment); + line_delta += new_len as i64 - old_len as i64; + } + + Ok(join_lines(&lines, base_has_trailing_newline)) +} + +fn split_preserving_final_empty_line(text: &str) -> Vec { + let mut lines: Vec = text.lines().map(ToString::to_string).collect(); + if text.ends_with('\n') { + if lines.last().is_some_and(|line| !line.is_empty()) || lines.is_empty() { + lines.push(String::new()); + } + } + lines +} + +fn join_lines(lines: &[String], had_trailing_newline: bool) -> String { + if lines.is_empty() { + return String::new(); + } + + let mut joined = lines.join("\n"); + if had_trailing_newline && !joined.ends_with('\n') { + joined.push('\n'); + } + if !had_trailing_newline && joined.ends_with('\n') { + joined.pop(); + } + joined +} + +fn filter_hunk_to_excerpt( + hunk: &ParsedHunk, + excerpt_start_row: u32, + excerpt_end_row: u32, +) -> Option { + let mut filtered_lines = Vec::new(); + let mut current_old_row = hunk.old_start.saturating_sub(1); + let mut filtered_old_start = None; + let mut has_overlap = false; + + for line in &hunk.lines { + match line { + HunkLine::Context(text) => { + let in_excerpt = + current_old_row >= excerpt_start_row && current_old_row < excerpt_end_row; + if in_excerpt { + filtered_old_start.get_or_insert(current_old_row); + filtered_lines.push(HunkLine::Context(text.clone())); + has_overlap = true; + } + current_old_row += 1; + } + HunkLine::Deletion(text) => { + let in_excerpt = + current_old_row >= excerpt_start_row && current_old_row < excerpt_end_row; + if in_excerpt { + filtered_old_start.get_or_insert(current_old_row); + filtered_lines.push(HunkLine::Deletion(text.clone())); + has_overlap = true; + } + current_old_row += 1; + } + HunkLine::Addition(text) => { + let insertion_in_excerpt = + current_old_row >= excerpt_start_row && current_old_row <= excerpt_end_row; + if insertion_in_excerpt { + filtered_old_start.get_or_insert(current_old_row); + filtered_lines.push(HunkLine::Addition(text.clone())); + has_overlap = true; + } + } + } + } + + if !has_overlap { + return None; + } + + Some(ParsedHunk { + old_start: filtered_old_start.unwrap_or(excerpt_start_row), + lines: filtered_lines, + }) +} + +fn parse_diff_hunks(diff: &str) -> Vec { + let mut hunks = Vec::new(); + let mut current_hunk: Option = None; + + for line in diff.lines() { + if let Some((old_start, old_count, _new_start, _new_count)) = parse_hunk_header(line) { + if let Some(hunk) = current_hunk.take() { + hunks.push(hunk); + } + let _ = old_count; + current_hunk = Some(ParsedHunk { + old_start, + lines: Vec::new(), + }); + continue; + } + + let Some(hunk) = current_hunk.as_mut() else { + continue; + }; + + if let Some(text) = line.strip_prefix('+') { + if !line.starts_with("+++") { + hunk.lines.push(HunkLine::Addition(text.to_string())); + } + } else if let Some(text) = line.strip_prefix('-') { + if !line.starts_with("---") { + hunk.lines.push(HunkLine::Deletion(text.to_string())); + } + } else if let Some(text) = line.strip_prefix(' ') { + hunk.lines.push(HunkLine::Context(text.to_string())); + } else if line.is_empty() { + hunk.lines.push(HunkLine::Context(String::new())); + } + } + + if let Some(hunk) = current_hunk { + hunks.push(hunk); + } + + hunks +} + +fn parse_hunk_header(line: &str) -> Option<(u32, u32, u32, u32)> { + let line = line.strip_prefix("@@ -")?; + let (old_part, rest) = line.split_once(' ')?; + let rest = rest.strip_prefix('+')?; + let (new_part, _) = rest.split_once(" @@")?; + + let (old_start, old_count) = parse_hunk_range(old_part)?; + let (new_start, new_count) = parse_hunk_range(new_part)?; + Some((old_start, old_count, new_start, new_count)) +} + +fn parse_hunk_range(part: &str) -> Option<(u32, u32)> { + if let Some((start, count)) = part.split_once(',') { + Some((start.parse().ok()?, count.parse().ok()?)) + } else { + Some((part.parse().ok()?, 1)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn applies_patch_in_file_mode() { + let base = "fn main() {\n println!(\"hello\");\n}\n"; + let patch = "@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"hello\");\n+ println!(\"world\");\n }\n"; + + let actual = apply_patch_to_excerpt(base, patch, 0).unwrap(); + assert_eq!(actual, "fn main() {\n println!(\"world\");\n}\n"); + } + + #[test] + fn applies_patch_in_json_excerpt_mode() { + let base = "b\nc\nd\n"; + let patch = "@@ -2,2 +2,2 @@\n-b\n-c\n+x\n+y\n"; + + let actual = apply_patch_to_excerpt(base, patch, 1).unwrap(); + assert_eq!(actual, "x\ny\nd\n"); + } + + #[test] + fn applies_patch_with_excerpt_relative_line_numbers() { + let base = "a\nb\nc\nd\n"; + // Patch uses excerpt-relative line numbers (line 2 of excerpt) + // even though the excerpt starts at file row 100. + let patch = "@@ -2,2 +2,2 @@\n-b\n-c\n+x\n+y\n"; + + let actual = apply_patch_to_excerpt(base, patch, 100).unwrap(); + assert_eq!(actual, "a\nx\ny\nd\n"); + } + + #[test] + fn prefers_file_global_line_numbers_over_excerpt_relative() { + let base = "a\nb\nc\n"; + // Patch uses file-global line numbers: excerpt starts at row 5, + // hunk targets line 6 (1-based) = row 5 (0-based) = first line. + let patch = "@@ -6,2 +6,2 @@\n-a\n-b\n+x\n+y\n"; + + let actual = apply_patch_to_excerpt(base, patch, 5).unwrap(); + assert_eq!(actual, "x\ny\nc\n"); + } +} diff --git a/crates/edit_prediction_metrics/src/patch_metrics.rs b/crates/edit_prediction_metrics/src/patch_metrics.rs index 9da499796efabc..85470da91c59d5 100644 --- a/crates/edit_prediction_metrics/src/patch_metrics.rs +++ b/crates/edit_prediction_metrics/src/patch_metrics.rs @@ -687,6 +687,35 @@ fn diff_tokens<'a>(old: &[&'a str], new: &[&'a str]) -> Vec { .collect() } +/// Reconstruct old and new text from a unified diff. +/// +/// Context and deletion lines form the old text; context and addition +/// lines form the new text. Returns `(old_text, new_text)`. +pub fn reconstruct_texts_from_diff(patch_str: &str) -> (String, String) { + let patch = Patch::parse_unified_diff(patch_str); + let mut old_lines: Vec<&str> = Vec::new(); + let mut new_lines: Vec<&str> = Vec::new(); + + for hunk in &patch.hunks { + for line in &hunk.lines { + match line { + PatchLine::Context(content) => { + old_lines.push(content); + new_lines.push(content); + } + PatchLine::Deletion(content) => { + old_lines.push(content); + } + PatchLine::Addition(content) => { + new_lines.push(content); + } + PatchLine::Garbage(_) => {} + } + } + } + + (old_lines.join("\n"), new_lines.join("\n")) +} #[derive(Debug, Default, Clone)] struct Patch { hunks: Vec, diff --git a/crates/edit_prediction_metrics/src/tokenize.rs b/crates/edit_prediction_metrics/src/tokenize.rs index 250a5c15167cbc..72d5535e488543 100644 --- a/crates/edit_prediction_metrics/src/tokenize.rs +++ b/crates/edit_prediction_metrics/src/tokenize.rs @@ -1,33 +1,158 @@ -fn char_class(character: char) -> u8 { - if character.is_alphanumeric() || character == '_' { - 0 +use std::{iter::Peekable, str::CharIndices}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CharClass { + Identifier, + Newline, + Whitespace, + Punctuation, +} + +const MULTI_CHAR_PUNCTUATION: &[&str] = &[ + ">>>=", "<<=", ">>=", "...", "..=", "??=", "**=", ">>>", "::", "->", "=>", "==", "!=", "<=", + ">=", "&&", "||", "<<", ">>", "..", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "++", "--", + "**", "??", "?.", ":=", "<-", "//", "/*", "*/", +]; + +fn char_class(character: char) -> CharClass { + if character == '\n' || character == '\r' { + CharClass::Newline } else if character.is_whitespace() { - 1 + CharClass::Whitespace + } else if character.is_alphanumeric() || character == '_' { + CharClass::Identifier } else { - 2 + CharClass::Punctuation } } +fn is_identifier_boundary(previous: char, current: char, next: Option) -> bool { + (current.is_uppercase() && (previous.is_lowercase() || previous.is_numeric())) + || (current.is_uppercase() + && previous.is_uppercase() + && next.is_some_and(|next| next.is_lowercase())) +} + +fn push_identifier_tokens<'a>(identifier: &'a str, tokens: &mut Vec<&'a str>) { + let characters: Vec<(usize, char)> = identifier.char_indices().collect(); + let mut segment_start = 0; + let mut index = 0; + + while index < characters.len() { + let (byte_index, character) = characters[index]; + + if character == '_' { + if segment_start < byte_index { + tokens.push(&identifier[segment_start..byte_index]); + } + + let mut underscore_end = byte_index + character.len_utf8(); + index += 1; + + while index < characters.len() && characters[index].1 == '_' { + underscore_end = characters[index].0 + characters[index].1.len_utf8(); + index += 1; + } + + tokens.push(&identifier[byte_index..underscore_end]); + segment_start = underscore_end; + continue; + } + + if byte_index > segment_start { + let previous = characters[index - 1].1; + let next = characters.get(index + 1).map(|(_, character)| *character); + + if is_identifier_boundary(previous, character, next) { + tokens.push(&identifier[segment_start..byte_index]); + segment_start = byte_index; + } + } + + index += 1; + } + + if segment_start < identifier.len() { + tokens.push(&identifier[segment_start..]); + } +} + +fn push_punctuation_token<'a>( + text: &'a str, + start: usize, + character: char, + characters: &mut Peekable>, + tokens: &mut Vec<&'a str>, +) { + let remaining = &text[start..]; + + for punctuation in MULTI_CHAR_PUNCTUATION { + if remaining.starts_with(punctuation) { + for _ in punctuation.chars().skip(1) { + characters.next(); + } + + tokens.push(&remaining[..punctuation.len()]); + return; + } + } + + let end = start + character.len_utf8(); + tokens.push(&text[start..end]); +} + pub(crate) fn tokenize(text: &str) -> Vec<&str> { let mut tokens = Vec::new(); let mut characters = text.char_indices().peekable(); while let Some((start, character)) = characters.next() { - let class = char_class(character); - if class == 2 { - tokens.push(&text[start..start + character.len_utf8()]); - continue; - } + match char_class(character) { + CharClass::Identifier => { + let mut end = start + character.len_utf8(); + + while let Some(&(next_start, next_character)) = characters.peek() { + if char_class(next_character) != CharClass::Identifier { + break; + } + + end = next_start + next_character.len_utf8(); + characters.next(); + } + + push_identifier_tokens(&text[start..end], &mut tokens); + } + CharClass::Newline => { + let mut end = start + character.len_utf8(); + + while let Some(&(next_start, next_character)) = characters.peek() { + if char_class(next_character) != CharClass::Newline { + break; + } + + end = next_start + next_character.len_utf8(); + characters.next(); + } - let mut end = start + character.len_utf8(); - while let Some(&(_, next_character)) = characters.peek() { - if char_class(next_character) != class { - break; + tokens.push(&text[start..end]); + } + CharClass::Whitespace => { + let mut end = start + character.len_utf8(); + + while let Some(&(next_start, next_character)) = characters.peek() { + if char_class(next_character) != CharClass::Whitespace { + break; + } + + end = next_start + next_character.len_utf8(); + characters.next(); + } + + tokens.push(&text[start..end]); + } + CharClass::Punctuation => { + push_punctuation_token(text, start, character, &mut characters, &mut tokens); } - end += next_character.len_utf8(); - characters.next(); } - tokens.push(&text[start..end]); } tokens @@ -38,17 +163,58 @@ mod tests { use super::tokenize; #[test] - fn tokenizes_code_like_text() { + fn tokenizes_code() { assert_eq!(tokenize("hello world"), vec!["hello", " ", "world"]); assert_eq!( tokenize("foo_bar123 + baz"), - vec!["foo_bar123", " ", "+", " ", "baz"] + vec!["foo", "_", "bar123", " ", "+", " ", "baz"] ); assert_eq!( tokenize("print(\"hello\")"), vec!["print", "(", "\"", "hello", "\"", ")"] ); - assert_eq!(tokenize("hello_world"), vec!["hello_world"]); + assert_eq!(tokenize("hello_world"), vec!["hello", "_", "world"]); assert_eq!(tokenize("fn();"), vec!["fn", "(", ")", ";"]); } + + #[test] + fn tokenizes_identifier_case_styles() { + assert_eq!( + tokenize("camelCase PascalCase snake_case"), + vec![ + "camel", "Case", " ", "Pascal", "Case", " ", "snake", "_", "case" + ] + ); + assert_eq!( + tokenize("myHTTPServer __private_value foo__bar"), + vec![ + "my", "HTTP", "Server", " ", "__", "private", "_", "value", " ", "foo", "__", "bar" + ] + ); + assert_eq!( + tokenize("XMLHttpRequest Version2Update"), + vec!["XML", "Http", "Request", " ", "Version2", "Update"] + ); + } + + #[test] + fn tokenizes_grouped_punctuation() { + assert_eq!( + tokenize("a::b -> c != d ..= e"), + vec![ + "a", "::", "b", " ", "->", " ", "c", " ", "!=", " ", "d", " ", "..=", " ", "e" + ] + ); + assert_eq!( + tokenize("foo?.bar ?? baz"), + vec!["foo", "?.", "bar", " ", "??", " ", "baz"] + ); + } + + #[test] + fn tokenize_whitespace_runs() { + assert_eq!(tokenize(" "), vec![" "]); + assert_eq!(tokenize(" \n foo"), vec![" ", "\n", " ", "foo"]); + assert_eq!(tokenize("\r\n\nfoo"), vec!["\r\n\n", "foo"]); + } } diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index d6772847ffb861..4d048c25a53528 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -11,7 +11,7 @@ use editor::{ use feature_flags::FeatureFlagAppExt; use fs::Fs; use gpui::{ - Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle, + Action, Anchor, Animation, AnimationExt, App, AsyncWindowContext, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div, ease_in_out, pulsating_between, }; @@ -172,7 +172,7 @@ impl Render for EditPredictionButton { } .ok() }) - .anchor(Corner::BottomRight) + .anchor(Anchor::BottomRight) .trigger_with_tooltip( IconButton::new("copilot-icon", icon), |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx), @@ -216,7 +216,7 @@ impl Render for EditPredictionButton { }) .ok() }) - .anchor(Corner::BottomRight) + .anchor(Anchor::BottomRight) .trigger_with_tooltip( IconButton::new("codestral-icon", IconName::AiMistral) .shape(IconButtonShape::Square) @@ -260,7 +260,7 @@ impl Render for EditPredictionButton { }) .ok() }) - .anchor(Corner::BottomRight) + .anchor(Anchor::BottomRight) .trigger( IconButton::new("openai-compatible-api-icon", IconName::AiOpenAiCompat) .shape(IconButtonShape::Square) @@ -290,7 +290,7 @@ impl Render for EditPredictionButton { }) .ok() }) - .anchor(Corner::BottomRight) + .anchor(Anchor::BottomRight) .trigger_with_tooltip( IconButton::new("ollama-icon", IconName::AiOllama) .shape(IconButtonShape::Square) @@ -485,7 +485,7 @@ impl Render for EditPredictionButton { .ok() }) }) - .anchor(Corner::BottomRight) + .anchor(Anchor::BottomRight) .with_handle(self.popover_menu_handle.clone()); let is_refreshing = self @@ -630,6 +630,28 @@ impl EditPredictionButton { menu } + fn add_configure_providers_item(&self, menu: ContextMenu) -> ContextMenu { + menu.separator().item( + ContextMenuEntry::new("Configure Providers") + .icon(IconName::Settings) + .icon_position(IconPosition::Start) + .icon_color(Color::Muted) + .handler(move |window, cx| { + telemetry::event!( + "Edit Prediction Menu Action", + action = "configure_providers", + ); + window.dispatch_action( + OpenSettingsAt { + path: "edit_predictions.providers".to_string(), + } + .boxed_clone(), + cx, + ); + }), + ) + } + pub fn build_copilot_start_menu( &mut self, window: &mut Window, @@ -637,39 +659,38 @@ impl EditPredictionButton { ) -> Entity { let fs = self.fs.clone(); let project = self.project.clone(); - ContextMenu::build(window, cx, |menu, _, _| { - menu.entry("Sign In to Copilot", None, move |window, cx| { - telemetry::event!( - "Edit Prediction Menu Action", - action = "sign_in", - provider = "copilot", - ); - if let Some(copilot) = EditPredictionStore::try_global(cx).and_then(|store| { - store.update(cx, |this, cx| { - this.start_copilot_for_project(&project.upgrade()?, cx) - }) - }) { - copilot_ui::initiate_sign_in(copilot, window, cx); - } - }) - .entry("Disable Copilot", None, { - let fs = fs.clone(); - move |_window, cx| { + ContextMenu::build(window, cx, |menu, _, cx| { + let menu = menu + .entry("Sign In to Copilot", None, move |window, cx| { telemetry::event!( "Edit Prediction Menu Action", - action = "disable_provider", + action = "sign_in", provider = "copilot", ); - hide_copilot(fs.clone(), cx) - } - }) - .separator() - .entry("Use Zed AI", None, { - let fs = fs.clone(); - move |_window, cx| { - set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed) - } - }) + if let Some(copilot) = EditPredictionStore::try_global(cx).and_then(|store| { + store.update(cx, |this, cx| { + this.start_copilot_for_project(&project.upgrade()?, cx) + }) + }) { + copilot_ui::initiate_sign_in(copilot, window, cx); + } + }) + .entry("Disable Copilot", None, { + let fs = fs.clone(); + move |_window, cx| { + telemetry::event!( + "Edit Prediction Menu Action", + action = "disable_provider", + provider = "copilot", + ); + hide_copilot(fs.clone(), cx) + } + }); + + let menu = + self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx); + let menu = self.add_configure_providers_item(menu); + menu }) } @@ -1008,7 +1029,9 @@ impl EditPredictionButton { let menu = self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx); - menu.separator() + let menu = self.add_configure_providers_item(menu); + let menu = menu + .separator() .item( ContextMenuEntry::new("Copilot: Next Edit Suggestions") .toggleable(IconPosition::Start, next_edit_suggestions) @@ -1034,7 +1057,8 @@ impl EditPredictionButton { "Go to Copilot Settings", OpenBrowser { url: settings_url }.boxed_clone(), ) - .action("Sign Out", copilot::SignOut.boxed_clone()) + .action("Sign Out", copilot::SignOut.boxed_clone()); + menu }) } @@ -1048,6 +1072,7 @@ impl EditPredictionButton { let menu = self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx); + let menu = self.add_configure_providers_item(menu); menu }) } @@ -1290,26 +1315,7 @@ impl EditPredictionButton { } } - menu = menu.separator().item( - ContextMenuEntry::new("Configure Providers") - .icon(IconName::Settings) - .icon_position(IconPosition::Start) - .icon_color(Color::Muted) - .handler(move |window, cx| { - telemetry::event!( - "Edit Prediction Menu Action", - action = "configure_providers", - ); - window.dispatch_action( - OpenSettingsAt { - path: "edit_predictions.providers".to_string(), - } - .boxed_clone(), - cx, - ); - }), - ); - + let menu = self.add_configure_providers_item(menu); menu }) } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 7524c5b01bf090..6a05f94cf628cd 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -849,6 +849,8 @@ actions!( ToggleIndentGuides, /// Toggles inlay hints display. ToggleInlayHints, + /// Toggles code lens display. + ToggleCodeLens, /// Toggles semantic highlights display. ToggleSemanticHighlights, /// Toggles inline values display. diff --git a/crates/editor/src/code_completion_tests.rs b/crates/editor/src/code_completion_tests.rs index 3211f0b818eb30..b3d05e23e57486 100644 --- a/crates/editor/src/code_completion_tests.rs +++ b/crates/editor/src/code_completion_tests.rs @@ -217,6 +217,77 @@ async fn test_sort_positions(cx: &mut TestAppContext) { assert_eq!(matches[0].string, "rounded-full"); } +#[gpui::test] +async fn test_case_sensitive_match_tie_breaker(cx: &mut TestAppContext) { + let completions = vec![ + CompletionBuilder::variable("abc", None, "11"), + CompletionBuilder::variable("ABC", None, "11"), + ]; + + let matches = filter_and_sort_matches("a", &completions, SnippetSortOrder::default(), cx).await; + assert_eq!( + matches + .iter() + .map(|m| m.string.as_str()) + .collect::>(), + vec!["abc", "ABC"] + ); + + let matches = filter_and_sort_matches("A", &completions, SnippetSortOrder::default(), cx).await; + assert_eq!( + matches + .iter() + .map(|m| m.string.as_str()) + .collect::>(), + vec!["ABC", "abc"] + ); + + let matches = + filter_and_sort_matches("ab", &completions, SnippetSortOrder::default(), cx).await; + assert_eq!( + matches + .iter() + .map(|m| m.string.as_str()) + .collect::>(), + vec!["abc", "ABC"] + ); + + let matches = + filter_and_sort_matches("AB", &completions, SnippetSortOrder::default(), cx).await; + assert_eq!( + matches + .iter() + .map(|m| m.string.as_str()) + .collect::>(), + vec!["ABC", "abc"] + ); + + let completions = vec![ + CompletionBuilder::variable("aBc", None, "11"), + CompletionBuilder::variable("Abc", None, "11"), + ]; + + let matches = + filter_and_sort_matches("Ab", &completions, SnippetSortOrder::default(), cx).await; + assert_eq!( + matches + .iter() + .map(|m| m.string.as_str()) + .collect::>(), + vec!["Abc", "aBc"] + ); + + let matches = + filter_and_sort_matches("aB", &completions, SnippetSortOrder::default(), cx).await; + assert_eq!( + matches + .iter() + .map(|m| m.string.as_str()) + .collect::>(), + vec!["aBc", "Abc"] + ); +} + #[gpui::test] async fn test_fuzzy_over_sort_positions(cx: &mut TestAppContext) { let completions = vec![ diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 2db2086eef422a..2c609e5ba81a00 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -1256,6 +1256,7 @@ impl CompletionsMenu { sort_snippet: Reverse, sort_score: Reverse>, sort_positions: Vec, + sort_exact_case_matches: Reverse, sort_text: Option<&'a str>, sort_kind: usize, sort_label: &'a str, @@ -1311,6 +1312,10 @@ impl CompletionsMenu { SnippetSortOrder::None => Reverse(0), }; let sort_positions = string_match.positions.clone(); + let sort_exact_case_matches = Reverse(exact_case_match_count( + query.unwrap_or_default(), + string_match, + )); // This exact matching won't work for multi-word snippets, but it's fine let sort_exact = Reverse(if Some(completion.label.filter_text()) == query { 1 @@ -1323,6 +1328,7 @@ impl CompletionsMenu { sort_snippet, sort_score, sort_positions, + sort_exact_case_matches, sort_text, sort_kind, sort_label, @@ -1379,6 +1385,30 @@ impl CompletionsMenu { } } +fn exact_case_match_count(query: &str, string_match: &StringMatch) -> usize { + let mut exact_matches = 0; + let mut query_chars = query.chars(); + let mut next_query_char = query_chars.next(); + let mut matched_positions = string_match.positions.iter().copied().peekable(); + + for (index, candidate_char) in string_match.string.char_indices() { + if matched_positions.peek() == Some(&index) { + let Some(query_char) = next_query_char else { + break; + }; + + if query_char == candidate_char { + exact_matches += 1; + } + + matched_positions.next(); + next_query_char = query_chars.next(); + } + } + + exact_matches +} + #[derive(Clone)] pub struct AvailableCodeAction { pub action: CodeAction, diff --git a/crates/editor/src/code_lens.rs b/crates/editor/src/code_lens.rs new file mode 100644 index 00000000000000..c1bf2525d9eb7d --- /dev/null +++ b/crates/editor/src/code_lens.rs @@ -0,0 +1,1066 @@ +use std::{iter, ops::Range, sync::Arc}; + +use collections::{HashMap, HashSet}; +use futures::future::join_all; +use gpui::{MouseButton, SharedString, Task, WeakEntity}; +use itertools::Itertools; +use language::{BufferId, ClientCommand}; +use multi_buffer::{Anchor, MultiBufferRow, MultiBufferSnapshot, ToPoint as _}; +use project::{CodeAction, TaskSourceKind}; +use settings::Settings as _; +use task::TaskContext; +use text::Point; + +use ui::{Context, Window, div, prelude::*}; +use workspace::PreviewTabsSettings; + +use crate::{ + Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, MultibufferSelectionMode, SelectionEffects, + actions::ToggleCodeLens, + display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, +}; + +#[derive(Clone, Debug)] +struct CodeLensLine { + position: Anchor, + indent_column: u32, + items: Vec, +} + +#[derive(Clone, Debug)] +struct CodeLensItem { + title: SharedString, + action: CodeAction, +} + +pub(super) struct CodeLensState { + pub(super) block_ids: HashMap>, + resolve_task: Task<()>, +} + +impl Default for CodeLensState { + fn default() -> Self { + Self { + block_ids: HashMap::default(), + resolve_task: Task::ready(()), + } + } +} + +impl CodeLensState { + fn all_block_ids(&self) -> HashSet { + self.block_ids.values().flatten().copied().collect() + } +} + +fn group_lenses_by_row( + lenses: Vec<(Anchor, CodeLensItem)>, + snapshot: &MultiBufferSnapshot, +) -> impl Iterator { + lenses + .into_iter() + .into_group_map_by(|(position, _)| { + let row = position.to_point(snapshot).row; + MultiBufferRow(row) + }) + .into_iter() + .sorted_by_key(|(row, _)| *row) + .filter_map(|(row, entries)| { + let position = entries.first()?.0; + let items = entries.into_iter().map(|(_, item)| item).collect(); + let indent_column = snapshot.indent_size_for_line(row).len; + Some(CodeLensLine { + position, + indent_column, + items, + }) + }) +} + +fn render_code_lens_line( + line_number: usize, + lens: CodeLensLine, + editor: WeakEntity, +) -> impl Fn(&mut crate::display_map::BlockContext) -> gpui::AnyElement { + move |cx| { + let mut children = Vec::with_capacity((2 * lens.items.len()).saturating_sub(1)); + let text_style = &cx.editor_style.text; + let font = text_style.font(); + let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9; + + for (i, item) in lens.items.iter().enumerate() { + if i > 0 { + children.push( + div() + .font(font.clone()) + .text_size(font_size) + .text_color(cx.app.theme().colors().text_muted) + .child(" | ") + .into_any_element(), + ); + } + + let title = item.title.clone(); + let action = item.action.clone(); + let editor_handle = editor.clone(); + let position = lens.position; + let id = (line_number as u64) << 32 | (i as u64); + + children.push( + div() + .id(ElementId::Integer(id)) + .font(font.clone()) + .text_size(font_size) + .text_color(cx.app.theme().colors().text_muted) + .cursor_pointer() + .hover(|style| style.text_color(cx.app.theme().colors().text)) + .child(title.clone()) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_down(MouseButton::Right, |_, _, cx| { + cx.stop_propagation(); + }) + .on_click({ + move |_event, window, cx| { + if let Some(editor) = editor_handle.upgrade() { + editor.update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::default(), + window, + cx, + |s| { + s.select_anchor_ranges([position..position]); + }, + ); + + let action = action.clone(); + if let Some(workspace) = editor.workspace() { + if try_handle_client_command( + &action, editor, &workspace, window, cx, + ) { + return; + } + + let project = workspace.read(cx).project().clone(); + if let Some(buffer) = editor + .buffer() + .read(cx) + .buffer(action.range.start.buffer_id) + { + project + .update(cx, |project, cx| { + project + .apply_code_action(buffer, action, true, cx) + }) + .detach_and_log_err(cx); + } + } + }); + } + } + }) + .into_any_element(), + ); + } + + div() + .pl(cx.margins.gutter.full_width() + cx.em_width * (lens.indent_column as f32 + 0.5)) + .h_full() + .flex() + .flex_row() + .items_end() + .children(children) + .into_any_element() + } +} + +pub(super) fn try_handle_client_command( + action: &CodeAction, + editor: &mut Editor, + workspace: &gpui::Entity, + window: &mut Window, + cx: &mut Context, +) -> bool { + let Some(command) = action.lsp_action.command() else { + return false; + }; + + let arguments = command.arguments.as_deref().unwrap_or_default(); + let project = workspace.read(cx).project().clone(); + let client_command = project + .read(cx) + .lsp_store() + .read(cx) + .language_server_adapter_for_id(action.server_id) + .and_then(|adapter| adapter.adapter.client_command(&command.command, arguments)) + .or_else(|| match command.command.as_str() { + "editor.action.showReferences" + | "editor.action.goToLocations" + | "editor.action.peekLocations" => Some(ClientCommand::ShowLocations), + _ => None, + }); + + match client_command { + Some(ClientCommand::ScheduleTask(task_template)) => { + schedule_task(task_template, action, editor, workspace, window, cx) + } + Some(ClientCommand::ShowLocations) => { + try_show_references(arguments, action, workspace, window, cx) + } + None => false, + } +} + +fn schedule_task( + task_template: task::TaskTemplate, + action: &CodeAction, + editor: &Editor, + workspace: &gpui::Entity, + window: &mut Window, + cx: &mut Context, +) -> bool { + let task_context = TaskContext { + cwd: task_template.cwd.as_ref().map(std::path::PathBuf::from), + ..TaskContext::default() + }; + let language_name = editor + .buffer() + .read(cx) + .buffer(action.range.start.buffer_id) + .and_then(|buffer| buffer.read(cx).language()) + .map(|language| language.name()); + let task_source_kind = match language_name { + Some(language_name) => TaskSourceKind::Lsp { + server: action.server_id, + language_name: SharedString::from(language_name), + }, + None => TaskSourceKind::AbsPath { + id_base: "code-lens".into(), + abs_path: task_template + .cwd + .as_ref() + .map(std::path::PathBuf::from) + .unwrap_or_default(), + }, + }; + + workspace.update(cx, |workspace, cx| { + workspace.schedule_task( + task_source_kind, + &task_template, + &task_context, + false, + window, + cx, + ); + }); + true +} + +fn try_show_references( + arguments: &[serde_json::Value], + action: &CodeAction, + workspace: &gpui::Entity, + window: &mut Window, + cx: &mut Context, +) -> bool { + if arguments.len() < 3 { + return false; + } + let Ok(locations) = serde_json::from_value::>(arguments[2].clone()) else { + return false; + }; + if locations.is_empty() { + return false; + } + + let server_id = action.server_id; + let project = workspace.read(cx).project().clone(); + let workspace = workspace.clone(); + + cx.spawn_in(window, async move |_editor, cx| { + let mut buffer_locations = std::collections::HashMap::default(); + + for location in &locations { + let open_task = cx.update(|_, cx| { + project.update(cx, |project, cx| { + let uri: lsp::Uri = location.uri.clone(); + project.open_local_buffer_via_lsp(uri, server_id, cx) + }) + })?; + let buffer = open_task.await?; + + let range = range_from_lsp(location.range); + buffer_locations + .entry(buffer) + .or_insert_with(Vec::new) + .push(range); + } + + workspace.update_in(cx, |workspace, window, cx| { + let target = buffer_locations + .iter() + .flat_map(|(k, v)| iter::repeat(k.clone()).zip(v)) + .map(|(buffer, location)| { + buffer + .read(cx) + .text_for_range(location.clone()) + .collect::() + }) + .filter(|text| !text.contains('\n')) + .unique() + .take(3) + .join(", "); + let title = if target.is_empty() { + "References".to_owned() + } else { + format!("References to {target}") + }; + let allow_preview = + PreviewTabsSettings::get_global(cx).enable_preview_multibuffer_from_code_navigation; + Editor::open_locations_in_multibuffer( + workspace, + buffer_locations, + title, + false, + allow_preview, + MultibufferSelectionMode::First, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + true +} + +fn range_from_lsp(range: lsp::Range) -> Range { + let start = Point::new(range.start.line, range.start.character); + let end = Point::new(range.end.line, range.end.character); + start..end +} + +impl Editor { + pub(super) fn refresh_code_lenses( + &mut self, + for_buffer: Option, + _window: &Window, + cx: &mut Context, + ) { + if !self.lsp_data_enabled() || self.code_lens.is_none() { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + + let buffers_to_query = self + .visible_buffers(cx) + .into_iter() + .filter(|buffer| self.is_lsp_relevant(buffer.read(cx).file(), cx)) + .chain(for_buffer.and_then(|buffer_id| self.buffer.read(cx).buffer(buffer_id))) + .filter(|editor_buffer| { + let editor_buffer_id = editor_buffer.read(cx).remote_id(); + for_buffer.is_none_or(|buffer_id| buffer_id == editor_buffer_id) + && self.registered_buffers.contains_key(&editor_buffer_id) + }) + .unique_by(|buffer| buffer.read(cx).remote_id()) + .collect::>(); + + if buffers_to_query.is_empty() { + return; + } + + let project = project.downgrade(); + self.refresh_code_lens_task = cx.spawn(async move |editor, cx| { + cx.background_executor() + .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT) + .await; + + let Some(tasks) = project + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + buffers_to_query + .into_iter() + .map(|buffer| { + let buffer_id = buffer.read(cx).remote_id(); + let task = lsp_store.code_lens_actions(&buffer, cx); + async move { (buffer_id, task.await) } + }) + .collect::>() + }) + }) + .ok() + else { + return; + }; + + let results = join_all(tasks).await; + if results.is_empty() { + return; + } + + let Ok(multi_buffer_snapshot) = + editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) + else { + return; + }; + + let mut new_lenses_per_buffer = HashMap::default(); + for (buffer_id, result) in results { + let actions = match result { + Ok(Some(actions)) => actions, + Ok(None) => continue, + Err(e) => { + log::error!("Failed to fetch code lenses for buffer {buffer_id:?}: {e:#}"); + continue; + } + }; + let individual_lenses = actions + .into_iter() + .filter_map(|action| { + let title = match &action.lsp_action { + project::LspAction::CodeLens(lens) => lens + .command + .as_ref() + .map(|cmd| SharedString::from(&cmd.title)), + _ => None, + }?; + let position = + multi_buffer_snapshot.anchor_in_excerpt(action.range.start)?; + Some((position, CodeLensItem { title, action })) + }) + .collect(); + new_lenses_per_buffer.insert( + buffer_id, + group_lenses_by_row(individual_lenses, &multi_buffer_snapshot) + .collect::>(), + ); + } + + editor + .update(cx, |editor, cx| { + let code_lens = editor.code_lens.get_or_insert_with(CodeLensState::default); + let mut blocks_to_remove = HashSet::default(); + for buffer_id in new_lenses_per_buffer.keys() { + if let Some(old_ids) = code_lens.block_ids.remove(buffer_id) { + blocks_to_remove.extend(old_ids); + } + } + if !blocks_to_remove.is_empty() { + editor.remove_blocks(blocks_to_remove, None, cx); + } + + let editor_handle = cx.entity().downgrade(); + for (buffer_id, lens_lines) in new_lenses_per_buffer { + if lens_lines.is_empty() { + continue; + } + let blocks = lens_lines + .into_iter() + .enumerate() + .map(|(line_number, lens_line)| { + let position = lens_line.position; + BlockProperties { + placement: BlockPlacement::Above(position), + height: Some(1), + style: BlockStyle::Flex, + render: Arc::new(render_code_lens_line( + line_number, + lens_line, + editor_handle.clone(), + )), + priority: 0, + } + }) + .collect::>(); + let block_ids = editor.insert_blocks(blocks, None, cx); + editor + .code_lens + .get_or_insert_with(CodeLensState::default) + .block_ids + .entry(buffer_id) + .or_default() + .extend(block_ids); + } + + editor.resolve_visible_code_lenses(cx); + }) + .ok(); + }); + } + + pub fn supports_code_lens(&self, cx: &ui::App) -> bool { + let Some(project) = self.project.as_ref() else { + return false; + }; + let lsp_store = project.read(cx).lsp_store().read(cx); + lsp_store + .lsp_server_capabilities + .values() + .any(|caps| caps.code_lens_provider.is_some()) + } + + pub fn code_lens_enabled(&self) -> bool { + self.code_lens.is_some() + } + + pub fn toggle_code_lens_action( + &mut self, + _: &ToggleCodeLens, + window: &mut Window, + cx: &mut Context, + ) { + let currently_enabled = self.code_lens.is_some(); + self.toggle_code_lens(!currently_enabled, window, cx); + } + + pub(super) fn toggle_code_lens( + &mut self, + enabled: bool, + window: &mut Window, + cx: &mut Context, + ) { + if enabled { + self.code_lens.get_or_insert_with(CodeLensState::default); + self.refresh_code_lenses(None, window, cx); + } else { + self.clear_code_lenses(cx); + } + } + + pub(super) fn resolve_visible_code_lenses(&mut self, cx: &mut Context) { + if !self.lsp_data_enabled() || self.code_lens.is_none() { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + + let resolve_tasks = self + .visible_buffer_ranges(cx) + .into_iter() + .filter_map(|(snapshot, visible_range, _)| { + let buffer_id = snapshot.remote_id(); + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + let visible_anchor_range = snapshot.anchor_before(visible_range.start) + ..snapshot.anchor_after(visible_range.end); + let task = project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.resolve_visible_code_lenses(&buffer, visible_anchor_range, cx) + }) + }); + Some((buffer_id, task)) + }) + .collect::>(); + if resolve_tasks.is_empty() { + return; + } + + let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default); + code_lens.resolve_task = cx.spawn(async move |editor, cx| { + let resolved_code_lens = join_all( + resolve_tasks + .into_iter() + .map(|(buffer_id, task)| async move { (buffer_id, task.await) }), + ) + .await; + editor + .update(cx, |editor, cx| { + editor.insert_resolved_code_lens_blocks(resolved_code_lens, cx); + }) + .ok(); + }); + } + + fn insert_resolved_code_lens_blocks( + &mut self, + resolved_code_lens: Vec<(BufferId, Vec)>, + cx: &mut Context, + ) { + let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); + let editor_handle = cx.entity().downgrade(); + + for (buffer_id, actions) in resolved_code_lens { + let lenses = actions + .into_iter() + .filter_map(|action| { + let title = match &action.lsp_action { + project::LspAction::CodeLens(lens) => lens + .command + .as_ref() + .map(|cmd| SharedString::from(&cmd.title)), + _ => None, + }?; + let position = multi_buffer_snapshot.anchor_in_excerpt(action.range.start)?; + Some((position, CodeLensItem { title, action })) + }) + .collect(); + + let blocks = group_lenses_by_row(lenses, &multi_buffer_snapshot) + .enumerate() + .map(|(line_number, lens_line)| { + let position = lens_line.position; + BlockProperties { + placement: BlockPlacement::Above(position), + height: Some(1), + style: BlockStyle::Flex, + render: Arc::new(render_code_lens_line( + line_number, + lens_line, + editor_handle.clone(), + )), + priority: 0, + } + }) + .collect::>(); + + if !blocks.is_empty() { + let block_ids = self.insert_blocks(blocks, None, cx); + self.code_lens + .get_or_insert_with(CodeLensState::default) + .block_ids + .entry(buffer_id) + .or_default() + .extend(block_ids); + } + } + cx.notify(); + } + + pub(super) fn clear_code_lenses(&mut self, cx: &mut Context) { + if let Some(code_lens) = self.code_lens.take() { + let all_blocks = code_lens.all_block_ids(); + if !all_blocks.is_empty() { + self.remove_blocks(all_blocks, None, cx); + } + cx.notify(); + } + self.refresh_code_lens_task = Task::ready(()); + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{Arc, Mutex}, + time::Duration, + }; + + use collections::HashSet; + use futures::StreamExt; + use gpui::TestAppContext; + use settings::CodeLens; + use util::path; + + use crate::{ + Editor, + editor_tests::{init_test, update_test_editor_settings}, + test::editor_lsp_test_context::EditorLspTestContext, + }; + + #[gpui::test] + async fn test_code_lens_blocks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: None, + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["lens_cmd".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + Ok(Some(vec![ + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: Some(lsp::Command { + title: "2 references".to_owned(), + command: "lens_cmd".to_owned(), + arguments: None, + }), + data: None, + }, + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 19)), + command: Some(lsp::Command { + title: "0 references".to_owned(), + command: "lens_cmd".to_owned(), + arguments: None, + }), + data: None, + }, + ])) + }); + + cx.set_state("ˇfunction hello() {}\nfunction world() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received a code lens request" + ); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + assert_eq!( + editor.code_lens_enabled(), + true, + "code lens should be enabled" + ); + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.block_ids.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!(total_blocks, 2, "Should have inserted two code lens blocks"); + }); + } + + #[gpui::test] + async fn test_code_lens_disabled_by_default(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: None, + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["lens_cmd".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + cx.lsp + .set_request_handler::(|_, _| async move { + panic!("Should not request code lenses when disabled"); + }); + + cx.set_state("ˇfunction hello() {}"); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + assert_eq!( + editor.code_lens_enabled(), + false, + "code lens should not be enabled when setting is off" + ); + }); + } + + #[gpui::test] + async fn test_code_lens_toggling(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: None, + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["lens_cmd".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + Ok(Some(vec![lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: Some(lsp::Command { + title: "1 reference".to_owned(), + command: "lens_cmd".to_owned(), + arguments: None, + }), + data: None, + }])) + }); + + cx.set_state("ˇfunction hello() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received a code lens request" + ); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + assert_eq!( + editor.code_lens_enabled(), + true, + "code lens should be enabled" + ); + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.block_ids.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!(total_blocks, 1, "Should have one code lens block"); + }); + + cx.update_editor(|editor, _window, cx| { + editor.clear_code_lenses(cx); + }); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + assert_eq!( + editor.code_lens_enabled(), + false, + "code lens should be disabled after clearing" + ); + }); + } + + #[gpui::test] + async fn test_code_lens_resolve(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + Ok(Some(vec![ + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: None, + data: Some(serde_json::json!({"id": "lens_1"})), + }, + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 19)), + command: None, + data: Some(serde_json::json!({"id": "lens_2"})), + }, + ])) + }); + + cx.lsp + .set_request_handler::(|lens, _| async move { + let id = lens + .data + .as_ref() + .and_then(|d| d.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let title = match id { + "lens_1" => "3 references", + "lens_2" => "1 implementation", + _ => "unknown", + }; + Ok(lsp::CodeLens { + command: Some(lsp::Command { + title: title.to_owned(), + command: format!("resolved_{id}"), + arguments: None, + }), + ..lens + }) + }); + + cx.set_state("ˇfunction hello() {}\nfunction world() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received a code lens request" + ); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.block_ids.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!( + total_blocks, 2, + "Unresolved lenses should have been resolved and displayed" + ); + }); + } + + #[gpui::test] + async fn test_code_lens_resolve_only_visible(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let line_count: u32 = 100; + let lens_every: u32 = 10; + let lines = (0..line_count) + .map(|i| format!("function func_{i}() {{}}")) + .collect::>() + .join("\n"); + + let lens_lines = (0..line_count) + .filter(|i| i % lens_every == 0) + .collect::>(); + + let resolved_lines = Arc::new(Mutex::new(Vec::::new())); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), serde_json::json!({ "main.ts": lines })) + .await; + + let project = project::Project::test(fs, [path!("/dir").as_ref()], cx).await; + 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, |mw, _| mw.workspace().clone()); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(language::Language::new( + language::LanguageConfig { + name: "TypeScript".into(), + matcher: language::LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..language::LanguageMatcher::default() + }, + ..language::LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ))); + + let mut fake_servers = language_registry.register_fake_lsp( + "TypeScript", + language::FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + ..lsp::ServerCapabilities::default() + }, + ..language::FakeLspAdapter::default() + }, + ); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + std::path::PathBuf::from(path!("/dir/main.ts")), + workspace::OpenOptions::default(), + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + + let lens_lines_for_handler = lens_lines.clone(); + fake_server.set_request_handler::(move |_, _| { + let lens_lines = lens_lines_for_handler.clone(); + async move { + Ok(Some( + lens_lines + .iter() + .map(|&line| lsp::CodeLens { + range: lsp::Range::new( + lsp::Position::new(line, 0), + lsp::Position::new(line, 10), + ), + command: None, + data: Some(serde_json::json!({ "line": line })), + }) + .collect(), + )) + } + }); + + { + let resolved_lines = resolved_lines.clone(); + fake_server.set_request_handler::( + move |lens, _| { + let resolved_lines = resolved_lines.clone(); + async move { + let line = lens + .data + .as_ref() + .and_then(|d| d.get("line")) + .and_then(|v| v.as_u64()) + .unwrap() as u32; + resolved_lines.lock().unwrap().push(line); + Ok(lsp::CodeLens { + command: Some(lsp::Command { + title: format!("{line} references"), + command: format!("show_refs_{line}"), + arguments: None, + }), + ..lens + }) + } + }, + ); + } + + cx.executor().advance_clock(Duration::from_millis(500)); + cx.run_until_parked(); + + let initial_resolved = resolved_lines + .lock() + .unwrap() + .drain(..) + .collect::>(); + assert_eq!( + initial_resolved, + HashSet::from_iter([0, 10, 20, 30, 40]), + "Only lenses visible at the top should be resolved" + ); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end(&crate::actions::MoveToEnd, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(500)); + cx.run_until_parked(); + + let after_scroll_resolved = resolved_lines + .lock() + .unwrap() + .drain(..) + .collect::>(); + assert_eq!( + after_scroll_resolved, + HashSet::from_iter([60, 70, 80, 90]), + "Only newly visible lenses at the bottom should be resolved, not middle ones" + ); + } +} diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 7cb8040e282a47..dae77579eb6a88 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -144,6 +144,15 @@ pub enum FoldStatus { Foldable, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct NavigationOverlayKey(TypeId); + +impl NavigationOverlayKey { + pub const fn unique() -> Self { + Self(TypeId::of::()) + } +} + /// Keys for tagging text highlights. /// /// Note the order is important as it determines the priority of the highlights, lower means higher priority @@ -168,6 +177,7 @@ pub enum HighlightKey { InlineAssist, InputComposition, MatchingBracket, + NavigationOverlay(NavigationOverlayKey), PendingInput, ProjectSearchView, Rename, @@ -194,12 +204,6 @@ pub struct CompanionExcerptPatch { pub target_excerpt_range: Range, } -pub type ConvertMultiBufferRows = fn( - &MultiBufferSnapshot, - &MultiBufferSnapshot, - Range, -) -> Vec; - /// Decides how text in a [`MultiBuffer`] should be displayed in a buffer, handling inlay hints, /// folding, hard tabs, soft wrapping, custom blocks (like diagnostics), and highlighting. /// @@ -237,26 +241,14 @@ pub struct DisplayMap { pub(crate) struct Companion { rhs_display_map_id: EntityId, - rhs_buffer_to_lhs_buffer: HashMap, - lhs_buffer_to_rhs_buffer: HashMap, - rhs_rows_to_lhs_rows: ConvertMultiBufferRows, - lhs_rows_to_rhs_rows: ConvertMultiBufferRows, rhs_custom_block_to_balancing_block: RefCell>, lhs_custom_block_to_balancing_block: RefCell>, } impl Companion { - pub(crate) fn new( - rhs_display_map_id: EntityId, - rhs_rows_to_lhs_rows: ConvertMultiBufferRows, - lhs_rows_to_rhs_rows: ConvertMultiBufferRows, - ) -> Self { + pub(crate) fn new(rhs_display_map_id: EntityId) -> Self { Self { rhs_display_map_id, - rhs_buffer_to_lhs_buffer: Default::default(), - lhs_buffer_to_rhs_buffer: Default::default(), - rhs_rows_to_lhs_rows, - lhs_rows_to_rhs_rows, rhs_custom_block_to_balancing_block: Default::default(), lhs_custom_block_to_balancing_block: Default::default(), } @@ -284,12 +276,11 @@ impl Companion { our_snapshot: &MultiBufferSnapshot, bounds: Range, ) -> Vec { - let convert_fn = if self.is_rhs(display_map_id) { - self.rhs_rows_to_lhs_rows + if self.is_rhs(display_map_id) { + crate::split::patches_for_rhs_range(companion_snapshot, our_snapshot, bounds) } else { - self.lhs_rows_to_rhs_rows - }; - convert_fn(companion_snapshot, our_snapshot, bounds) + crate::split::patches_for_lhs_range(companion_snapshot, our_snapshot, bounds) + } } pub(crate) fn convert_point_from_companion( @@ -299,17 +290,19 @@ impl Companion { companion_snapshot: &MultiBufferSnapshot, point: MultiBufferPoint, ) -> Range { - let convert_fn = if self.is_rhs(display_map_id) { - self.lhs_rows_to_rhs_rows + let patches = if self.is_rhs(display_map_id) { + crate::split::patches_for_lhs_range(our_snapshot, companion_snapshot, point..point) } else { - self.rhs_rows_to_lhs_rows + crate::split::patches_for_rhs_range(our_snapshot, companion_snapshot, point..point) }; - let excerpt = convert_fn(our_snapshot, companion_snapshot, point..point) - .into_iter() - .next(); - - let Some(excerpt) = excerpt else { + let Some(excerpt) = patches.into_iter().next() else { + if cfg!(any(test, debug_assertions)) { + assert!( + our_snapshot.max_point() == Point::zero(), + "`patches_for_*_in_range` is only allowed to return an empty vec if the multibuffer is empty" + ); + } return Point::zero()..our_snapshot.max_point(); }; excerpt.patch.edit_for_old_position(point).new @@ -322,38 +315,17 @@ impl Companion { companion_snapshot: &MultiBufferSnapshot, point: MultiBufferPoint, ) -> Range { - let convert_fn = if self.is_rhs(display_map_id) { - self.rhs_rows_to_lhs_rows + let patches = if self.is_rhs(display_map_id) { + crate::split::patches_for_rhs_range(companion_snapshot, our_snapshot, point..point) } else { - self.lhs_rows_to_rhs_rows + crate::split::patches_for_lhs_range(companion_snapshot, our_snapshot, point..point) }; - let excerpt = convert_fn(companion_snapshot, our_snapshot, point..point) - .into_iter() - .next(); - - let Some(excerpt) = excerpt else { + let Some(excerpt) = patches.into_iter().next() else { return Point::zero()..companion_snapshot.max_point(); }; excerpt.patch.edit_for_old_position(point).new } - - fn buffer_to_companion_buffer(&self, display_map_id: EntityId) -> &HashMap { - if self.is_rhs(display_map_id) { - &self.rhs_buffer_to_lhs_buffer - } else { - &self.lhs_buffer_to_rhs_buffer - } - } - - pub(crate) fn lhs_to_rhs_buffer(&self, lhs_buffer_id: BufferId) -> Option { - self.lhs_buffer_to_rhs_buffer.get(&lhs_buffer_id).copied() - } - - pub(crate) fn add_buffer_mapping(&mut self, lhs_buffer: BufferId, rhs_buffer: BufferId) { - self.lhs_buffer_to_rhs_buffer.insert(lhs_buffer, rhs_buffer); - self.rhs_buffer_to_lhs_buffer.insert(rhs_buffer, lhs_buffer); - } } #[derive(Default, Debug)] @@ -514,9 +486,12 @@ impl DisplayMap { // entries: the block map doesn't remove buffers from // `folded_buffers` when they leave the multibuffer, so we // unfold any RHS buffers whose companion mapping is missing. + let rhs_snapshot = self.buffer.read(cx).snapshot(cx); let mut buffers_to_unfold = Vec::new(); for my_buffer in self.folded_buffers() { - let their_buffer = companion.read(cx).rhs_buffer_to_lhs_buffer.get(my_buffer); + let their_buffer = rhs_snapshot + .diff_for_buffer_id(*my_buffer) + .map(|diff| diff.base_text().remote_id()); let Some(their_buffer) = their_buffer else { buffers_to_unfold.push(*my_buffer); @@ -526,7 +501,7 @@ impl DisplayMap { companion_display_map .block_map .folded_buffers - .insert(*their_buffer); + .insert(their_buffer); } for buffer_id in buffers_to_unfold { self.block_map.folded_buffers.remove(&buffer_id); @@ -2091,6 +2066,21 @@ impl DisplaySnapshot { DisplayPoint(self.block_snapshot.clip_point(point.0, bias)) } + pub fn inlay_bias_at(&self, point: DisplayPoint) -> Option { + let wrap_point = self.block_snapshot.to_wrap_point(point.0, Bias::Left); + let tab_point = self.block_snapshot.to_tab_point(wrap_point); + let (fold_point, _, _) = self + .block_snapshot + .tab_snapshot + .tab_point_to_fold_point(tab_point, Bias::Left); + let inlay_point = + fold_point.to_inlay_point(&self.block_snapshot.tab_snapshot.fold_snapshot); + self.block_snapshot + .tab_snapshot + .fold_snapshot + .inlay_bias_at_point(inlay_point) + } + pub fn clip_at_line_end(&self, display_point: DisplayPoint) -> DisplayPoint { let mut point = self.display_point_to_point(display_point, Bias::Left); diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 17fa7e3de4a361..45469ab6cf1fea 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -2056,13 +2056,15 @@ impl BlockMapWriter<'_> { if let Some(companion) = &self.companion && companion.inverse.is_some() { - companion_buffer_ids.extend( - companion - .companion - .buffer_to_companion_buffer(companion.display_map_id) - .get(&buffer_id) - .copied(), - ) + if let Some(diff) = multi_buffer_snapshot.diff_for_buffer_id(buffer_id) { + let companion_buffer_id = + if companion.companion.is_rhs(companion.display_map_id) { + diff.base_text().remote_id() + } else { + diff.buffer_id() + }; + companion_buffer_ids.insert(companion_buffer_id); + } } } ranges.sort_unstable_by_key(|range| range.start); @@ -2869,7 +2871,6 @@ mod tests { display_map::{ Companion, fold_map::FoldMap, inlay_map::InlayMap, tab_map::TabMap, wrap_map::WrapMap, }, - split::{convert_lhs_rows_to_rhs, convert_rhs_rows_to_lhs}, test::test_font, }; use buffer_diff::BufferDiff; @@ -4680,13 +4681,7 @@ mod tests { let rhs_entity_id = rhs_multibuffer.entity_id(); - let companion = cx.new(|_| { - Companion::new( - rhs_entity_id, - convert_rhs_rows_to_lhs, - convert_lhs_rows_to_rhs, - ) - }); + let companion = cx.new(|_| Companion::new(rhs_entity_id)); let rhs_edits = Patch::new(vec![text::Edit { old: WrapRow(0)..rhs_wrap_snapshot.max_point().row(), diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 698b58682d7ef7..016417ceed56ca 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -1094,6 +1094,15 @@ impl InlaySnapshot { } } + pub fn inlay_bias_at_point(&self, point: InlayPoint) -> Option { + let mut cursor = self.transforms.cursor::>(()); + cursor.seek(&point, Bias::Left); + match cursor.item() { + Some(Transform::Inlay(inlay)) => Some(inlay.position.bias()), + _ => None, + } + } + #[ztracing::instrument(skip_all)] pub fn text_summary(&self) -> MBTextSummary { self.transforms.summary().output diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index 987801471e5602..8078c90fa597fc 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -1,6 +1,7 @@ use edit_prediction_types::{ EditPredictionDelegate, EditPredictionIconSet, PredictedCursorPosition, }; +use futures::StreamExt; use gpui::{ Entity, KeyBinding, KeybindingKeystroke, Keystroke, Modifiers, NoAction, Task, prelude::*, }; @@ -11,6 +12,7 @@ use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint}; use project::{Completion, CompletionResponse, CompletionSource}; use std::{ ops::Range, + path::PathBuf, rc::Rc, sync::{ Arc, @@ -21,11 +23,11 @@ use text::{Point, ToOffset}; use ui::prelude::*; use crate::{ - AcceptEditPrediction, CompletionContext, CompletionProvider, EditPrediction, + AcceptEditPrediction, CodeContextMenu, CompletionContext, CompletionProvider, EditPrediction, EditPredictionKeybindAction, EditPredictionKeybindSurface, MenuEditPredictionsPolicy, ShowCompletions, editor_tests::{init_test, update_test_language_settings}, - test::editor_test_context::EditorTestContext, + test::{editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext}, }; use rpc::proto::PeerId; use workspace::CollaboratorId; @@ -487,6 +489,43 @@ async fn test_edit_prediction_preview_cleanup_on_toggle_off(cx: &mut gpui::TestA }); } +#[gpui::test] +async fn test_hidden_edit_prediction_does_not_open_snippet_menu_on_word_input( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = hidden_edit_prediction_snippet_test_context(cx).await; + cx.simulate_input("t"); + cx.run_until_parked(); + + cx.update_editor(|editor, _, _| { + assert!(editor.has_active_edit_prediction()); + assert!(editor.context_menu.borrow().is_none()); + }); +} + +#[gpui::test] +async fn test_hidden_edit_prediction_opens_snippet_menu_for_strong_prefix_match( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = hidden_edit_prediction_snippet_test_context(cx).await; + cx.simulate_input("t"); + cx.run_until_parked(); + cx.simulate_input("h"); + cx.run_until_parked(); + + cx.update_editor(|editor, _, _| { + let Some(CodeContextMenu::Completions(menu)) = &*editor.context_menu.borrow() else { + panic!("expected completions menu"); + }; + let entries = menu.entries.borrow(); + assert!(entries.iter().any(|entry| entry.string == "Theta")); + }); +} + #[gpui::test] async fn test_edit_prediction_preview_activates_when_prediction_arrives_with_modifier_held( cx: &mut gpui::TestAppContext, @@ -537,6 +576,172 @@ async fn test_edit_prediction_preview_activates_when_prediction_arrives_with_mod }); } +#[gpui::test] +async fn test_edit_prediction_preview_does_not_hide_code_actions_on_modifier_press( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + update_test_language_settings(cx, &|settings| { + settings.edit_predictions.get_or_insert_default().mode = Some(EditPredictionsMode::Subtle); + }); + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "ctrl-enter", + AcceptEditPrediction, + Some("Editor && edit_prediction && !showing_completions"), + )]); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), + ..Default::default() + }, + cx, + ) + .await; + cx.set_state(indoc! {" + fn main() { + let valueˇ = 1; + } + "}); + + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + cx.update_editor(|editor, window, cx| { + editor.set_edit_prediction_provider(Some(provider.clone()), window, cx); + }); + + let snapshot = cx.buffer_snapshot(); + let edit_position = snapshot.anchor_after(Point::new(1, 13)); + cx.update(|_, cx| { + provider.update(cx, |provider, _| { + provider.set_edit_prediction(Some(edit_prediction_types::EditPrediction::Local { + id: None, + edits: vec![(edit_position..edit_position, " + 1".into())], + cursor_position: None, + edit_preview: None, + })) + }) + }); + cx.update_editor(|editor, window, cx| { + editor.set_menu_edit_predictions_policy(MenuEditPredictionsPolicy::ByProvider); + editor.update_visible_edit_prediction(window, cx); + }); + cx.update_editor(|editor, _, _| { + assert!(editor.has_active_edit_prediction()); + assert!(editor.stale_edit_prediction_in_menu.is_none()); + }); + + let mut code_action_requests = cx.set_request_handler::( + move |_, _, _| async move { + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + lsp::CodeAction { + title: "Inline value".to_string(), + kind: Some(lsp::CodeActionKind::QUICKFIX), + ..Default::default() + }, + )])) + }, + ); + + cx.update_editor(|editor, window, cx| { + editor.toggle_code_actions( + &crate::actions::ToggleCodeActions { + deployed_from: None, + quick_launch: false, + }, + window, + cx, + ); + }); + code_action_requests.next().await; + cx.run_until_parked(); + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + + cx.update_editor(|editor, _, _| { + assert!(!editor.has_active_edit_prediction()); + assert!(editor.stale_edit_prediction_in_menu.is_some()); + assert!(editor.context_menu_visible()); + assert!(matches!( + editor.context_menu.borrow().as_ref(), + Some(crate::code_context_menus::CodeContextMenu::CodeActions(_)) + )); + assert!(!editor.edit_prediction_preview_is_active()); + }); + + cx.simulate_modifiers_change(Modifiers::control()); + cx.run_until_parked(); + + cx.update_editor(|editor, _, _| { + assert!( + !editor.edit_prediction_preview_is_active(), + "modifier-only press should not activate edit prediction preview while code actions are open" + ); + assert!( + editor.context_menu_visible(), + "modifier-only press should not hide the code actions menu" + ); + assert!(matches!( + editor.context_menu.borrow().as_ref(), + Some(crate::code_context_menus::CodeContextMenu::CodeActions(_)) + )); + }); +} + +#[gpui::test] +async fn test_edit_prediction_preview_supersedes_completions_menu(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + update_test_language_settings(cx, &|settings| { + settings.edit_predictions.get_or_insert_default().mode = Some(EditPredictionsMode::Subtle); + }); + cx.update(|cx| { + cx.bind_keys([KeyBinding::new( + "ctrl-enter", + AcceptEditPrediction, + Some("Editor && edit_prediction && showing_completions"), + )]); + }); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + assign_editor_completion_menu_provider(&mut cx); + cx.set_state("let x = ˇ;"); + + propose_edits(&provider, vec![(8..8, "42")], &mut cx); + cx.update_editor(|editor, window, cx| { + editor.set_menu_edit_predictions_policy(MenuEditPredictionsPolicy::ByProvider); + editor.update_visible_edit_prediction(window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.run_until_parked(); + + cx.editor(|editor, _, _| { + assert!(editor.has_active_edit_prediction()); + assert!(editor.context_menu_visible()); + assert!(matches!( + editor.context_menu.borrow().as_ref(), + Some(crate::code_context_menus::CodeContextMenu::Completions(_)) + )); + assert!(!editor.edit_prediction_preview_is_active()); + }); + + cx.simulate_modifiers_change(Modifiers::control()); + cx.run_until_parked(); + + cx.editor(|editor, _, _| { + assert!(editor.edit_prediction_preview_is_active()); + assert!(!editor.context_menu_visible()); + assert!(matches!( + editor.context_menu.borrow().as_ref(), + Some(crate::code_context_menus::CodeContextMenu::Completions(_)) + )); + }); +} + fn load_default_keymap(cx: &mut gpui::TestAppContext) { cx.update(|cx| { cx.bind_keys( @@ -1228,6 +1433,37 @@ fn propose_edits_with_cursor_position_in_insertion( }); } +async fn hidden_edit_prediction_snippet_test_context( + cx: &mut gpui::TestAppContext, +) -> EditorTestContext { + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + cx.update_editor(|editor, _, cx| { + editor.set_menu_edit_predictions_policy(MenuEditPredictionsPolicy::Never); + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + let snippet = project::snippet_provider::Snippet { + prefix: vec!["Theta".to_string(), "turnstile".to_string()], + body: "⊢".to_string(), + description: Some("unicode symbol".to_string()), + name: "unicode snippets".to_string(), + }; + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + }); + }) + }); + cx.set_state("ˇ"); + + propose_edits(&provider, vec![(0..0, "x")], &mut cx); + cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + cx +} + fn assign_editor_completion_provider( provider: Entity, cx: &mut EditorTestContext, @@ -1286,21 +1522,25 @@ impl CompletionProvider for FakeCompletionMenuProvider { _window: &mut Window, cx: &mut Context, ) -> Task>> { - let completion = Completion { - replace_range: text::Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()), - new_text: "fake_completion".to_string(), - label: CodeLabel::plain("fake_completion".to_string(), None), - documentation: None, - source: CompletionSource::Custom, - icon_path: None, - match_start: None, - snippet_deduplication_key: None, - insert_text_mode: None, - confirm: None, - }; + let replace_range = text::Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()); + let completions = ["fake_completion", "fake_completion_2"] + .into_iter() + .map(|label| Completion { + replace_range: replace_range.clone(), + new_text: label.to_string(), + label: CodeLabel::plain(label.to_string(), None), + documentation: None, + source: CompletionSource::Custom, + icon_path: None, + match_start: None, + snippet_deduplication_key: None, + insert_text_mode: None, + confirm: None, + }) + .collect(); Task::ready(Ok(vec![CompletionResponse { - completions: vec![completion], + completions, display_options: Default::default(), is_incomplete: false, }])) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 7897ccf89843a0..b01d1592abb525 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -16,6 +16,7 @@ pub mod blink_manager; mod bracket_colorization; mod clangd_ext; pub mod code_context_menus; +mod code_lens; pub mod display_map; mod document_colors; mod document_symbols; @@ -59,7 +60,7 @@ pub mod test; pub(crate) use actions::*; pub use display_map::{ ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder, HighlightKey, - SemanticTokenHighlight, + NavigationOverlayKey, SemanticTokenHighlight, }; pub use edit_prediction_types::Direction; pub use editor_settings::{ @@ -98,6 +99,7 @@ use code_context_menus::{ AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu, CompletionsMenu, ContextMenuOrigin, }; +use code_lens::CodeLensState; use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; use dap::TelemetrySpawnLocation; @@ -111,7 +113,7 @@ use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings}; use element::{LineWithInvisibles, PositionMap, layout_line}; use futures::{ FutureExt, - future::{self, Shared, join}, + future::{self, Shared}, }; use fuzzy::{StringMatch, StringMatchCandidate}; use git::blame::{GitBlame, GlobalBlameRenderer}; @@ -1125,6 +1127,18 @@ pub(crate) struct DiffReviewOverlay { _subscription: Subscription, } +enum CodeActionsForSelection { + None, + Fetching(Shared>>), + Ready(ActionFetchReady), +} + +#[derive(Clone)] +struct ActionFetchReady { + location: Location, + actions: Rc<[AvailableCodeAction]>, +} + /// Zed's primary implementation of text input, allowing users to edit a [`MultiBuffer`]. /// /// See the [module level documentation](self) for more information. @@ -1199,6 +1213,7 @@ pub struct Editor { highlight_order: usize, highlighted_rows: HashMap>, background_highlights: HashMap, + navigation_overlays: HashMap>, gutter_highlights: HashMap, scrollbar_marker_state: ScrollbarMarkerState, active_indent_guides_state: ActiveIndentGuidesState, @@ -1213,8 +1228,8 @@ pub struct Editor { auto_signature_help: Option, find_all_references_task_sources: Vec, next_completion_id: CompletionId, - available_code_actions: Option<(Location, Rc<[AvailableCodeAction]>)>, - code_actions_task: Option>>, + code_actions_for_selection: CodeActionsForSelection, + runnables_for_selection_toggle: Task<()>, quick_selection_highlight_task: Option<(Range, Task<()>)>, debounced_selection_highlight_task: Option<(Range, Task<()>)>, debounced_selection_highlight_complete: bool, @@ -1338,8 +1353,10 @@ pub struct Editor { selection_drag_state: SelectionDragState, colors: Option, + code_lens: Option, post_scroll_update: Task<()>, refresh_colors_task: Task<()>, + refresh_code_lens_task: Task<()>, use_document_folding_ranges: bool, refresh_folding_ranges_task: Task<()>, inlay_hints: Option, @@ -1418,6 +1435,21 @@ pub struct EditorSnapshot { semantic_tokens_enabled: bool, } +#[derive(Clone, Debug, PartialEq)] +pub struct NavigationTargetOverlay { + pub target_range: Range, + pub label: NavigationOverlayLabel, + pub covered_text_range: Option>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct NavigationOverlayLabel { + pub text: SharedString, + pub text_color: Hsla, + pub x_offset: Pixels, + pub scale_factor: f32, +} + #[derive(Default, Debug, Clone, Copy)] pub struct GutterDimensions { pub left_padding: Pixels, @@ -2162,7 +2194,7 @@ impl Editor { window, |editor, _, event, window, cx| match event { project::Event::RefreshCodeLens => { - // we always query lens with actions, without storing them, always refreshing them + editor.refresh_code_lenses(None, window, cx); } project::Event::RefreshInlayHints { server_id, @@ -2227,7 +2259,7 @@ impl Editor { editor.update_lsp_data(Some(buffer_id), window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); refresh_linked_ranges(editor, window, cx); - editor.refresh_code_actions(window, cx); + editor.refresh_code_actions_for_selection(window, cx); editor.refresh_document_highlights(cx); } } @@ -2459,6 +2491,7 @@ impl Editor { highlight_order: 0, highlighted_rows: HashMap::default(), background_highlights: HashMap::default(), + navigation_overlays: HashMap::default(), gutter_highlights: HashMap::default(), scrollbar_marker_state: ScrollbarMarkerState::default(), active_indent_guides_state: ActiveIndentGuidesState::default(), @@ -2475,8 +2508,8 @@ impl Editor { next_completion_id: 0, next_inlay_id: 0, code_action_providers, - available_code_actions: None, - code_actions_task: None, + code_actions_for_selection: CodeActionsForSelection::None, + runnables_for_selection_toggle: Task::ready(()), quick_selection_highlight_task: None, debounced_selection_highlight_task: None, debounced_selection_highlight_complete: false, @@ -2591,7 +2624,9 @@ impl Editor { runnables: RunnableData::new(), pull_diagnostics_task: Task::ready(()), colors: None, + code_lens: None, refresh_colors_task: Task::ready(()), + refresh_code_lens_task: Task::ready(()), use_document_folding_ranges: false, refresh_folding_ranges_task: Task::ready(()), inlay_hints: None, @@ -2664,7 +2699,7 @@ impl Editor { EditorEvent::ScrollPositionChanged { local, .. } => { if *local { editor.hide_signature_help(cx, SignatureHelpHiddenBy::Escape); - editor.inline_blame_popover.take(); + editor.hide_blame_popover(true, cx); let snapshot = editor.snapshot(window, cx); let new_anchor = editor .scroll_manager @@ -2763,6 +2798,9 @@ impl Editor { editor.colors = Some(LspColorData::new(cx)); editor.use_document_folding_ranges = true; editor.inlay_hints = Some(LspInlayHintData::new(inlay_hint_settings)); + if EditorSettings::get_global(cx).code_lens.inline() { + editor.code_lens = Some(CodeLensState::default()); + } if let Some(buffer) = multi_buffer.read(cx).as_singleton() { editor.register_buffer(buffer.read(cx).remote_id(), cx); @@ -3031,6 +3069,15 @@ impl Editor { window: &mut Window, cx: &mut App, ) -> bool { + let can_supersede_active_menu = + self.context_menu.borrow().as_ref().is_none_or(|menu| { + !menu.visible() || matches!(menu, CodeContextMenu::Completions(_)) + }); + + if !can_supersede_active_menu { + return false; + } + let key_context = self.key_context_internal(true, window, cx); let actions: [&dyn Action; 3] = [ &AcceptEditPrediction, @@ -3787,12 +3834,7 @@ impl Editor { hide_hover(self, cx); - if old_cursor_position.to_display_point(&display_map).row() - != new_cursor_position.to_display_point(&display_map).row() - { - self.available_code_actions.take(); - } - self.refresh_code_actions(window, cx); + self.refresh_code_actions_for_selection(window, cx); self.refresh_document_highlights(cx); refresh_linked_ranges(self, window, cx); @@ -3800,7 +3842,7 @@ impl Editor { self.refresh_matching_bracket_highlights(&display_map, cx); self.refresh_outline_symbols_at_cursor(cx); self.update_visible_edit_prediction(window, cx); - self.inline_blame_popover.take(); + self.hide_blame_popover(true, cx); if self.git_blame_inline_enabled { self.start_inline_blame_timer(window, cx); } @@ -5294,8 +5336,9 @@ impl Editor { let start_point = selection.start.to_point(&buffer); let mut existing_indent = buffer.indent_size_for_line(MultiBufferRow(start_point.row)); + let full_indent_len = existing_indent.len; existing_indent.len = cmp::min(existing_indent.len, start_point.column); - let start = selection.start; + let mut start = selection.start; let end = selection.end; let selection_is_empty = start == end; let language_scope = buffer.language_scope_at(start); @@ -5445,6 +5488,19 @@ impl Editor { } new_text.extend(extra_indent.chars()); } + // Extend the edit to the beginning of the line + // to clear auto-indent whitespace that would + // otherwise remain as trailing whitespace. This + // applies to blank lines and lines where only + // indentation remains before the cursor. + if selection_is_empty + && preserve_indent + && full_indent_len > 0 + && start_point.column == full_indent_len + { + start = buffer.point_to_offset(Point::new(start_point.row, 0)); + } + ( start, new_text, @@ -5814,7 +5870,7 @@ impl Editor { _ => self.open_or_update_completions_menu( None, Some(text.to_owned()).filter(|x| !x.is_empty()), - true, + trigger_in_words, window, cx, ), @@ -6302,8 +6358,15 @@ impl Editor { let provider_responses = if let Some(provider) = &provider && load_provider_completions { - let trigger_character = - trigger.filter(|trigger| buffer.read(cx).completion_triggers().contains(trigger)); + let trigger_character = trigger + .as_ref() + .filter(|trigger| { + buffer + .read(cx) + .completion_triggers() + .contains(trigger.as_str()) + }) + .cloned(); let completion_context = CompletionContext { trigger_kind: match &trigger_character { Some(_) => CompletionTriggerKind::TRIGGER_CHARACTER, @@ -6351,16 +6414,51 @@ impl Editor { Task::ready(BTreeMap::default()) }; + let snippet_char_classifier = buffer_snapshot + .char_classifier_at(buffer_position) + .scope_context(Some(CharScopeContext::Completion)); + let snippets = if let Some(provider) = &provider && provider.show_snippets() && let Some(project) = self.project() { - let char_classifier = buffer_snapshot - .char_classifier_at(buffer_position) - .scope_context(Some(CharScopeContext::Completion)); - project.update(cx, |project, cx| { - snippet_completions(project, &buffer, buffer_position, char_classifier, cx) - }) + let word_trigger = trigger.as_ref().is_some_and(|trigger| { + !trigger.is_empty() + && trigger + .chars() + .all(|character| snippet_char_classifier.is_word(character)) + }); + let requires_strong_snippet_match = !menu_is_open && !trigger_in_words && word_trigger; + let load_snippet_completions = !requires_strong_snippet_match + || query.as_ref().is_some_and(|query| { + let project = project.read(cx); + has_strong_snippet_prefix_match( + &project, + &buffer, + buffer_position, + &snippet_char_classifier, + query, + cx, + ) + }); + + if load_snippet_completions { + project.update(cx, |project, cx| { + snippet_completions( + project, + &buffer, + buffer_position, + snippet_char_classifier, + cx, + ) + }) + } else { + Task::ready(Ok(CompletionResponse { + completions: Vec::new(), + display_options: Default::default(), + is_incomplete: false, + })) + } } else { Task::ready(Ok(CompletionResponse { completions: Vec::new(), @@ -6876,6 +6974,9 @@ impl Editor { })) } + /// Toggles an action selection menu for the latest selection. + /// May show LSP code actions, code lens' command, runnables and potentially more entities applicable as actions. + /// Previous menu toggled with this method will be closed. pub fn toggle_code_actions( &mut self, action: &ToggleCodeActions, @@ -6930,16 +7031,7 @@ impl Editor { .runnables((buffer_id, buffer_row)) .map(|t| Arc::new(t.to_owned())); - if !self.focus_handle.is_focused(window) { - return; - } let project = self.project.clone(); - - let code_actions_task = match deployed_from { - Some(CodeActionSource::RunMenu(_)) => Task::ready(None), - _ => self.code_actions(buffer_row, window, cx), - }; - let runnable_task = match deployed_from { Some(CodeActionSource::Indicator(_)) => Task::ready(Ok(Default::default())), _ => { @@ -6977,19 +7069,42 @@ impl Editor { } }; - cx.spawn_in(window, async move |editor, cx| { + let toggle_task = cx.spawn_in(window, async move |editor, cx| { let (resolved_tasks, debug_scenarios, task_context) = runnable_task.await?; - let code_actions = code_actions_task.await; - let spawn_straight_away = quick_launch - && resolved_tasks - .as_ref() - .is_some_and(|tasks| tasks.templates.len() == 1) - && code_actions - .as_ref() - .is_none_or(|actions| actions.is_empty()) - && debug_scenarios.is_empty(); + + let code_actions = if let Some(CodeActionSource::RunMenu(_)) = &deployed_from { + None + } else { + editor.update(cx, |editor, _cx| match &editor.code_actions_for_selection { + CodeActionsForSelection::None => None, + CodeActionsForSelection::Fetching(task) => Some(task.clone()), + CodeActionsForSelection::Ready(action_fetch_ready) => { + Some(Task::ready(Some(action_fetch_ready.clone())).shared()) + } + })? + }; + let code_actions = match code_actions { + Some(code_actions) => code_actions + .await + .filter(|ActionFetchReady { location, .. }| { + let snapshot = location.buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let point_range = location.range.to_point(&snapshot); + (point_range.start.row..=point_range.end.row).contains(&buffer_row) + }) + .map(|ActionFetchReady { actions, .. }| actions), + None => None, + }; editor.update_in(cx, |editor, window, cx| { + let spawn_straight_away = quick_launch + && resolved_tasks + .as_ref() + .is_some_and(|tasks| tasks.templates.len() == 1) + && code_actions + .as_ref() + .is_none_or(|actions| actions.is_empty()) + && debug_scenarios.is_empty(); + crate::hover_popover::hide_hover(editor, cx); let actions = CodeActionContents::new( resolved_tasks, @@ -7025,8 +7140,16 @@ impl Editor { Task::ready(Ok(())) }) + }); + self.runnables_for_selection_toggle = cx.background_spawn(async move { + match toggle_task.await { + Ok(code_action_spawn) => match code_action_spawn.await { + Ok(()) => {} + Err(e) => log::error!("failed to spawn a toggled code action: {e:#}"), + }, + Err(e) => log::error!("failed to toggle code actions: {e:#}"), + } }) - .detach_and_log_err(cx); } fn debug_scenarios( @@ -7070,42 +7193,6 @@ impl Editor { .unwrap_or_else(|| Task::ready(vec![])) } - fn code_actions( - &mut self, - buffer_row: u32, - window: &mut Window, - cx: &mut Context, - ) -> Task>> { - let mut task = self.code_actions_task.take(); - cx.spawn_in(window, async move |editor, cx| { - while let Some(prev_task) = task { - prev_task.await.log_err(); - task = editor - .update(cx, |this, _| this.code_actions_task.take()) - .ok()?; - } - - editor - .update(cx, |editor, cx| { - editor - .available_code_actions - .clone() - .and_then(|(location, code_actions)| { - let snapshot = location.buffer.read(cx).snapshot(); - let point_range = location.range.to_point(&snapshot); - let point_range = point_range.start.row..=point_range.end.row; - if point_range.contains(&buffer_row) { - Some(code_actions) - } else { - None - } - }) - }) - .ok() - .flatten() - }) - } - pub fn confirm_code_action( &mut self, action: &ConfirmCodeAction, @@ -7142,6 +7229,10 @@ impl Editor { }) } CodeActionsItem::CodeAction { action, provider } => { + if code_lens::try_handle_client_command(&action, self, &workspace, window, cx) { + return Some(Task::ready(Ok(()))); + } + let apply_code_action = provider.apply_code_action(buffer, action, true, window, cx); let workspace = workspace.downgrade(); @@ -7315,11 +7406,6 @@ impl Editor { Ok(()) } - pub fn clear_code_action_providers(&mut self) { - self.code_action_providers.clear(); - self.available_code_actions.take(); - } - pub fn add_code_action_provider( &mut self, provider: Rc, @@ -7335,7 +7421,7 @@ impl Editor { } self.code_action_providers.push(provider); - self.refresh_code_actions(window, cx); + self.refresh_code_actions_for_selection(window, cx); } pub fn remove_code_action_provider( @@ -7346,7 +7432,7 @@ impl Editor { ) { self.code_action_providers .retain(|provider| provider.id() != id); - self.refresh_code_actions(window, cx); + self.refresh_code_actions_for_selection(window, cx); } pub fn code_actions_enabled_for_toolbar(&self, cx: &App) -> bool { @@ -7354,10 +7440,12 @@ impl Editor { && EditorSettings::get_global(cx).toolbar.code_actions } - pub fn has_available_code_actions(&self) -> bool { - self.available_code_actions - .as_ref() - .is_some_and(|(_, actions)| !actions.is_empty()) + pub fn has_available_code_actions_for_selection(&self) -> bool { + if let CodeActionsForSelection::Ready(ready) = &self.code_actions_for_selection { + !ready.actions.is_empty() + } else { + false + } } fn render_inline_code_actions( @@ -7409,73 +7497,88 @@ impl Editor { &self.context_menu } - fn refresh_code_actions(&mut self, window: &mut Window, cx: &mut Context) { - self.code_actions_task = Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor() - .timer(CODE_ACTIONS_DEBOUNCE_TIMEOUT) - .await; + fn refresh_code_actions_for_selection(&mut self, window: &mut Window, cx: &mut Context) { + self.code_actions_for_selection = CodeActionsForSelection::Fetching( + cx.spawn_in(window, async move |editor, cx| { + cx.background_executor() + .timer(CODE_ACTIONS_DEBOUNCE_TIMEOUT) + .await; - let (start_buffer, start, _, end, _newest_selection) = this - .update(cx, |this, cx| { - let newest_selection = this.selections.newest_anchor().clone(); - if newest_selection.head().diff_base_anchor().is_some() { - return None; - } - let display_snapshot = this.display_snapshot(cx); - let newest_selection_adjusted = - this.selections.newest_adjusted(&display_snapshot); - let buffer = this.buffer.read(cx); + let (start_buffer, start, _, end, _newest_selection) = editor + .update(cx, |editor, cx| { + let newest_selection = editor.selections.newest_anchor().clone(); + if newest_selection.head().diff_base_anchor().is_some() { + return None; + } + let display_snapshot = editor.display_snapshot(cx); + let newest_selection_adjusted = + editor.selections.newest_adjusted(&display_snapshot); + let buffer = editor.buffer.read(cx); - let (start_buffer, start) = - buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; - let (end_buffer, end) = - buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; + let (start_buffer, start) = + buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; + let (end_buffer, end) = + buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; - Some((start_buffer, start, end_buffer, end, newest_selection)) - })? - .filter(|(start_buffer, _, end_buffer, _, _)| start_buffer == end_buffer) - .context( - "Expected selection to lie in a single buffer when refreshing code actions", - )?; - let (providers, tasks) = this.update_in(cx, |this, window, cx| { - let providers = this.code_action_providers.clone(); - let tasks = this - .code_action_providers - .iter() - .map(|provider| provider.code_actions(&start_buffer, start..end, window, cx)) - .collect::>(); - (providers, tasks) - })?; + Some((start_buffer, start, end_buffer, end, newest_selection)) + }) + .ok() + .flatten() + .filter(|(start_buffer, _, end_buffer, _, _)| start_buffer == end_buffer)?; - let mut actions = Vec::new(); - for (provider, provider_actions) in - providers.into_iter().zip(future::join_all(tasks).await) - { - if let Some(provider_actions) = provider_actions.log_err() { - actions.extend(provider_actions.into_iter().map(|action| { - AvailableCodeAction { - action, - provider: provider.clone(), - } - })); + let (providers, tasks) = editor + .update_in(cx, |editor, window, cx| { + let providers = editor.code_action_providers.clone(); + let tasks = editor + .code_action_providers + .iter() + .map(|provider| { + provider.code_actions(&start_buffer, start..end, window, cx) + }) + .collect::>(); + (providers, tasks) + }) + .ok()?; + + let mut actions = Vec::new(); + for (provider, provider_actions) in + providers.into_iter().zip(future::join_all(tasks).await) + { + if let Some(provider_actions) = provider_actions.log_err() { + actions.extend(provider_actions.into_iter().map(|action| { + AvailableCodeAction { + action, + provider: provider.clone(), + } + })); + } } - } - this.update(cx, |this, cx| { - this.available_code_actions = if actions.is_empty() { - None - } else { - Some(( - Location { - buffer: start_buffer, - range: start..end, - }, - actions.into(), - )) - }; - cx.notify(); + editor + .update(cx, |editor, cx| { + let new_actions = if actions.is_empty() { + editor.code_actions_for_selection = CodeActionsForSelection::None; + None + } else { + let new_actions = ActionFetchReady { + location: Location { + buffer: start_buffer, + range: start..end, + }, + actions: Rc::from(actions), + }; + editor.code_actions_for_selection = + CodeActionsForSelection::Ready(new_actions.clone()); + Some(new_actions) + }; + cx.notify(); + new_actions + }) + .ok() + .flatten() }) - })); + .shared(), + ); } fn start_inline_blame_timer(&mut self, window: &mut Window, cx: &mut Context) { @@ -7598,23 +7701,36 @@ impl Editor { self.mouse_context_menu.is_some() } + /// Hides the inline blame popover element, in case it's already visible, or + /// interrupts the task meant to show it, in case the task is running. + /// + /// When `ignore_timeout` is set to `true`, the popover is hidden + /// immediately, otherwise it'll be hidden after a short delay. + /// + /// Returns `true` if the popover was visible and was hidden, `false` + /// otherwise. pub fn hide_blame_popover(&mut self, ignore_timeout: bool, cx: &mut Context) -> bool { self.inline_blame_popover_show_task.take(); + if let Some(state) = &mut self.inline_blame_popover { - let hide_task = cx.spawn(async move |editor, cx| { - if !ignore_timeout { + if ignore_timeout { + self.inline_blame_popover.take(); + cx.notify(); + } else { + state.hide_task = Some(cx.spawn(async move |editor, cx| { cx.background_executor() .timer(std::time::Duration::from_millis(100)) .await; - } - editor - .update(cx, |editor, cx| { - editor.inline_blame_popover.take(); - cx.notify(); - }) - .ok(); - }); - state.hide_task = Some(hide_task); + + editor + .update(cx, |editor, cx| { + editor.inline_blame_popover.take(); + cx.notify(); + }) + .ok(); + })); + } + true } else { false @@ -24597,6 +24713,64 @@ impl Editor { self.display_map.read(cx).text_highlights(key) } + pub fn set_navigation_overlays( + &mut self, + key: NavigationOverlayKey, + overlays: Vec, + cx: &mut Context, + ) { + let buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let mut covered_text_ranges = overlays + .iter() + .filter_map(|overlay| overlay.covered_text_range.clone()) + .collect::>(); + covered_text_ranges.sort_by(|left, right| { + left.start + .cmp(&right.start, &buffer_snapshot) + .then_with(|| left.end.cmp(&right.end, &buffer_snapshot)) + }); + + self.display_map.update(cx, |map, cx| { + map.clear_highlights(HighlightKey::NavigationOverlay(key)); + if !covered_text_ranges.is_empty() { + map.highlight_text( + HighlightKey::NavigationOverlay(key), + covered_text_ranges, + HighlightStyle { + fade_out: Some(1.0), + ..Default::default() + }, + false, + cx, + ); + } + }); + + if overlays.is_empty() { + self.navigation_overlays.remove(&key); + } else { + self.navigation_overlays.insert(key, Arc::from(overlays)); + } + + cx.notify(); + } + + pub fn clear_navigation_overlays(&mut self, key: NavigationOverlayKey, cx: &mut Context) { + let removed = self.navigation_overlays.remove(&key).is_some(); + let cleared = self.display_map.update(cx, |map, _| { + map.clear_highlights(HighlightKey::NavigationOverlay(key)) + }); + if removed || cleared { + cx.notify(); + } + } + + pub(crate) fn navigation_overlay_sets( + &self, + ) -> &HashMap> { + &self.navigation_overlays + } + pub fn clear_highlights(&mut self, key: HighlightKey, cx: &mut Context) { let cleared = self .display_map @@ -24744,7 +24918,7 @@ impl Editor { self.scrollbar_marker_state.dirty = true; self.active_indent_guides_state.dirty = true; self.refresh_active_diagnostics(cx); - self.refresh_code_actions(window, cx); + self.refresh_code_actions_for_selection(window, cx); self.refresh_single_line_folds(window, cx); let snapshot = self.snapshot(window, cx); self.refresh_matching_bracket_highlights(&snapshot, cx); @@ -25084,6 +25258,12 @@ impl Editor { self.refresh_document_colors(None, window, cx); } + let code_lens_inline = EditorSettings::get_global(cx).code_lens.inline(); + let was_inline = self.code_lens.is_some(); + if code_lens_inline != was_inline { + self.toggle_code_lens(code_lens_inline, window, cx); + } + self.refresh_inlay_hints( InlayHintRefreshReason::SettingsChange(inlay_hint_settings( self.selections.newest_anchor().head(), @@ -26256,6 +26436,7 @@ impl Editor { self.refresh_semantic_tokens(for_buffer, None, cx); self.refresh_document_colors(for_buffer, window, cx); self.refresh_folding_ranges(for_buffer, window, cx); + self.refresh_code_lenses(for_buffer, window, cx); self.refresh_document_symbols(for_buffer, cx); } @@ -26426,6 +26607,7 @@ impl Editor { self.register_visible_buffers(cx); self.colorize_brackets(false, cx); self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + self.resolve_visible_code_lenses(cx); if !self.buffer().read(cx).is_singleton() || self.needs_initial_data_update { self.needs_initial_data_update = false; self.update_lsp_data(None, window, cx); @@ -27682,21 +27864,22 @@ impl CodeActionProvider for Entity { cx: &mut App, ) -> Task>> { self.update(cx, |project, cx| { - let code_lens_actions = project.code_lens_actions(buffer, range.clone(), cx); + let code_lens_actions = if EditorSettings::get_global(cx).code_lens.show_in_menu() { + Some(project.code_lens_actions(buffer, range.clone(), cx)) + } else { + None + }; let code_actions = project.code_actions(buffer, range, None, cx); cx.background_spawn(async move { - let (code_lens_actions, code_actions) = join(code_lens_actions, code_actions).await; - Ok(code_lens_actions - .context("code lens fetch")? - .into_iter() - .flatten() - .chain( - code_actions - .context("code action fetch")? - .into_iter() - .flatten(), - ) - .collect()) + let code_lens_actions = match code_lens_actions { + Some(task) => task.await.context("code lens fetch")?.unwrap_or_default(), + None => Vec::new(), + }; + let code_actions = code_actions + .await + .context("code action fetch")? + .unwrap_or_default(); + Ok(code_lens_actions.into_iter().chain(code_actions).collect()) }) }) } @@ -27715,6 +27898,33 @@ impl CodeActionProvider for Entity { } } +fn has_strong_snippet_prefix_match( + project: &Project, + buffer: &Entity, + buffer_anchor: text::Anchor, + classifier: &CharClassifier, + query: &str, + cx: &App, +) -> bool { + if query.chars().take(2).count() < 2 { + return false; + } + + let query = query.to_lowercase(); + let is_word_char = |character| classifier.is_word(character); + let languages = buffer.read(cx).languages_at(buffer_anchor); + let snippet_store = project.snippets().read(cx); + + languages.iter().any(|language| { + snippet_store + .snippets_for(Some(language.lsp_id()), cx) + .iter() + .flat_map(|snippet| snippet.prefix.iter()) + .flat_map(|prefix| snippet_candidate_suffixes(prefix, &is_word_char)) + .any(|candidate| candidate.to_lowercase().starts_with(&query)) + }) +} + fn snippet_completions( project: &Project, buffer: &Entity, @@ -29378,7 +29588,7 @@ pub fn diagnostic_style(severity: lsp::DiagnosticSeverity, colors: &StatusColors lsp::DiagnosticSeverity::ERROR => colors.error, lsp::DiagnosticSeverity::WARNING => colors.warning, lsp::DiagnosticSeverity::INFORMATION => colors.info, - lsp::DiagnosticSeverity::HINT => colors.info, + lsp::DiagnosticSeverity::HINT => colors.hint, _ => colors.ignored, } } diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index e70dd137ba3820..b35bce02af4a56 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -4,7 +4,7 @@ use gpui::App; use language::CursorShape; use project::project_settings::DiagnosticSeverity; pub use settings::{ - CompletionDetailAlignment, CurrentLineHighlight, DelayMs, DiffViewStyle, DisplayIn, + CodeLens, CompletionDetailAlignment, CurrentLineHighlight, DelayMs, DiffViewStyle, DisplayIn, DocumentColorsRenderMode, DoubleClickInMultibuffer, GoToDefinitionFallback, HideMouseMode, MinimapThumb, MinimapThumbBorder, MultiCursorModifier, ScrollBeyondLastLine, ScrollbarDiagnostics, SeedQuerySetting, ShowMinimap, SnippetSortOrder, @@ -58,6 +58,7 @@ pub struct EditorSettings { pub diagnostics_max_severity: Option, pub inline_code_actions: bool, pub drag_and_drop_selection: DragAndDropSelection, + pub code_lens: CodeLens, pub lsp_document_colors: DocumentColorsRenderMode, pub minimum_contrast_for_highlights: f32, pub completion_menu_scrollbar: ShowScrollbar, @@ -295,6 +296,7 @@ impl Settings for EditorSettings { enabled: drag_and_drop_selection.enabled.unwrap(), delay: drag_and_drop_selection.delay.unwrap(), }, + code_lens: editor.code_lens.unwrap(), lsp_document_colors: editor.lsp_document_colors.unwrap(), minimum_contrast_for_highlights: editor.minimum_contrast_for_highlights.unwrap().0, completion_menu_scrollbar: editor diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 20eb17a2da9ba8..647f40a95c3fca 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3553,6 +3553,81 @@ fn test_newline(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_newline_trailing_whitespace(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(settings::AutoIndentMode::PreserveIndent); + }); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(" hello\n world\n", cx)); + let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) + ]) + }); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), " hello\n \n world\n"); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), " hello\n\n \n world\n"); + }) + .unwrap(); + + buffer.update(cx, |buffer, cx| { + let start = MultiBufferOffset(0); + let end = buffer.len(cx); + buffer.edit([(start..end, " hello\n world\n")], None, cx); + }); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7) + ]) + }); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), " hel\n lo\n world\n"); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), " hel\n\n lo\n world\n"); + }) + .unwrap(); + + update_test_language_settings(cx, &|settings| { + settings.defaults.tab_size = NonZeroU32::new(4); + settings.defaults.hard_tabs = Some(true); + }); + + buffer.update(cx, |buffer, cx| { + let start = MultiBufferOffset(0); + let end = buffer.len(cx); + buffer.edit([(start..end, "\thello\n\tworld\n")], None, cx); + }); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) + ]) + }); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), "\thello\n\t\n\tworld\n"); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), "\thello\n\n\t\n\tworld\n"); + }) + .unwrap(); +} + #[gpui::test] async fn test_newline_yaml(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -28321,6 +28396,9 @@ async fn test_tree_sitter_brackets_newline_insertion(cx: &mut TestAppContext) { #[gpui::test(iterations = 10)] async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(settings::CodeLens::Menu); + }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( @@ -28413,15 +28491,6 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex }), data: None, }, - lsp::CodeLens { - range: lsp::Range::default(), - command: Some(lsp::Command { - title: "Command not in capabilities".to_owned(), - command: "not in capabilities".to_owned(), - arguments: None, - }), - data: None, - }, lsp::CodeLens { range: lsp::Range { start: lsp::Position { @@ -28936,29 +29005,36 @@ println!("5"); }); } -#[gpui::test] -async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) { - struct EmptyModalView { - focus_handle: gpui::FocusHandle, - } - impl EventEmitter for EmptyModalView {} - impl Render for EmptyModalView { - fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement { - div() - } +struct EmptyModalView { + focus_handle: gpui::FocusHandle, +} + +impl EventEmitter for EmptyModalView {} + +impl Render for EmptyModalView { + fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement { + div() } - impl Focusable for EmptyModalView { - fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } +} + +impl Focusable for EmptyModalView { + fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { + self.focus_handle.clone() } - impl workspace::ModalView for EmptyModalView {} - fn new_empty_modal_view(cx: &App) -> EmptyModalView { - EmptyModalView { +} + +impl workspace::ModalView for EmptyModalView {} + +impl EmptyModalView { + fn new(cx: &App) -> Self { + Self { focus_handle: cx.focus_handle(), } } +} +#[gpui::test] +async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); @@ -28987,7 +29063,7 @@ async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) { assert!(editor.mouse_context_menu.is_some()); }); workspace.update_in(cx, |workspace, window, cx| { - workspace.toggle_modal(window, cx, |_, cx| new_empty_modal_view(cx)); + workspace.toggle_modal(window, cx, |_, cx| EmptyModalView::new(cx)); }); cx.read(|cx| { @@ -28995,6 +29071,72 @@ async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_hide_pending_blame_popover_when_modal_opens(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let multi_buffer = cx.update(|cx| MultiBuffer::build_simple("Buffer Contents!", cx)); + let buffer_id = multi_buffer.read_with(cx, |multi_buffer, cx| { + multi_buffer + .all_buffers_iter() + .next() + .expect("Should have at least one buffer") + .read(cx) + .remote_id() + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + let editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + + editor.update_in(cx, |editor, _, cx| { + editor.blame = Some( + cx.new(|cx| GitBlame::new(editor.buffer.clone(), project.clone(), false, true, cx)), + ); + editor.show_blame_popover( + buffer_id, + &::git::blame::BlameEntry { + sha: "1b1b1b".parse().unwrap(), + range: 0..1, + ..Default::default() + }, + gpui::point(gpui::px(0.), gpui::px(0.)), + false, + cx, + ); + + assert!(editor.inline_blame_popover_show_task.is_some()); + assert!(editor.inline_blame_popover.is_none()); + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |_, cx| EmptyModalView::new(cx)); + }); + + // Toggling a modal while the blame popover task is still pending should + // clear both the task and any rendered popover. + editor.update_in(cx, |editor, _, _| { + assert!(editor.inline_blame_popover.is_none()); + assert!(editor.inline_blame_popover_show_task.is_none()); + }); +} + fn set_linked_edit_ranges( opening: (Point, Point), closing: (Point, Point), @@ -31358,6 +31500,96 @@ async fn test_inlay_hints_request_timeout(cx: &mut TestAppContext) { .unwrap(); } +#[gpui::test] +async fn test_click_on_parameter_inlay_hint_places_cursor_correctly(cx: &mut TestAppContext) { + use crate::inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}; + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, &|settings: &mut SettingsContent| { + settings.project.all_languages.defaults.inlay_hints = + Some(InlayHintSettingsContent { + enabled: Some(true), + show_parameter_hints: Some(true), + show_type_hints: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + ..Default::default() + }) + }); + }); + }); + + cx.set_state("fn foo(value: i32) {} fn main() { foo(ˇ42); }"); + + // Buffer: `fn foo(value: i32) {} fn main() { foo(42); }` + // The parameter hint "value:" appears before "42" + let hint_start_offset = cx.ranges("fn foo(value: i32) {} fn main() { foo(ˇ42); }")[0].start; + let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset)); + let hint_label = "value:"; + let expected_uri = cx.buffer_lsp_url.clone(); + cx.lsp + .set_request_handler::(move |params, _| { + let expected_uri = expected_uri.clone(); + async move { + assert_eq!(params.text_document.uri, expected_uri); + Ok(Some(vec![lsp::InlayHint { + position: hint_position, + label: lsp::InlayHintLabel::String(hint_label.to_string()), + kind: Some(lsp::InlayHintKind::PARAMETER), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: Some(true), + data: None, + }])) + } + }) + .next() + .await; + cx.background_executor.run_until_parked(); + + cx.update_editor(|editor, _window, cx| { + let expected_labels = vec!["value: ".to_string()]; + assert_eq!(expected_labels, cached_hint_labels(editor, cx)); + assert_eq!(expected_labels, visible_hint_labels(editor, cx)); + }); + + // The cursor is at `4` in `42`. The parameter hint "value: " appears just + // before it in display space. We'll click a few characters to the left of + // the cursor position to land inside the inlay hint text. + let cursor_display_point = cx.update_editor(|editor, _window, cx| { + editor + .selections + .newest_display(&editor.display_snapshot(cx)) + .head() + }); + let cursor_pixel = cx.pixel_position_for(cursor_display_point); + let em_width = + cx.update_editor(|editor, _, _| editor.last_position_map.as_ref().unwrap().em_layout_width); + // Click 3 characters to the left of the cursor, which lands inside the + // "value: " inlay hint text. + let click_position = gpui::Point { + x: cursor_pixel.x - em_width * 3.0, + y: cursor_pixel.y, + }; + cx.simulate_click(click_position, Modifiers::none()); + cx.background_executor.run_until_parked(); + + // The cursor should be placed after the `(`, at the `4` in `42`, + // NOT before the `(`. + cx.assert_editor_state("fn foo(value: i32) {} fn main() { foo(ˇ42); }"); +} + #[gpui::test] async fn test_newline_replacement_in_single_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -32455,6 +32687,80 @@ async fn test_no_duplicated_sticky_headers(cx: &mut TestAppContext) { assert_eq!(sticky_headers(5.0), vec![]); } +#[gpui::test] +async fn test_autoscroll_keeps_cursor_visible_below_sticky_headers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.vertical_scroll_margin = Some(0.0); + settings.scroll_beyond_last_line = Some(ScrollBeyondLastLine::OnePage); + settings.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }); + }); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + impl Foo { fn bar() { + let x = 1; + fn baz() { + let y = 2; + } + } } + ˇ + "}); + + let mut previous_cursor_row = cx.update_editor(|editor, window, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + let cursor_row = editor + .selections + .newest_display(&editor.display_snapshot(cx)) + .head() + .row(); + editor.set_scroll_top_row(cursor_row, window, cx); + cursor_row + }); + + for _ in 0..6 { + cx.update_editor(|editor, window, cx| editor.move_up(&MoveUp, window, cx)); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let scroll_top = snapshot.scroll_position().y; + let sticky_header_count = EditorElement::sticky_headers(editor, &snapshot).len(); + let cursor_row = editor + .selections + .newest_display(&snapshot.display_snapshot) + .head() + .row(); + assert_eq!( + cursor_row, + previous_cursor_row + .previous_row() + .max(DisplayRow(scroll_top as u32) + DisplayRow(sticky_header_count as u32)) + ); + previous_cursor_row = cursor_row; + }); + + // The `ScrollCursorTop` action shouldn't change the scroll position, as the cursor is + // already as high up as the sticky headers allow. + let scroll_top_before = + cx.update_editor(|editor, window, cx| editor.snapshot(window, cx).scroll_position().y); + cx.update_editor(|editor, window, cx| { + editor.scroll_cursor_top(&ScrollCursorTop, window, cx) + }); + cx.run_until_parked(); + let scroll_top_after = + cx.update_editor(|editor, window, cx| editor.snapshot(window, cx).scroll_position().y); + assert_eq!(scroll_top_before, scroll_top_after); + } +} + #[gpui::test] fn test_relative_line_numbers(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 2875ac50f7aa87..0646bf7a0683ad 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -40,15 +40,15 @@ use file_icons::FileIcons; use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; use gpui::{ Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, - Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, - DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, Font, FontId, - FontWeight, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, - IsZero, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, - MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, - PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, - StatefulInteractiveElement, Style, Styled, StyledText, TextAlign, TextRun, TextStyleRefinement, - WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, - pattern_slash, point, px, quad, relative, size, solid_background, transparent_black, + Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corners, CursorStyle, DispatchPhase, + Edges, Element, ElementInputHandler, Entity, Focusable as _, Font, FontId, FontWeight, + GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Length, + Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, + MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, PressureStage, ScrollDelta, + ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, + Style, Styled, StyledText, TextAlign, TextRun, TextStyleRefinement, WeakEntity, Window, + anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, pattern_slash, + point, px, quad, relative, size, solid_background, transparent_black, }; use itertools::Itertools; use language::{ @@ -502,6 +502,7 @@ impl EditorElement { register_action(editor, window, Editor::toggle_relative_line_numbers); register_action(editor, window, Editor::toggle_indent_guides); register_action(editor, window, Editor::toggle_inlay_hints); + register_action(editor, window, Editor::toggle_code_lens_action); register_action(editor, window, Editor::toggle_semantic_highlights); register_action(editor, window, Editor::toggle_edit_predictions); if editor.read(cx).diagnostics_enabled() { @@ -843,7 +844,7 @@ impl EditorElement { } } - let position = point_for_position.previous_valid; + let position = point_for_position.nearest_valid; if let Some(mode) = Editor::columnar_selection_mode(&modifiers, cx) { editor.select( SelectPhase::BeginColumnar { @@ -898,7 +899,7 @@ impl EditorElement { { let point_for_position = position_map.point_for_position(event.position); editor.set_gutter_context_menu( - point_for_position.previous_valid.row(), + point_for_position.nearest_valid.row(), None, event.position, window, @@ -916,7 +917,7 @@ impl EditorElement { mouse_context_menu::deploy_context_menu( editor, Some(event.position), - point_for_position.previous_valid, + point_for_position.nearest_valid, window, cx, ); @@ -935,7 +936,7 @@ impl EditorElement { } let point_for_position = position_map.point_for_position(event.position); - let position = point_for_position.previous_valid; + let position = point_for_position.nearest_valid; editor.select( SelectPhase::BeginColumnar { @@ -977,7 +978,7 @@ impl EditorElement { if event.position == *click_position { editor.select( SelectPhase::Begin { - position: point_for_position.previous_valid, + position: point_for_position.nearest_valid, add: false, click_count: 1, // ready to drag state only occurs on click count 1 }, @@ -1001,7 +1002,7 @@ impl EditorElement { || cfg!(not(target_os = "macos")) && event.modifiers.control); editor.move_selection_on_drop( &selection.clone(), - point_for_position.previous_valid, + point_for_position.nearest_valid, is_cut, window, cx, @@ -1037,7 +1038,7 @@ impl EditorElement { if EditorSettings::get_global(cx).middle_click_paste { if let Some(text) = cx.read_from_primary().and_then(|item| item.text()) { let point_for_position = position_map.point_for_position(event.position); - let position = point_for_position.previous_valid; + let position = point_for_position.nearest_valid; editor.select( SelectPhase::Begin { @@ -1166,7 +1167,7 @@ impl EditorElement { if !editor.has_pending_selection() { let drop_anchor = position_map .snapshot - .display_point_to_anchor(point_for_position.previous_valid, Bias::Left); + .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); match editor.selection_drag_state { SelectionDragState::Dragging { ref mut drop_cursor, @@ -1210,7 +1211,7 @@ impl EditorElement { editor.selection_drag_state = SelectionDragState::None; editor.select( SelectPhase::Begin { - position: click_point.previous_valid, + position: click_point.nearest_valid, add: false, click_count: 1, }, @@ -1219,7 +1220,7 @@ impl EditorElement { ); editor.select( SelectPhase::Update { - position: point_for_position.previous_valid, + position: point_for_position.nearest_valid, goal_column: point_for_position.exact_unclipped.column(), scroll_delta, }, @@ -1233,7 +1234,7 @@ impl EditorElement { } else { editor.select( SelectPhase::Update { - position: point_for_position.previous_valid, + position: point_for_position.nearest_valid, goal_column: point_for_position.exact_unclipped.column(), scroll_delta, }, @@ -1260,7 +1261,7 @@ impl EditorElement { editor.show_mouse_cursor(cx); let point_for_position = position_map.point_for_position(event.position); - let valid_point = point_for_position.previous_valid; + let valid_point = point_for_position.nearest_valid; // Update diff review drag state if we're dragging if editor.diff_review_drag_state.is_some() { @@ -1953,6 +1954,106 @@ impl EditorElement { cursor_layouts } + fn layout_navigation_overlays( + &self, + snapshot: &EditorSnapshot, + visible_display_row_range: Range, + line_layouts: &[LineWithInvisibles], + text_hitbox: &Hitbox, + content_origin: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, + line_height: Pixels, + window: &mut Window, + cx: &mut App, + ) -> Vec { + let mut overlay_sets = self + .editor + .read(cx) + .navigation_overlay_sets() + .iter() + .map(|(key, overlays)| (*key, overlays.clone())) + .collect::>(); + if overlay_sets.is_empty() { + return Vec::new(); + } + overlay_sets.sort_by_key(|(key, _)| *key); + + let layout_context = NavigationOverlayLayoutContext { + display_snapshot: &snapshot.display_snapshot, + visible_display_row_range: &visible_display_row_range, + line_layouts, + text_align: self.style.text.text_align, + content_width: text_hitbox.size.width, + content_origin, + scroll_position, + scroll_pixel_position, + line_height, + editor_font: self.style.text.font(), + editor_font_size: self.style.text.font_size.to_pixels(window.rem_size()), + }; + let mut navigation_overlay_paint_commands = Vec::new(); + + for (_, overlays) in overlay_sets { + for overlay in overlays.as_ref() { + Self::layout_navigation_label( + overlay, + &layout_context, + window, + cx, + &mut navigation_overlay_paint_commands, + ); + } + } + + navigation_overlay_paint_commands + } + + fn layout_navigation_label( + overlay: &crate::NavigationTargetOverlay, + context: &NavigationOverlayLayoutContext<'_>, + window: &mut Window, + cx: &mut App, + paint_commands: &mut Vec, + ) { + let label = &overlay.label; + let label_display_point = overlay + .target_range + .start + .to_display_point(context.display_snapshot); + let label_row = label_display_point.row(); + if !context.visible_display_row_range.contains(&label_row) { + return; + } + + let row_index = label_row.minus(context.visible_display_row_range.start) as usize; + let row_layout = &context.line_layouts[row_index]; + let label_column = label_display_point.column().min(row_layout.len as u32) as usize; + let label_x = row_layout.x_for_index(label_column) + + row_layout.alignment_offset(context.text_align, context.content_width) + - context.scroll_pixel_position.x.into() + + label.x_offset; + let label_y = ((label_row.as_f64() - context.scroll_position.y) + * ScrollPixelOffset::from(context.line_height)) + .into(); + let label_text_size = (context.editor_font_size * label.scale_factor.max(0.0)).max(px(1.0)); + let origin = context.content_origin + point(label_x, label_y); + + let mut element = div() + .block_mouse_except_scroll() + .font(context.editor_font.clone()) + .text_size(label_text_size) + .text_color(label.text_color) + .line_height(context.line_height) + .child(label.text.clone()) + .into_any_element(); + element.prepaint_as_root(origin, AvailableSpace::min_size(), window, cx); + + paint_commands.push(NavigationOverlayPaintCommand::Label( + NavigationLabelLayout { element, origin }, + )); + } + fn layout_scrollbars( &self, snapshot: &EditorSnapshot, @@ -2104,8 +2205,8 @@ impl EditorElement { MinimapThumb::Hover => thumb_state.is_some(), }; - let minimap_bounds = Bounds::from_corner_and_size( - Corner::TopRight, + let minimap_bounds = Bounds::from_anchor_and_size( + gpui::Anchor::TopRight, top_right_anchor, size(minimap_width, editor_bounds.size.height), ); @@ -2537,7 +2638,9 @@ impl EditorElement { let icon_size = ui::IconSize::XSmall; let mut button = self.editor.update(cx, |editor, cx| { - editor.available_code_actions.as_ref()?; + if !editor.has_available_code_actions_for_selection() { + return None; + } let active = editor .context_menu .borrow() @@ -4055,6 +4158,7 @@ impl EditorElement { && !row_block_types.contains_key(&(row - 1)) && element_height_in_lines == 1 { + // Render inline at end of line (for diagnostic blocks that fit) x_offset = line_width + margin; row = row - 1; is_block = false; @@ -5270,7 +5374,7 @@ impl EditorElement { anchored() .position(position) .child(context_menu) - .anchor(Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .snap_to_window_with_margin(px(8.)), ) .with_priority(1) @@ -6526,6 +6630,7 @@ impl EditorElement { self.paint_document_colors(layout, window); self.paint_lines(&invisible_display_ranges, layout, window, cx); self.paint_redactions(layout, window); + self.paint_navigation_overlays(layout, window, cx); self.paint_cursors(layout, window, cx); self.paint_inline_diagnostics(layout, window, cx); self.paint_inline_blame(layout, window, cx); @@ -6688,7 +6793,7 @@ impl EditorElement { let snapshot = editor.snapshot(window, cx); let anchor = snapshot .display_snapshot - .display_point_to_anchor(point_for_position.previous_valid, Bias::Left); + .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); editor.change_selections( SelectionEffects::scroll(Autoscroll::top_relative(line_index)), window, @@ -6758,6 +6863,20 @@ impl EditorElement { }); } + fn paint_navigation_overlays( + &mut self, + layout: &mut EditorLayout, + window: &mut Window, + cx: &mut App, + ) { + window.with_element_namespace("navigation_overlays", |window| { + for command in &mut layout.navigation_overlay_paint_commands { + let NavigationOverlayPaintCommand::Label(label) = command; + label.element.paint(window, cx); + } + }); + } + fn paint_document_colors(&self, layout: &mut EditorLayout, window: &mut Window) { let Some((colors_render_mode, image_colors)) = &layout.document_colors else { return; @@ -9550,8 +9669,8 @@ pub struct EditorRequestLayoutState { impl EditorRequestLayoutState { // In ideal conditions we only need one more subsequent prepaint call for resize to take effect. - // i.e. MAX_PREPAINT_DEPTH = 2, but since moving blocks inline (place_near), more lines from - // below get exposed, and we end up querying blocks for those lines too in subsequent renders. + // i.e. MAX_PREPAINT_DEPTH = 2, but placing near blocks can expose more lines from below, and + // we end up querying blocks for those lines too in subsequent renders. // Setting MAX_PREPAINT_DEPTH = 3, passes all tests. Just to be on the safe side we set it to 5, so // that subsequent shrinking does not lead to incorrect block placing. const MAX_PREPAINT_DEPTH: usize = 5; @@ -10484,11 +10603,6 @@ impl Element for EditorElement { } else { None }; - self.editor.update(cx, |editor, _| { - editor.scroll_manager.set_sticky_header_line_count( - sticky_headers.as_ref().map_or(0, |h| h.lines.len()), - ); - }); let indent_guides = if scroll_pixel_position != preliminary_scroll_pixel_position { self.layout_indent_guides( @@ -10687,6 +10801,18 @@ impl Element for EditorElement { window, cx, ); + let navigation_overlay_paint_commands = self.layout_navigation_overlays( + &snapshot, + start_row..end_row, + &line_layouts, + &text_hitbox, + content_origin, + scroll_position, + scroll_pixel_position, + line_height, + window, + cx, + ); let scrollbars_layout = self.layout_scrollbars( &snapshot, @@ -11073,6 +11199,7 @@ impl Element for EditorElement { spacer_blocks, cursors, visible_cursors, + navigation_overlay_paint_commands, selections, edit_prediction_popover, diff_hunk_controls, @@ -11132,33 +11259,58 @@ impl Element for EditorElement { window.with_text_style(Some(text_style), |window| { window.with_content_mask(Some(ContentMask { bounds }), |window| { self.paint_mouse_listeners(layout, window, cx); - self.paint_background(layout, window, cx); - self.paint_indent_guides(layout, window, cx); + // Mask the editor behind sticky scroll headers. Important + // for transparent backgrounds. + let below_sticky_headers_mask = layout + .sticky_headers + .as_ref() + .and_then(|h| h.lines.last()) + .map(|last| ContentMask { + bounds: Bounds { + origin: point( + bounds.origin.x, + bounds.origin.y + last.offset + layout.position_map.line_height, + ), + size: size( + bounds.size.width, + (bounds.size.height + - last.offset + - layout.position_map.line_height) + .max(Pixels::ZERO), + ), + }, + }); - if layout.gutter_hitbox.size.width > Pixels::ZERO { - self.paint_blamed_display_rows(layout, window, cx); - self.paint_line_numbers(layout, window, cx); - } + window.with_content_mask(below_sticky_headers_mask, |window| { + self.paint_background(layout, window, cx); - self.paint_text(layout, window, cx); + self.paint_indent_guides(layout, window, cx); - if !layout.spacer_blocks.is_empty() { - window.with_element_namespace("blocks", |window| { - self.paint_spacer_blocks(layout, window, cx); - }); - } + if layout.gutter_hitbox.size.width > Pixels::ZERO { + self.paint_blamed_display_rows(layout, window, cx); + self.paint_line_numbers(layout, window, cx); + } - if layout.gutter_hitbox.size.width > Pixels::ZERO { - self.paint_gutter_highlights(layout, window, cx); - self.paint_gutter_indicators(layout, window, cx); - } + self.paint_text(layout, window, cx); - if !layout.blocks.is_empty() { - window.with_element_namespace("blocks", |window| { - self.paint_non_spacer_blocks(layout, window, cx); - }); - } + if !layout.spacer_blocks.is_empty() { + window.with_element_namespace("blocks", |window| { + self.paint_spacer_blocks(layout, window, cx); + }); + } + + if layout.gutter_hitbox.size.width > Pixels::ZERO { + self.paint_gutter_highlights(layout, window, cx); + self.paint_gutter_indicators(layout, window, cx); + } + + if !layout.blocks.is_empty() { + window.with_element_namespace("blocks", |window| { + self.paint_non_spacer_blocks(layout, window, cx); + }); + } + }); window.with_element_namespace("blocks", |window| { if let Some(mut sticky_header) = layout.sticky_buffer_header.take() { @@ -11265,6 +11417,7 @@ pub struct EditorLayout { redacted_ranges: Vec>, cursors: Vec<(DisplayPoint, Hsla)>, visible_cursors: Vec, + navigation_overlay_paint_commands: Vec, selections: Vec<(PlayerColor, Vec)>, test_indicators: Vec, bookmarks: Vec, @@ -11524,8 +11677,8 @@ impl EditorScrollbars { let viewport_size = size(editor_width, editor_bounds.size.height); let scrollbar_bounds_for = |axis: ScrollbarAxis| match axis { - ScrollbarAxis::Horizontal => Bounds::from_corner_and_size( - Corner::BottomLeft, + ScrollbarAxis::Horizontal => Bounds::from_anchor_and_size( + gpui::Anchor::BottomLeft, editor_bounds.bottom_left(), size( // The horizontal viewport size differs from the space available for the @@ -11534,8 +11687,8 @@ impl EditorScrollbars { scrollbar_width, ), ), - ScrollbarAxis::Vertical => Bounds::from_corner_and_size( - Corner::TopRight, + ScrollbarAxis::Vertical => Bounds::from_anchor_and_size( + gpui::Anchor::TopRight, editor_bounds.top_right(), size(scrollbar_width, viewport_size.height), ), @@ -11902,6 +12055,7 @@ pub(crate) struct PositionMap { pub struct PointForPosition { pub previous_valid: DisplayPoint, pub next_valid: DisplayPoint, + pub nearest_valid: DisplayPoint, pub exact_unclipped: DisplayPoint, pub column_overshoot_after_line_end: u32, } @@ -11971,12 +12125,23 @@ impl PositionMap { let previous_valid = self.snapshot.clip_point(exact_unclipped, Bias::Left); let next_valid = self.snapshot.clip_point(exact_unclipped, Bias::Right); + let nearest_valid = if previous_valid == next_valid { + previous_valid + } else { + match self.snapshot.inlay_bias_at(exact_unclipped) { + Some(Bias::Left) => next_valid, + Some(Bias::Right) => previous_valid, + None => previous_valid, + } + }; + let column_overshoot_after_line_end = (x_overshoot_after_line_end / self.em_layout_width) as u32; *exact_unclipped.column_mut() += column_overshoot_after_line_end; PointForPosition { previous_valid, next_valid, + nearest_valid, exact_unclipped, column_overshoot_after_line_end, } @@ -12006,12 +12171,23 @@ impl PositionMap { let previous_valid = self.snapshot.clip_point(exact_unclipped, Bias::Left); let next_valid = self.snapshot.clip_point(exact_unclipped, Bias::Right); + let nearest_valid = if previous_valid == next_valid { + previous_valid + } else { + match self.snapshot.inlay_bias_at(exact_unclipped) { + Some(Bias::Left) => next_valid, + Some(Bias::Right) => previous_valid, + None => previous_valid, + } + }; + let column_overshoot_after_line_end = (x_overshoot_after_line_end / self.em_layout_width) as u32; *exact_unclipped.column_mut() += column_overshoot_after_line_end; PointForPosition { previous_valid, next_valid, + nearest_valid, exact_unclipped, column_overshoot_after_line_end, } @@ -12072,6 +12248,32 @@ pub struct IndentGuideLayout { settings: IndentGuideSettings, } +enum NavigationOverlayPaintCommand { + Label(NavigationLabelLayout), +} + +struct NavigationLabelLayout { + element: AnyElement, + #[cfg_attr(not(test), allow(dead_code))] + origin: gpui::Point, +} + +struct NavigationOverlayLayoutContext<'a> { + display_snapshot: &'a DisplaySnapshot, + visible_display_row_range: &'a Range, + line_layouts: &'a [LineWithInvisibles], + text_align: TextAlign, + content_width: Pixels, + content_origin: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, + line_height: Pixels, + editor_font: Font, + editor_font_size: Pixels, +} + +const LABEL_LINE_HEIGHT_PADDING_PX: f32 = 2.0; + pub struct CursorLayout { origin: gpui::Point, block_width: Pixels, @@ -12164,7 +12366,7 @@ impl CursorLayout { .bg(self.color) .text_size(text_size) .px_0p5() - .line_height(text_size + px(2.)) + .line_height(text_size + px(LABEL_LINE_HEIGHT_PADDING_PX)) .text_color(cursor_name.color) .child(cursor_name.string) .into_any_element(); @@ -12466,7 +12668,8 @@ fn compute_auto_height_layout( mod tests { use super::*; use crate::{ - Editor, MultiBuffer, SelectionEffects, + Editor, HighlightKey, MultiBuffer, NavigationOverlayKey, NavigationOverlayLabel, + NavigationTargetOverlay, SelectionEffects, display_map::{BlockPlacement, BlockProperties}, editor_tests::{init_test, update_test_language_settings}, }; @@ -12477,6 +12680,38 @@ mod tests { use std::num::NonZeroU32; use util::test::sample_text; + enum PrimaryNavigationOverlay {} + + const PRIMARY_NAVIGATION_OVERLAY_KEY: NavigationOverlayKey = + NavigationOverlayKey::unique::(); + + fn navigation_overlay( + label_text: &'static str, + target_range: Range, + covered_text_range: Option>, + ) -> NavigationTargetOverlay { + NavigationTargetOverlay { + target_range, + label: NavigationOverlayLabel { + text: SharedString::from(label_text), + text_color: Hsla::black(), + x_offset: Pixels::ZERO, + scale_factor: 1.0, + }, + covered_text_range, + } + } + + fn navigation_label_layouts(state: &EditorLayout) -> Vec<&NavigationLabelLayout> { + state + .navigation_overlay_paint_commands + .iter() + .map(|command| match command { + NavigationOverlayPaintCommand::Label(label) => label, + }) + .collect() + } + const fn placeholder_hitbox() -> Hitbox { use gpui::HitboxId; let zero_bounds = Bounds { @@ -12605,6 +12840,109 @@ mod tests { } } + #[gpui::test] + fn test_navigation_overlay_covered_text_highlights_are_replaced(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let window = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("overlay replacement", cx); + Editor::new(EditorMode::full(), buffer, None, window, cx) + }); + let editor = window.root(cx).unwrap(); + + editor.update(cx, |editor, cx| { + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let target_start = buffer_snapshot.anchor_after(Point::new(0, 0)); + let target_end = buffer_snapshot.anchor_after(Point::new(0, 7)); + let covered_text_end = buffer_snapshot.anchor_after(Point::new(0, 2)); + + editor.set_navigation_overlays( + PRIMARY_NAVIGATION_OVERLAY_KEY, + vec![navigation_overlay( + "ov", + target_start..target_end, + Some(target_start..covered_text_end), + )], + cx, + ); + assert!( + editor + .text_highlights( + HighlightKey::NavigationOverlay(PRIMARY_NAVIGATION_OVERLAY_KEY), + cx, + ) + .is_some() + ); + + editor.set_navigation_overlays( + PRIMARY_NAVIGATION_OVERLAY_KEY, + vec![navigation_overlay("ov", target_start..target_end, None)], + cx, + ); + assert!( + editor + .text_highlights( + HighlightKey::NavigationOverlay(PRIMARY_NAVIGATION_OVERLAY_KEY), + cx, + ) + .is_none() + ); + }); + } + + #[gpui::test] + async fn test_navigation_overlay_repositions_when_editor_width_changes( + cx: &mut TestAppContext, + ) { + init_test(cx, |_| {}); + let text = "jump target overlay ".repeat(16); + let window = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&text, cx); + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + editor + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + let editor = window.root(cx).unwrap(); + + editor.update(cx, |editor, cx| { + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let target_start = buffer_snapshot.anchor_after(Point::new(0, 30)); + let target_end = buffer_snapshot.anchor_after(Point::new(0, 40)); + + editor.set_navigation_overlays( + PRIMARY_NAVIGATION_OVERLAY_KEY, + vec![navigation_overlay("jj", target_start..target_end, None)], + cx, + ); + }); + + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); + let (_, wide_state) = cx.draw(Default::default(), size(px(520.), px(260.)), |_, _| { + EditorElement::new(&editor, style.clone()) + }); + let (_, narrow_state) = cx.draw(Default::default(), size(px(140.), px(260.)), |_, _| { + EditorElement::new(&editor, style.clone()) + }); + + let wide_label_layouts = navigation_label_layouts(&wide_state); + let narrow_label_layouts = navigation_label_layouts(&narrow_state); + + assert_eq!(wide_label_layouts.len(), 1); + assert_eq!(narrow_label_layouts.len(), 1); + + let wide_label_origin = wide_label_layouts[0].origin; + let narrow_label_origin = narrow_label_layouts[0].origin; + + assert!( + narrow_label_origin.y > wide_label_origin.y, + "expected inline label to move to a later wrapped row when the editor narrows" + ); + assert!( + narrow_label_origin.x < wide_label_origin.x, + "expected inline label to recompute its horizontal position for the wrapped row" + ); + } + #[gpui::test] fn test_layout_line_numbers(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 21177ad27b5886..90d57d478712fe 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -1952,6 +1952,7 @@ mod tests { PointForPosition { previous_valid, next_valid, + nearest_valid: previous_valid, exact_unclipped, column_overshoot_after_line_end: 0, } @@ -2079,6 +2080,7 @@ mod tests { PointForPosition { previous_valid, next_valid, + nearest_valid: previous_valid, exact_unclipped, column_overshoot_after_line_end: 0, } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 5ad584f1a4233a..1752aefc5e6a0e 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1017,10 +1017,10 @@ impl Item for Editor { if let Some(workspace_entity) = &workspace.weak_handle().upgrade() { cx.subscribe( workspace_entity, - |editor, _, event: &workspace::Event, _cx| { + |editor, _, event: &workspace::Event, cx| { if let workspace::Event::ModalOpened = event { editor.mouse_context_menu.take(); - editor.inline_blame_popover.take(); + editor.hide_blame_popover(true, cx); } }, ) @@ -1660,8 +1660,17 @@ impl SearchableItem for Editor { } } - fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { - let setting = EditorSettings::get_global(cx).seed_search_query_from_cursor; + fn query_suggestion( + &mut self, + ignore_settings: bool, + window: &mut Window, + cx: &mut Context, + ) -> String { + let setting = if ignore_settings { + SeedQuerySetting::Always + } else { + EditorSettings::get_global(cx).seed_search_query_from_cursor + }; let snapshot = self.snapshot(window, cx); let selection = self.selections.newest_adjusted(&snapshot.display_snapshot); let buffer_snapshot = snapshot.buffer_snapshot(); diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs index dc97a3ea310b5b..b17b9944173629 100644 --- a/crates/editor/src/runnables.rs +++ b/crates/editor/src/runnables.rs @@ -118,11 +118,14 @@ impl Editor { return; } if let Some(buffer) = self.buffer().read(cx).as_singleton() { - let buffer_id = buffer.read(cx).remote_id(); + let buffer_read = buffer.read(cx); + if buffer_read.file().is_none() { + self.clear_runnables(None); + return; + } + let buffer_id = buffer_read.remote_id(); if invalidate_buffer_data != Some(buffer_id) - && self - .runnables - .has_cached(buffer_id, &buffer.read(cx).version()) + && self.runnables.has_cached(buffer_id, &buffer_read.version()) { return; } @@ -711,13 +714,14 @@ mod tests { use lsp::LanguageServerName; use multi_buffer::{MultiBuffer, PathKey}; use project::{ - FakeFs, Project, + FakeFs, Project, ProjectPath, lsp_store::lsp_ext_command::{CargoRunnableArgs, Runnable, RunnableArgs, RunnableKind}, }; use serde_json::json; use task::{TaskTemplate, TaskTemplates}; use text::Point; use util::path; + use util::rel_path::rel_path; use crate::{ Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount, @@ -1079,4 +1083,95 @@ mod tests { "Runnables should be removed after #[test] is deleted and LSP returns empty" ); } + + #[gpui::test] + async fn test_no_runnables_for_unsaved_buffer(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({})).await; + + 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_with_task_context()); + + let rust_language = language_registry.language_for_name("Rust").await.unwrap(); + let buffer = cx.new(|cx| { + let mut buffer = language::Buffer::local( + indoc! {" + fn main() { + println!(\"hello\"); + } + + #[test] + fn test_one() { + assert!(true); + } + "}, + cx, + ); + buffer.set_language(Some(rust_language), cx); + buffer + }); + + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let editor = cx.add_window(|window, cx| { + build_editor_with_project(project.clone(), multi_buffer, window, cx) + }); + + editor + .update(cx, |editor, window, cx| { + editor.refresh_runnables(None, window, cx); + }) + .expect("editor update"); + cx.executor().advance_clock(UPDATE_DEBOUNCE); + cx.executor().run_until_parked(); + + let labels = editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .expect("editor update"); + assert_eq!( + labels, + Vec::<(text::BufferId, language::BufferRow, Vec)>::new(), + "No runnables should appear for an unsaved buffer without a file on disk" + ); + + let worktree_id = project.update(cx, |project, cx| { + project + .worktrees(cx) + .next() + .expect("worktree") + .read(cx) + .id() + }); + project + .update(cx, |project, cx| { + project.save_buffer_as( + buffer.clone(), + ProjectPath { + worktree_id, + path: rel_path("main.rs").into(), + }, + cx, + ) + }) + .await + .expect("save buffer as"); + + editor + .update(cx, |editor, window, cx| { + editor.refresh_runnables(None, window, cx); + }) + .expect("editor update"); + cx.executor().advance_clock(UPDATE_DEBOUNCE); + cx.executor().run_until_parked(); + + let labels = editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .expect("editor update"); + assert!( + !labels.is_empty(), + "Runnables should appear after the buffer is saved to disk" + ); + } } diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index b14ba4bcdacf92..143a73fd701ac9 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -201,8 +201,6 @@ pub struct ScrollManager { /// Each side separately clamps the x component using its own scroll_max_x when reading from the SharedScrollAnchor. scroll_max_x: Option, ongoing: OngoingScroll, - /// Number of sticky header lines currently being rendered for the current scroll position. - sticky_header_line_count: usize, /// The second element indicates whether the autoscroll request is local /// (true) or remote (false). Local requests are initiated by user actions, /// while remote requests come from external sources. @@ -234,7 +232,6 @@ impl ScrollManager { anchor, scroll_max_x: None, ongoing: OngoingScroll::new(), - sticky_header_line_count: 0, autoscroll_request: None, show_scrollbars: true, hide_scrollbar_task: None, @@ -273,7 +270,6 @@ impl ScrollManager { this.display_map_id = Some(my_snapshot.display_map_id); }); self.ongoing = other.ongoing; - self.sticky_header_line_count = other.sticky_header_line_count; } pub fn offset(&self, cx: &App) -> gpui::Point { @@ -360,14 +356,6 @@ impl ScrollManager { pos } - pub fn sticky_header_line_count(&self) -> usize { - self.sticky_header_line_count - } - - pub fn set_sticky_header_line_count(&mut self, count: usize) { - self.sticky_header_line_count = count; - } - fn set_scroll_position( &mut self, scroll_position: gpui::Point, diff --git a/crates/editor/src/scroll/actions.rs b/crates/editor/src/scroll/actions.rs index 48438b6592a3a7..4685b1003ee57d 100644 --- a/crates/editor/src/scroll/actions.rs +++ b/crates/editor/src/scroll/actions.rs @@ -1,12 +1,10 @@ use super::Axis; use crate::{ - Autoscroll, Editor, EditorMode, EditorSettings, NextScreen, NextScrollCursorCenterTopBottom, + Autoscroll, Editor, EditorMode, NextScreen, NextScrollCursorCenterTopBottom, SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT, ScrollCursorBottom, ScrollCursorCenter, ScrollCursorCenterTopBottom, ScrollCursorTop, display_map::DisplayRow, scroll::ScrollOffset, }; use gpui::{Context, Point, Window}; -use settings::Settings; -use text::ToOffset; impl Editor { pub fn next_screen(&mut self, _: &NextScreen, window: &mut Window, cx: &mut Context) { @@ -77,23 +75,9 @@ impl Editor { let scroll_margin_rows = self.vertical_scroll_margin() as u32; let selection_head = self.selections.newest_display(&display_snapshot).head(); - let sticky_headers_len = if EditorSettings::get_global(cx).sticky_scroll.enabled - && let Some(buffer_snapshot) = display_snapshot.buffer_snapshot().as_singleton() - { - let select_head_point = - rope::Point::new(selection_head.to_point(&display_snapshot).row, 0); - buffer_snapshot - .outline_items_containing(select_head_point..select_head_point, false, None) - .iter() - .filter(|outline| { - outline.range.start.offset - < select_head_point.to_offset(&buffer_snapshot) as u32 - }) - .collect::>() - .len() - } else { - 0 - } as u32; + let sticky_headers_len = + self.visible_sticky_header_count_for_point(&display_snapshot, selection_head, cx) + as u32; let new_screen_top = selection_head.row().0; let header_offset = display_snapshot diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 832059f8c049be..c2b07ffa96aef4 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -1,12 +1,15 @@ use crate::{ - DisplayRow, Editor, EditorMode, LineWithInvisibles, RowExt, SelectionEffects, - display_map::ToDisplayPoint, + DisplayPoint, DisplayRow, Editor, EditorMode, EditorSettings, LineWithInvisibles, RowExt, + SelectionEffects, + display_map::{DisplaySnapshot, ToDisplayPoint}, scroll::{ScrollOffset, WasScrolled}, }; -use gpui::{Bounds, Context, Pixels, Window}; +use gpui::{App, Bounds, Context, Pixels, Window}; use language::Point; use multi_buffer::Anchor; +use settings::Settings; use std::cmp; +use text::Bias; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum Autoscroll { @@ -140,23 +143,24 @@ impl Editor { return (NeedsHorizontalAutoscroll(false), editor_was_scrolled); }; + let mut target_point; let mut target_top; let mut target_bottom; if let Some(first_highlighted_row) = self.highlighted_display_row_for_autoscroll(&display_map) { - target_top = first_highlighted_row.as_f64(); + target_point = DisplayPoint::new(first_highlighted_row, 0); + target_top = target_point.row().as_f64(); target_bottom = target_top + 1.; } else { let selections = self.selections.all::(&display_map); - target_top = selections + target_point = selections .first() .unwrap() .head() - .to_display_point(&display_map) - .row() - .as_f64(); + .to_display_point(&display_map); + target_top = target_point.row().as_f64(); target_bottom = selections .last() .unwrap() @@ -173,21 +177,17 @@ impl Editor { ) || (matches!(autoscroll, Autoscroll::Strategy(AutoscrollStrategy::Fit, _)) && !selections_fit) { - let newest_selection_top = selections + target_point = selections .iter() .max_by_key(|s| s.id) .unwrap() .head() - .to_display_point(&display_map) - .row() - .as_f64(); - target_top = newest_selection_top; - target_bottom = newest_selection_top + 1.; + .to_display_point(&display_map); + target_top = target_point.row().as_f64(); + target_bottom = target_top + 1.; } } - let visible_sticky_headers = self.scroll_manager.sticky_header_line_count(); - let margin = if matches!(self.mode, EditorMode::AutoHeight { .. }) { 0. } else { @@ -209,10 +209,14 @@ impl Editor { .unwrap_or_default(), }; if let Autoscroll::Strategy(_, Some(anchor)) = autoscroll { - target_top = anchor.to_display_point(&display_map).row().as_f64(); + target_point = anchor.to_display_point(&display_map); + target_top = target_point.row().as_f64(); target_bottom = target_top + 1.; } + let visible_sticky_headers = + self.visible_sticky_header_count_for_point(&display_map, target_point, cx); + let was_autoscrolled = match strategy { AutoscrollStrategy::Fit | AutoscrollStrategy::Newest => { let margin = margin.min(self.scroll_manager.vertical_scroll_margin); @@ -274,6 +278,54 @@ impl Editor { (NeedsHorizontalAutoscroll(true), was_scrolled) } + pub(crate) fn visible_sticky_header_count_for_point( + &self, + display_map: &DisplaySnapshot, + target_point: DisplayPoint, + cx: &App, + ) -> usize { + let sticky_scroll = EditorSettings::get_global(cx).sticky_scroll; + if !sticky_scroll.enabled { + return 0; + } + + let Some(buffer_snapshot) = display_map.buffer_snapshot().as_singleton() else { + return 0; + }; + + let point = target_point.to_point(display_map); + let mut item_ranges = buffer_snapshot + .outline_ranges_containing(point..point) + .collect::>(); + item_ranges.sort_by_key(|item_range| item_range.start); + + let mut previous_sticky_row = None; + let mut num_visible_sticky_headers = 0; + + for item_range in item_ranges { + let sticky_row = display_map + .point_to_display_point(item_range.start, Bias::Left) + .row(); + if sticky_row >= target_point.row() { + break; + } + if previous_sticky_row.replace(sticky_row) == Some(sticky_row) { + continue; + } + + let end_row = display_map + .point_to_display_point(item_range.end, Bias::Left) + .row(); + if end_row <= target_point.row() { + continue; + } + + num_visible_sticky_headers += 1; + } + + num_visible_sticky_headers + } + pub(crate) fn autoscroll_horizontally( &mut self, start_row: DisplayRow, diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index eaadbbb0e2ee9a..29c998ce976fee 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -15,7 +15,7 @@ use project::{ project_settings::ProjectSettings, }; use settings::{ - SemanticTokenColorOverride, SemanticTokenFontStyle, SemanticTokenFontWeight, + SemanticTokenColorOverride, SemanticTokenFontStyle, SemanticTokenFontWeight, SemanticTokenRule, SemanticTokenRules, Settings as _, }; use text::BufferId; @@ -295,13 +295,14 @@ impl Editor { ) else { continue; }; + let theme = cx.theme().syntax(); token_highlights.reserve(2 * server_tokens.len()); token_highlights.extend(buffer_into_editor_highlights( &server_tokens, stylizer, &multi_buffer_snapshot, &mut interner, - cx, + theme, )); } @@ -328,7 +329,7 @@ fn buffer_into_editor_highlights<'a, 'b>( stylizer: &'a SemanticTokenStylizer, multi_buffer_snapshot: &'a multi_buffer::MultiBufferSnapshot, interner: &'b mut HighlightStyleInterner, - cx: &'a App, + theme: &'a SyntaxTheme, ) -> impl Iterator + use<'a, 'b> { multi_buffer_snapshot .text_anchors_to_visible_anchors( @@ -341,12 +342,7 @@ fn buffer_into_editor_highlights<'a, 'b>( .zip(buffer_tokens) .filter_map(|((multi_buffer_start, multi_buffer_end), token)| { let range = multi_buffer_start?..multi_buffer_end?; - let style = convert_token( - stylizer, - cx.theme().syntax(), - token.token_type, - token.token_modifiers, - )?; + let style = convert_token(stylizer, theme, token.token_type, token.token_modifiers)?; let style = interner.intern(style); Some(SemanticTokenHighlight { range, @@ -365,27 +361,19 @@ fn convert_token( modifiers: u32, ) -> Option { let rules = stylizer.rules_for_token(token_type)?; - let matching: Vec<_> = rules - .iter() - .filter(|rule| { - rule.token_modifiers - .iter() - .all(|m| stylizer.has_modifier(modifiers, m)) - }) - .collect(); - - if let Some(rule) = matching.last() { - if rule.no_style_defined() { - return None; - } + let filter = |rule: &&SemanticTokenRule| { + rule.token_modifiers + .iter() + .all(|m| stylizer.has_modifier(modifiers, m)) + }; + let last = rules.last()?; + if last.no_style_defined() && filter(&last) { + return None; } let mut highlight = HighlightStyle::default(); - let mut empty = true; - - for rule in matching { - empty = false; + for rule in rules.into_iter().filter(filter) { let style = rule .style .iter() @@ -400,7 +388,7 @@ fn convert_token( highlight.$highlight_field = rule .$rule_field .map($transform) - .or_else(|| style.and_then(|s| s.$highlight_field)) + .or_else(|| style.as_ref().and_then(|s| s.$highlight_field)) .or(highlight.$highlight_field) }; } @@ -460,8 +448,7 @@ fn convert_token( }, ); } - - if empty { None } else { Some(highlight) } + Some(highlight) } #[cfg(test)] diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index ee15583072144c..43479f1713c905 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -43,7 +43,7 @@ use crate::{ }; use zed_actions::assistant::InlineAssist; -pub(crate) fn convert_lhs_rows_to_rhs( +pub(crate) fn patches_for_lhs_range( rhs_snapshot: &MultiBufferSnapshot, lhs_snapshot: &MultiBufferSnapshot, lhs_bounds: Range, @@ -56,7 +56,7 @@ pub(crate) fn convert_lhs_rows_to_rhs( ) } -pub(crate) fn convert_rhs_rows_to_lhs( +pub(crate) fn patches_for_rhs_range( lhs_snapshot: &MultiBufferSnapshot, rhs_snapshot: &MultiBufferSnapshot, rhs_bounds: Range, @@ -69,7 +69,7 @@ pub(crate) fn convert_rhs_rows_to_lhs( ) } -fn rhs_range_to_base_text_range( +fn buffer_range_to_base_text_range( rhs_range: &Range, diff_snapshot: &BufferDiffSnapshot, rhs_buffer_snapshot: &text::BufferSnapshot, @@ -89,19 +89,19 @@ fn translate_lhs_selections_to_rhs( splittable: &SplittableEditor, cx: &App, ) -> HashMap, (Vec>, Option)> { - let rhs_display_map = splittable.rhs_editor.read(cx).display_map.read(cx); - let Some(companion) = rhs_display_map.companion() else { + let Some(lhs) = &splittable.lhs else { return HashMap::default(); }; - let companion = companion.read(cx); + let lhs_snapshot = lhs.multibuffer.read(cx).snapshot(cx); let mut translated: HashMap, (Vec>, Option)> = HashMap::default(); for (lhs_buffer_id, (ranges, scroll_offset)) in selections_by_buffer { - let Some(rhs_buffer_id) = companion.lhs_to_rhs_buffer(*lhs_buffer_id) else { + let Some(diff) = lhs_snapshot.diff_for_buffer_id(*lhs_buffer_id) else { continue; }; + let rhs_buffer_id = diff.buffer_id(); let Some(rhs_buffer) = splittable .rhs_editor @@ -155,19 +155,19 @@ fn translate_lhs_hunks_to_rhs( splittable: &SplittableEditor, cx: &App, ) -> Vec { - let rhs_display_map = splittable.rhs_editor.read(cx).display_map.read(cx); - let Some(companion) = rhs_display_map.companion() else { + let Some(lhs) = &splittable.lhs else { return vec![]; }; - let companion = companion.read(cx); + let lhs_snapshot = lhs.multibuffer.read(cx).snapshot(cx); let rhs_snapshot = splittable.rhs_multibuffer.read(cx).snapshot(cx); let rhs_hunks: Vec = rhs_snapshot.diff_hunks().collect(); let mut translated = Vec::new(); for lhs_hunk in lhs_hunks { - let Some(rhs_buffer_id) = companion.lhs_to_rhs_buffer(lhs_hunk.buffer_id) else { + let Some(diff) = lhs_snapshot.diff_for_buffer_id(lhs_hunk.buffer_id) else { continue; }; + let rhs_buffer_id = diff.buffer_id(); if let Some(rhs_hunk) = rhs_hunks.iter().find(|rhs_hunk| { rhs_hunk.buffer_id == rhs_buffer_id && rhs_hunk.diff_base_byte_range == lhs_hunk.diff_base_byte_range @@ -207,19 +207,21 @@ where return; }; - let diff = source_snapshot - .diff_for_buffer_id(first.source_buffer_snapshot.remote_id()) - .expect("buffer with no diff when creating patches"); - let source_is_lhs = - first.source_buffer_snapshot.remote_id() == diff.base_text().remote_id(); + let source_buffer_id = first.source_buffer_snapshot.remote_id(); + let Some(diff) = source_snapshot.diff_for_buffer_id(source_buffer_id) else { + pending.clear(); + return; + }; + let source_is_lhs = source_buffer_id == diff.base_text().remote_id(); let target_buffer_id = if source_is_lhs { diff.buffer_id() } else { diff.base_text().remote_id() }; - let target_buffer = target_snapshot - .buffer_for_id(target_buffer_id) - .expect("missing corresponding buffer"); + let Some(target_buffer) = target_snapshot.buffer_for_id(target_buffer_id) else { + pending.clear(); + return; + }; let rhs_buffer = if source_is_lhs { target_buffer } else { @@ -228,28 +230,34 @@ where let patch = translate_fn(diff, union_start..=union_end, rhs_buffer); - for excerpt in pending.drain(..) { - let target_position = patch.old_to_new(excerpt.buffer_point_range.start); - let target_position = target_buffer.anchor_before(target_position); - let Some(target_position) = target_snapshot.anchor_in_excerpt(target_position) else { - continue; - }; - let Some((target_buffer_snapshot, target_excerpt_range)) = - target_snapshot.excerpt_containing(target_position..target_position) - else { - continue; - }; + let mut source_excerpts = source_snapshot + .excerpts_for_buffer(source_buffer_id) + .peekable(); + let mut target_excerpts = target_snapshot + .excerpts_for_buffer(target_buffer_id) + .peekable(); - result.push(patch_for_excerpt( - source_snapshot, - target_snapshot, - &excerpt.source_buffer_snapshot, - target_buffer_snapshot, - excerpt.source_excerpt_range, - target_excerpt_range, - &patch, - excerpt.buffer_point_range, - )); + for excerpt in pending.drain(..) { + while let Some(source_excerpt_range) = source_excerpts.peek() + && source_excerpt_range != &excerpt.source_excerpt_range + { + source_excerpts.next(); + target_excerpts.next(); + } + if let Some(source_excerpt_range) = source_excerpts.peek() + && let Some(target_excerpt_range) = target_excerpts.peek() + { + result.push(patch_for_excerpt( + source_snapshot, + target_snapshot, + &excerpt.source_buffer_snapshot, + target_buffer, + source_excerpt_range.clone(), + target_excerpt_range.clone(), + &patch, + excerpt.buffer_point_range, + )); + } } }; @@ -412,7 +420,6 @@ pub struct SplittableEditor { struct LhsEditor { multibuffer: Entity, editor: Entity, - companion: Entity, was_last_focused: bool, _subscriptions: Vec, } @@ -483,8 +490,9 @@ impl SplittableEditor { Editor::for_multibuffer(rhs_multibuffer.clone(), Some(project.clone()), window, cx); editor.set_expand_all_diff_hunks(cx); editor.disable_runnables(); - editor.disable_diagnostics(cx); + editor.disable_inline_diagnostics(); editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx); + editor.start_temporary_diff_override(); editor }); // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs @@ -603,12 +611,10 @@ impl SplittableEditor { .filter_map(|anchor| { let (anchor, lhs_buffer) = lhs_snapshot.anchor_to_buffer_anchor(*anchor)?; - let rhs_buffer_id = - lhs.companion.read(cx).lhs_to_rhs_buffer(anchor.buffer_id)?; + let diff = lhs_snapshot.diff_for_buffer_id(anchor.buffer_id)?; + let rhs_buffer_id = diff.buffer_id(); let rhs_buffer = rhs_snapshot.buffer_for_id(rhs_buffer_id)?; - let diff = this.rhs_multibuffer.read(cx).diff_for(rhs_buffer_id)?; - let diff_snapshot = diff.read(cx).snapshot(cx); - let rhs_point = diff_snapshot.base_text_point_to_buffer_point( + let rhs_point = diff.base_text_point_to_buffer_point( anchor.to_point(&lhs_buffer), &rhs_buffer, ); @@ -697,18 +703,11 @@ impl SplittableEditor { let rhs_display_map = self.rhs_editor.read(cx).display_map.clone(); let lhs_display_map = lhs_editor.read(cx).display_map.clone(); let rhs_display_map_id = rhs_display_map.entity_id(); - let companion = cx.new(|_| { - Companion::new( - rhs_display_map_id, - convert_rhs_rows_to_lhs, - convert_lhs_rows_to_rhs, - ) - }); + let companion = cx.new(|_| Companion::new(rhs_display_map_id)); let lhs = LhsEditor { editor: lhs_editor, multibuffer: lhs_multibuffer, was_last_focused: false, - companion: companion.clone(), _subscriptions: subscriptions, }; @@ -734,7 +733,7 @@ impl SplittableEditor { self.lhs = Some(lhs); - self.sync_lhs_for_paths(all_paths, &companion, cx); + self.sync_lhs_for_paths(all_paths, cx); rhs_display_map.update(cx, |dm, cx| { dm.set_companion(Some((lhs_display_map, companion.clone())), cx); @@ -1048,7 +1047,7 @@ impl SplittableEditor { cx: &mut Context, ) -> bool { let has_ranges = ranges.clone().into_iter().next().is_some(); - let Some(companion) = self.companion(cx) else { + if self.lhs.is_none() { return self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { let added_a_new_excerpt = rhs_multibuffer.update_excerpts_for_path( path, @@ -1066,7 +1065,7 @@ impl SplittableEditor { } added_a_new_excerpt }); - }; + } let result = self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { let added_a_new_excerpt = rhs_multibuffer.update_excerpts_for_path( @@ -1086,7 +1085,7 @@ impl SplittableEditor { added_a_new_excerpt }); - self.sync_lhs_for_paths(vec![(path, diff)], &companion, cx); + self.sync_lhs_for_paths(vec![(path, diff)], cx); result } @@ -1097,12 +1096,12 @@ impl SplittableEditor { direction: ExpandExcerptDirection, cx: &mut Context, ) { - let Some(companion) = self.companion(cx) else { + if self.lhs.is_none() { self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { rhs_multibuffer.expand_excerpts(excerpt_anchors, lines, direction, cx); }); return; - }; + } let paths: Vec<_> = self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { let snapshot = rhs_multibuffer.snapshot(cx); @@ -1121,7 +1120,7 @@ impl SplittableEditor { paths }); - self.sync_lhs_for_paths(paths, &companion, cx); + self.sync_lhs_for_paths(paths, cx); } pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context) { @@ -1147,18 +1146,9 @@ impl SplittableEditor { Some(&self.rhs_editor) } - fn companion(&self, cx: &App) -> Option> { - if self.lhs.is_none() { - return None; - } - let rhs_display_map = self.rhs_editor.read(cx).display_map.clone(); - rhs_display_map.read(cx).companion().cloned() - } - fn sync_lhs_for_paths( &self, paths: Vec<(PathKey, Entity)>, - companion: &Entity, cx: &mut Context, ) { let Some(lhs) = &self.lhs else { return }; @@ -1186,7 +1176,7 @@ impl SplittableEditor { for info in rhs_multibuffer_snapshot.excerpts_for_buffer(main_buffer_id) { have_excerpt = true; let rhs_context = info.context.to_point(&main_buffer_snapshot); - let lhs_context = rhs_range_to_base_text_range( + let lhs_context = buffer_range_to_base_text_range( &rhs_context, &diff_snapshot, &main_buffer_snapshot, @@ -1242,12 +1232,6 @@ impl SplittableEditor { cx, ); } - - let lhs_buffer_id = diff.read(cx).base_text(cx).remote_id(); - let rhs_buffer_id = diff.read(cx).buffer_id; - companion.update(cx, |c, _| { - c.add_buffer_mapping(lhs_buffer_id, rhs_buffer_id); - }); } }); } @@ -1689,8 +1673,11 @@ impl SplittableEditor { .unwrap(); let lhs_range = lhs_excerpt.context.to_point(&lhs_buffer_snapshot); let rhs_range = rhs_excerpt.context.to_point(&rhs_buffer_snapshot); - let expected_lhs_range = - rhs_range_to_base_text_range(&rhs_range, &diff_snapshot, &rhs_buffer_snapshot); + let expected_lhs_range = buffer_range_to_base_text_range( + &rhs_range, + &diff_snapshot, + &rhs_buffer_snapshot, + ); assert_eq!( lhs_range, expected_lhs_range, "corresponding lhs excerpt should have a matching range" @@ -1918,9 +1905,15 @@ impl SearchableItem for SplittableEditor { } } - fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { - self.focused_editor() - .update(cx, |editor, cx| editor.query_suggestion(window, cx)) + fn query_suggestion( + &mut self, + ignore_settings: bool, + window: &mut Window, + cx: &mut Context, + ) -> String { + self.focused_editor().update(cx, |editor, cx| { + editor.query_suggestion(ignore_settings, window, cx) + }) } fn activate_match( diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index f9ab1835f94327..bb6cbc883e1b6d 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -40,7 +40,7 @@ use std::time::{Duration, Instant}; use acp_thread::AgentConnection as _; use agent::{NativeAgent, NativeAgentConnection, Templates, ThreadStore}; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use anyhow::{Context, Result}; use clap::Parser; use feature_flags::FeatureFlagAppExt as _; diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index f0e789994127c9..f67e5494695516 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -15,7 +15,7 @@ use std::{ str::FromStr, sync::Arc, }; -use util::command::Stdio; +use util::{command::Stdio, rel_path::PathExt}; use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _}; use wasmparser::Parser; @@ -108,7 +108,7 @@ impl ExtensionBuilder { for (debug_adapter_name, meta) in &mut extension_manifest.debug_adapters { let debug_adapter_schema_path = - extension_dir.join(build_debug_adapter_schema_path(debug_adapter_name, meta)); + extension_dir.join(build_debug_adapter_schema_path(debug_adapter_name, meta)?); let debug_adapter_schema = fs::read_to_string(&debug_adapter_schema_path) .with_context(|| { @@ -582,8 +582,9 @@ async fn populate_defaults( let language_dir = language_dir?; let config_path = language_dir.join(LanguageConfig::FILE_NAME); if fs.is_file(config_path.as_path()).await { - let relative_language_dir = - language_dir.strip_prefix(extension_path)?.to_path_buf(); + let relative_language_dir = language_dir + .strip_prefix(extension_path)? + .to_rel_path_buf()?; if !manifest.languages.contains(&relative_language_dir) { manifest.languages.push(relative_language_dir); } @@ -601,7 +602,8 @@ async fn populate_defaults( while let Some(theme_path) = theme_dir_entries.next().await { let theme_path = theme_path?; if theme_path.extension() == Some("json".as_ref()) { - let relative_theme_path = theme_path.strip_prefix(extension_path)?.to_path_buf(); + let relative_theme_path = + theme_path.strip_prefix(extension_path)?.to_rel_path_buf()?; if !manifest.themes.contains(&relative_theme_path) { manifest.themes.push(relative_theme_path); } @@ -619,8 +621,9 @@ async fn populate_defaults( while let Some(icon_theme_path) = icon_theme_dir_entries.next().await { let icon_theme_path = icon_theme_path?; if icon_theme_path.extension() == Some("json".as_ref()) { - let relative_icon_theme_path = - icon_theme_path.strip_prefix(extension_path)?.to_path_buf(); + let relative_icon_theme_path = icon_theme_path + .strip_prefix(extension_path)? + .to_rel_path_buf()?; if !manifest.icon_themes.contains(&relative_icon_theme_path) { manifest.icon_themes.push(relative_icon_theme_path); } diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index d3bef88713733b..919051867d5f16 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -11,6 +11,7 @@ use language::LanguageName; use lsp::LanguageServerName; use semver::Version; use serde::{Deserialize, Serialize}; +use util::rel_path::{PathExt, RelPathBuf}; use crate::ExtensionCapability; @@ -28,11 +29,11 @@ pub struct OldExtensionManifest { pub authors: Vec, #[serde(default)] - pub themes: BTreeMap, PathBuf>, + pub themes: BTreeMap, RelPathBuf>, #[serde(default)] - pub languages: BTreeMap, PathBuf>, + pub languages: BTreeMap, RelPathBuf>, #[serde(default)] - pub grammars: BTreeMap, PathBuf>, + pub grammars: BTreeMap, RelPathBuf>, } /// The schema version of the [`ExtensionManifest`]. @@ -94,11 +95,11 @@ pub struct ExtensionManifest { pub lib: LibManifestEntry, #[serde(default)] - pub themes: Vec, + pub themes: Vec, #[serde(default)] - pub icon_themes: Vec, + pub icon_themes: Vec, #[serde(default)] - pub languages: Vec, + pub languages: Vec, #[serde(default)] pub grammars: BTreeMap, GrammarManifestEntry>, #[serde(default)] @@ -195,11 +196,13 @@ impl ExtensionManifest { pub fn build_debug_adapter_schema_path( adapter_name: &Arc, meta: &DebugAdapterManifestEntry, -) -> PathBuf { - meta.schema_path.clone().unwrap_or_else(|| { - Path::new("debug_adapter_schemas") +) -> anyhow::Result { + match &meta.schema_path { + Some(path) => Ok(path.clone()), + None => Path::new("debug_adapter_schemas") .join(Path::new(adapter_name.as_ref()).with_extension("json")) - }) + .to_rel_path_buf(), + } } #[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)] @@ -350,7 +353,7 @@ pub struct SlashCommandManifestEntry { #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] pub struct DebugAdapterManifestEntry { - pub schema_path: Option, + pub schema_path: Option, } #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] @@ -442,7 +445,9 @@ fn manifest_from_old_manifest( #[cfg(test)] mod tests { + use indoc::indoc; use pretty_assertions::assert_eq; + use util::rel_path::rel_path_buf; use crate::ProcessExecCapability; @@ -478,11 +483,11 @@ mod tests { fn test_build_adapter_schema_path_with_schema_path() { let adapter_name = Arc::from("my_adapter"); let entry = DebugAdapterManifestEntry { - schema_path: Some(PathBuf::from("foo/bar")), + schema_path: Some(rel_path_buf("foo/bar")), }; - let path = build_debug_adapter_schema_path(&adapter_name, &entry); - assert_eq!(path, PathBuf::from("foo/bar")); + let path = build_debug_adapter_schema_path(&adapter_name, &entry).unwrap(); + assert_eq!(path, rel_path_buf("foo/bar")); } #[test] @@ -490,11 +495,8 @@ mod tests { let adapter_name = Arc::from("my_adapter"); let entry = DebugAdapterManifestEntry { schema_path: None }; - let path = build_debug_adapter_schema_path(&adapter_name, &entry); - assert_eq!( - path, - PathBuf::from("debug_adapter_schemas").join("my_adapter.json") - ); + let path = build_debug_adapter_schema_path(&adapter_name, &entry).unwrap(); + assert_eq!(path, rel_path_buf("debug_adapter_schemas/my_adapter.json")); } #[test] @@ -572,22 +574,37 @@ mod tests { ); assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg } + + #[test] + #[cfg(target_os = "windows")] + fn test_deserialize_manifest_with_windows_separators() { + let content = indoc! {r#" + id = "test-manifest" + name = "Test Manifest" + version = "0.0.1" + schema_version = 0 + languages = ["foo\\bar"] + "#}; + let manifest: ExtensionManifest = toml::from_str(&content).expect("manifest should parse"); + assert_eq!(manifest.languages, vec![rel_path_buf("foo/bar")]); + } + #[test] fn parse_manifest_with_agent_server_archive_launcher() { - let toml_src = r#" -id = "example.agent-server-ext" -name = "Agent Server Example" -version = "1.0.0" -schema_version = 0 - -[agent_servers.foo] -name = "Foo Agent" - -[agent_servers.foo.targets.linux-x86_64] -archive = "https://example.com/agent-linux-x64.tar.gz" -cmd = "./agent" -args = ["--serve"] -"#; + let toml_src = indoc! {r#" + id = "example.agent-server-ext" + name = "Agent Server Example" + version = "1.0.0" + schema_version = 0 + + [agent_servers.foo] + name = "Foo Agent" + + [agent_servers.foo.targets.linux-x86_64] + archive = "https://example.com/agent-linux-x64.tar.gz" + cmd = "./agent" + args = ["--serve"] + "#}; let manifest: ExtensionManifest = toml::from_str(toml_src).expect("manifest should parse"); assert_eq!(manifest.id.as_ref(), "example.agent-server-ext"); diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 57845754fc8263..38dc626562beec 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -165,6 +165,7 @@ async fn copy_extension_resources( let output_themes_dir = output_dir.join("themes"); fs::create_dir_all(&output_themes_dir)?; for theme_path in &manifest.themes { + let theme_path = theme_path.as_std_path(); fs::copy( extension_path.join(theme_path), output_themes_dir.join(theme_path.file_name().context("invalid theme path")?), @@ -177,6 +178,7 @@ async fn copy_extension_resources( let output_icon_themes_dir = output_dir.join("icon_themes"); fs::create_dir_all(&output_icon_themes_dir)?; for icon_theme_path in &manifest.icon_themes { + let icon_theme_path = icon_theme_path.as_std_path(); fs::copy( extension_path.join(icon_theme_path), output_icon_themes_dir.join( @@ -224,6 +226,7 @@ async fn copy_extension_resources( let output_languages_dir = output_dir.join("languages"); fs::create_dir_all(&output_languages_dir)?; for language_path in &manifest.languages { + let language_path = language_path.as_std_path(); copy_recursive( fs.as_ref(), &extension_path.join(language_path), @@ -243,14 +246,11 @@ async fn copy_extension_resources( if !manifest.debug_adapters.is_empty() { for (debug_adapter, entry) in &manifest.debug_adapters { - let schema_path = entry.schema_path.clone().unwrap_or_else(|| { - PathBuf::from("debug_adapter_schemas".to_owned()) - .join(debug_adapter.as_ref()) - .with_extension("json") - }); + let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, entry)?; let parent = schema_path .parent() .with_context(|| format!("invalid empty schema path for {debug_adapter}"))?; + let schema_path = schema_path.as_std_path(); fs::create_dir_all(output_dir.join(parent))?; copy_recursive( fs.as_ref(), @@ -265,7 +265,7 @@ async fn copy_extension_resources( .with_context(|| { format!( "failed to copy debug adapter schema '{}'", - schema_path.display() + schema_path.display(), ) })?; } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 03f340a56a98eb..ca43b4a3993f6e 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -56,7 +56,7 @@ use std::{ }; use task::TaskTemplates; use url::Url; -use util::{ResultExt, paths::RemotePathBuf}; +use util::{ResultExt, paths::RemotePathBuf, rel_path::PathExt}; use wasm_host::{ WasmExtension, WasmHost, wit::{is_supported_wasm_api_version, wasm_api_version_range}, @@ -1244,13 +1244,16 @@ impl ExtensionStore { })); themes_to_add.extend(extension.manifest.themes.iter().map(|theme_path| { let mut path = self.installed_dir.clone(); - path.extend([Path::new(extension_id.as_ref()), theme_path.as_path()]); + path.extend([Path::new(extension_id.as_ref()), theme_path.as_std_path()]); path })); icon_themes_to_add.extend(extension.manifest.icon_themes.iter().map( |icon_theme_path| { let mut path = self.installed_dir.clone(); - path.extend([Path::new(extension_id.as_ref()), icon_theme_path.as_path()]); + path.extend([ + Path::new(extension_id.as_ref()), + icon_theme_path.as_std_path(), + ]); let mut icons_root_path = self.installed_dir.clone(); icons_root_path.extend([Path::new(extension_id.as_ref())]); @@ -1560,7 +1563,7 @@ impl ExtensionStore { })?; let config = ::toml::from_str::(&config)?; - let relative_path = relative_path.to_path_buf(); + let relative_path = relative_path.to_rel_path_buf()?; if !extension_manifest.languages.contains(&relative_path) { extension_manifest.languages.push(relative_path.clone()); } @@ -1569,7 +1572,7 @@ impl ExtensionStore { config.name.clone(), ExtensionIndexLanguageEntry { extension: extension_id.clone(), - path: relative_path, + path: relative_path.as_std_path().to_path_buf(), matcher: config.matcher, hidden: config.hidden, grammar: config.grammar, @@ -1593,7 +1596,7 @@ impl ExtensionStore { continue; }; - let relative_path = relative_path.to_path_buf(); + let relative_path = relative_path.to_rel_path_buf()?; if !extension_manifest.themes.contains(&relative_path) { extension_manifest.themes.push(relative_path.clone()); } @@ -1603,7 +1606,7 @@ impl ExtensionStore { theme_name.into(), ExtensionIndexThemeEntry { extension: extension_id.clone(), - path: relative_path.clone(), + path: relative_path.as_std_path().to_path_buf(), }, ); } @@ -1625,7 +1628,7 @@ impl ExtensionStore { continue; }; - let relative_path = relative_path.to_path_buf(); + let relative_path = relative_path.to_rel_path_buf()?; if !extension_manifest.icon_themes.contains(&relative_path) { extension_manifest.icon_themes.push(relative_path.clone()); } @@ -1635,7 +1638,7 @@ impl ExtensionStore { icon_theme_name.into(), ExtensionIndexIconThemeEntry { extension: extension_id.clone(), - path: relative_path.clone(), + path: relative_path.as_std_path().to_path_buf(), }, ); } @@ -1721,15 +1724,15 @@ impl ExtensionStore { } for (adapter_name, meta) in loaded_extension.manifest.debug_adapters.iter() { - let schema_path = &extension::build_debug_adapter_schema_path(adapter_name, meta); + let schema_path = extension::build_debug_adapter_schema_path(adapter_name, meta)?; - if fs.is_file(&src_dir.join(schema_path)).await { + if fs.is_file(&src_dir.join(&schema_path)).await { if let Some(parent) = schema_path.parent() { fs.create_dir(&tmp_dir.join(parent)).await? } fs.copy_file( - &src_dir.join(schema_path), - &tmp_dir.join(schema_path), + &src_dir.join(&schema_path), + &tmp_dir.join(&schema_path), fs::CopyOptions::default(), ) .await? diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index c395aedb260036..abdb3ffd3fad2b 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -26,7 +26,7 @@ use std::{ sync::Arc, }; use theme::ThemeRegistry; -use util::test::TempTree; +use util::{rel_path::rel_path_buf, test::TempTree}; #[cfg(test)] #[ctor::ctor] @@ -150,7 +150,10 @@ async fn test_extension_store(cx: &mut TestAppContext) { themes: Default::default(), icon_themes: Vec::new(), lib: Default::default(), - languages: vec!["languages/erb".into(), "languages/ruby".into()], + languages: vec![ + rel_path_buf("languages/erb"), + rel_path_buf("languages/ruby"), + ], grammars: [ ("embedded_template".into(), GrammarManifestEntry::default()), ("ruby".into(), GrammarManifestEntry::default()), @@ -182,8 +185,8 @@ async fn test_extension_store(cx: &mut TestAppContext) { authors: vec![], repository: None, themes: vec![ - "themes/monokai-pro.json".into(), - "themes/monokai.json".into(), + rel_path_buf("themes/monokai-pro.json"), + rel_path_buf("themes/monokai.json"), ], icon_themes: Vec::new(), lib: Default::default(), @@ -367,7 +370,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { description: None, authors: vec![], repository: None, - themes: vec!["themes/gruvbox.json".into()], + themes: vec![rel_path_buf("themes/gruvbox.json")], icon_themes: Vec::new(), lib: Default::default(), languages: Default::default(), diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 7c30228257dbaa..725e8e571dac67 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -194,7 +194,7 @@ impl HeadlessExtensionStore { } for (debug_adapter, meta) in &manifest.debug_adapters { - let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, meta); + let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, meta)?; this.update(cx, |this, _cx| { this.proxy.register_debug_adapter( diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 19bf62d8bbc476..0e6bfe8498dc5b 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -14,7 +14,7 @@ use editor::{Editor, EditorElement, EditorStyle}; use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{StringMatchCandidate, match_strings}; use gpui::{ - Action, App, ClipboardItem, Context, Corner, Entity, EventEmitter, Focusable, + Action, Anchor, App, ClipboardItem, Context, Entity, EventEmitter, Focusable, InteractiveElement, KeyContext, ParentElement, Point, Render, Styled, Task, TextStyle, UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list, }; @@ -923,7 +923,7 @@ impl ExtensionsPage { ) .icon_size(IconSize::Small), ) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .offset(Point { x: px(0.0), y: px(2.0), diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index ae2980c699fd19..dadcab383f091e 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -259,12 +259,14 @@ impl FeatureFlagAppExt for App { { self.observe_global::(move |cx| { let store = cx.global::(); - callback( - OnFlagsReady { - is_staff: store.is_staff(), - }, - cx, - ); + if store.server_flags_received() { + callback( + OnFlagsReady { + is_staff: store.is_staff(), + }, + cx, + ); + } }) } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 1665e6ffb6c068..aae8137a0a6e9d 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -16,18 +16,6 @@ impl FeatureFlag for PanicFeatureFlag { } register_feature_flag!(PanicFeatureFlag); -pub struct AgentV2FeatureFlag; - -impl FeatureFlag for AgentV2FeatureFlag { - const NAME: &'static str = "agent-v2"; - type Value = PresenceFlag; - - fn enabled_for_staff() -> bool { - true - } -} -register_feature_flag!(AgentV2FeatureFlag); - /// A feature flag for granting access to beta ACP features. /// /// We reuse this feature flag for new betas, so don't delete it if it is not currently in use. diff --git a/crates/feature_flags/src/store.rs b/crates/feature_flags/src/store.rs index 54d261fc7261a0..a8376de7e3a878 100644 --- a/crates/feature_flags/src/store.rs +++ b/crates/feature_flags/src/store.rs @@ -70,6 +70,7 @@ macro_rules! register_feature_flag { pub struct FeatureFlagStore { staff: bool, server_flags: HashMap, + server_flags_received: bool, _settings_subscription: Option, } @@ -95,12 +96,17 @@ impl FeatureFlagStore { self.staff } + pub fn server_flags_received(&self) -> bool { + self.server_flags_received + } + pub fn set_staff(&mut self, staff: bool) { self.staff = staff; } pub fn update_server_flags(&mut self, staff: bool, flags: Vec) { self.staff = staff; + self.server_flags_received = true; self.server_flags.clear(); for flag in flags { self.server_flags.insert(flag.clone(), flag); @@ -371,4 +377,32 @@ mod tests { assert_eq!(store.try_flag_value::(cx), None); assert_eq!(PresenceFlag::default(), PresenceFlag::Off); } + + #[gpui::test] + fn on_flags_ready_waits_for_server_flags(cx: &mut gpui::TestAppContext) { + use crate::FeatureFlagAppExt; + use std::cell::Cell; + use std::rc::Rc; + + cx.update(|cx| { + init_settings_store(cx); + FeatureFlagStore::init(cx); + }); + + let fired = Rc::new(Cell::new(false)); + cx.update({ + let fired = fired.clone(); + |cx| cx.on_flags_ready(move |_, _| fired.set(true)).detach() + }); + + // Settings-triggered no-op touch must not fire on_flags_ready. + cx.update(|cx| cx.update_default_global::(|_, _| {})); + cx.run_until_parked(); + assert!(!fired.get()); + + // Server flags arrive — now it should fire. + cx.update(|cx| cx.update_flags(true, vec![])); + cx.run_until_parked(); + assert!(fired.get()); + } } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index ddba89c9c744f8..9a9cc983fa74d9 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1770,8 +1770,8 @@ impl PickerDelegate for FileFinderDelegate { .child( PopoverMenu::new("filter-menu-popover") .with_handle(self.filter_popover_menu_handle.clone()) - .attach(gpui::Corner::BottomRight) - .anchor(gpui::Corner::BottomLeft) + .attach(gpui::Anchor::BottomRight) + .anchor(gpui::Anchor::BottomLeft) .offset(gpui::Point { x: px(1.0), y: px(1.0), @@ -1830,8 +1830,8 @@ impl PickerDelegate for FileFinderDelegate { .child( PopoverMenu::new("split-menu-popover") .with_handle(self.split_popover_menu_handle.clone()) - .attach(gpui::Corner::BottomRight) - .anchor(gpui::Corner::BottomLeft) + .attach(gpui::Anchor::BottomRight) + .anchor(gpui::Anchor::BottomLeft) .offset(gpui::Point { x: px(1.0), y: px(1.0), diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index e44f557646239d..fa42c436f1b9be 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -1889,7 +1889,10 @@ impl FakeFs { drop(repo_state); if emit_git_event { - state.emit_event([(dot_git, Some(PathEventKind::Changed))]); + state.emit_event([( + dot_git.join("fake_git_repo_event"), + Some(PathEventKind::Changed), + )]); } Ok(result) @@ -1944,7 +1947,10 @@ impl FakeFs { if emit_git_event { drop(repo_state); - state.emit_event([(canonical_path, Some(PathEventKind::Changed))]); + state.emit_event([( + canonical_path.join("fake_git_repo_event"), + Some(PathEventKind::Changed), + )]); } Ok(result) diff --git a/crates/fuzzy_nucleo/Cargo.toml b/crates/fuzzy_nucleo/Cargo.toml index b2152035ff317a..2f9a1b9ec39bee 100644 --- a/crates/fuzzy_nucleo/Cargo.toml +++ b/crates/fuzzy_nucleo/Cargo.toml @@ -20,6 +20,7 @@ util.workspace = true [dev-dependencies] criterion.workspace = true +gpui = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } [[bench]] diff --git a/crates/fuzzy_nucleo/benches/match_benchmark.rs b/crates/fuzzy_nucleo/benches/match_benchmark.rs index 3aab6e756fcb94..8f6eedce491613 100644 --- a/crates/fuzzy_nucleo/benches/match_benchmark.rs +++ b/crates/fuzzy_nucleo/benches/match_benchmark.rs @@ -1,5 +1,6 @@ use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; use fuzzy::CharBag; +use std::sync::atomic::AtomicBool; use util::{paths::PathStyle, rel_path::RelPath}; const DIRS: &[&str] = &[ @@ -129,6 +130,92 @@ fn generate_queries(count: usize) -> (Vec, Vec, Vec) { (n_word(1), n_word(2), n_word(4)) } +fn generate_candidates(count: usize) -> Vec { + (0..count) + .map(|id| { + let dir = DIRS[id % DIRS.len()]; + let file = FILENAMES[id / DIRS.len() % FILENAMES.len()]; + fuzzy_nucleo::StringMatchCandidate::new(id, &format!("{dir}/{file}")) + }) + .collect() +} + +fn to_fuzzy_candidates( + candidates: &[fuzzy_nucleo::StringMatchCandidate], +) -> Vec { + candidates + .iter() + .map(|c| fuzzy::StringMatchCandidate::new(c.id, c.string.as_ref())) + .collect() +} + +fn bench_string_matching(criterion: &mut Criterion) { + let cancel = AtomicBool::new(false); + + let dispatcher = std::sync::Arc::new(gpui::TestDispatcher::new(0)); + let background_executor = gpui::BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = gpui::ForegroundExecutor::new(dispatcher); + + let sizes = [100, 1000, 10_000]; + let query_count = 200; + let (q1, q2, q4) = generate_queries(query_count); + + for (label, queries) in [("1-word", &q1), ("2-word", &q2), ("4-word", &q4)] { + let mut group = criterion.benchmark_group(label); + for size in sizes { + let candidates = generate_candidates(size); + let fuzzy_candidates = to_fuzzy_candidates(&candidates); + + let mut query_idx = 0usize; + group.bench_function(BenchmarkId::new("nucleo", size), |b| { + b.iter_batched( + || { + let query = queries[query_idx % queries.len()].as_str(); + query_idx += 1; + query + }, + |query| { + foreground_executor.block_on(fuzzy_nucleo::match_strings_async( + &candidates, + query, + fuzzy_nucleo::Case::Ignore, + fuzzy_nucleo::LengthPenalty::On, + size, + &cancel, + background_executor.clone(), + )) + }, + BatchSize::SmallInput, + ) + }); + + let mut query_idx = 0usize; + group.bench_function(BenchmarkId::new("fuzzy", size), |b| { + b.iter_batched( + || { + let query = queries[query_idx % queries.len()].as_str(); + query_idx += 1; + query + }, + |query| { + foreground_executor.block_on(fuzzy::match_strings( + &fuzzy_candidates, + query, + false, + true, + size, + &cancel, + background_executor.clone(), + )) + }, + BatchSize::SmallInput, + ) + }); + } + group.finish(); + } +} + fn generate_path_strings(count: usize) -> &'static [String] { let paths: Box<[String]> = (0..count) .map(|id| { @@ -249,5 +336,5 @@ fn bench_path_matching(criterion: &mut Criterion) { } } -criterion_group!(benches, bench_path_matching); +criterion_group!(benches, bench_string_matching, bench_path_matching); criterion_main!(benches); diff --git a/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs index dcc9edf37d4bf3..a6b32f6e1cc1b9 100644 --- a/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs +++ b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs @@ -1,8 +1,11 @@ mod matcher; mod paths; +mod strings; + pub use paths::{ PathMatch, PathMatchCandidate, PathMatchCandidateSet, match_fixed_path_set, match_path_sets, }; +pub use strings::{StringMatch, StringMatchCandidate, match_strings, match_strings_async}; pub(crate) struct Cancelled; @@ -13,8 +16,12 @@ pub enum Case { } impl Case { - pub fn from_smart(smart: bool) -> Self { - if smart { Self::Smart } else { Self::Ignore } + pub fn smart_if_uppercase_in(query: &str) -> Self { + if query.chars().any(|c| c.is_uppercase()) { + Self::Smart + } else { + Self::Ignore + } } pub fn is_smart(self) -> bool { diff --git a/crates/fuzzy_nucleo/src/strings.rs b/crates/fuzzy_nucleo/src/strings.rs new file mode 100644 index 00000000000000..4f3f02767a8900 --- /dev/null +++ b/crates/fuzzy_nucleo/src/strings.rs @@ -0,0 +1,844 @@ +use std::{ + borrow::Borrow, + cmp::Ordering, + iter, + ops::Range, + sync::atomic::{self, AtomicBool}, +}; + +use gpui::{BackgroundExecutor, SharedString}; +use nucleo::Utf32Str; +use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization}; + +use crate::{ + Cancelled, Case, LengthPenalty, + matcher::{self, LENGTH_PENALTY}, + positions_from_sorted, +}; +use fuzzy::CharBag; + +// String matching is always case-insensitive at the nucleo level — using +// `CaseMatching::Smart` there would reject queries whose capitalization +// doesn't match the candidate, breaking pickers like the command palette +// (`"Editor: Backspace"` against the action named `"editor: backspace"`). +// `Case::Smart` is still honored as a *scoring hint*: when the query +// contains uppercase, candidates whose matched characters disagree in case +// are downranked rather than dropped. +const SMART_CASE_PENALTY_PER_MISMATCH: f64 = 0.9; + +struct Query { + atoms: Vec, + source_words: Option>>, + char_bag: CharBag, +} + +impl Query { + fn build(query: &str, case: Case) -> Option { + let mut atoms = Vec::new(); + let mut source_words = Vec::new(); + let wants_case_penalty = case.is_smart() && query.chars().any(|c| c.is_uppercase()); + + for word in query.split_whitespace() { + atoms.push(Atom::new( + word, + CaseMatching::Ignore, + Normalization::Smart, + AtomKind::Fuzzy, + false, + )); + if wants_case_penalty { + source_words.push(word.chars().collect()); + } + } + + if atoms.is_empty() { + return None; + } + + Some(Query { + atoms, + source_words: wants_case_penalty.then_some(source_words), + char_bag: CharBag::from(query), + }) + } +} + +#[derive(Clone, Debug)] +pub struct StringMatchCandidate { + pub id: usize, + pub string: SharedString, + char_bag: CharBag, +} + +impl StringMatchCandidate { + pub fn new(id: usize, string: impl ToString) -> Self { + Self::from_shared(id, SharedString::new(string.to_string())) + } + + pub fn from_shared(id: usize, string: SharedString) -> Self { + let char_bag = CharBag::from(string.as_ref()); + Self { + id, + string, + char_bag, + } + } +} + +#[derive(Clone, Debug)] +pub struct StringMatch { + pub candidate_id: usize, + pub score: f64, + pub positions: Vec, + pub string: SharedString, +} + +impl StringMatch { + pub fn ranges(&self) -> impl '_ + Iterator> { + let mut positions = self.positions.iter().peekable(); + iter::from_fn(move || { + let start = *positions.next()?; + let char_len = self.char_len_at_index(start)?; + let mut end = start + char_len; + while let Some(next_start) = positions.peek() { + if end == **next_start { + let Some(char_len) = self.char_len_at_index(end) else { + break; + }; + end += char_len; + positions.next(); + } else { + break; + } + } + Some(start..end) + }) + } + + fn char_len_at_index(&self, ix: usize) -> Option { + self.string + .get(ix..) + .and_then(|slice| slice.chars().next().map(|c| c.len_utf8())) + } +} + +impl PartialEq for StringMatch { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} + +impl Eq for StringMatch {} + +impl PartialOrd for StringMatch { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for StringMatch { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .total_cmp(&other.score) + .then_with(|| self.candidate_id.cmp(&other.candidate_id)) + } +} + +pub async fn match_strings_async( + candidates: &[T], + query: &str, + case: Case, + length_penalty: LengthPenalty, + max_results: usize, + cancel_flag: &AtomicBool, + executor: BackgroundExecutor, +) -> Vec +where + T: Borrow + Sync, +{ + if candidates.is_empty() || max_results == 0 { + return Vec::new(); + } + + let Some(query) = Query::build(query, case) else { + return empty_query_results(candidates, max_results); + }; + + let num_cpus = executor.num_cpus().min(candidates.len()); + let base_size = candidates.len() / num_cpus; + let remainder = candidates.len() % num_cpus; + let mut segment_results = (0..num_cpus) + .map(|_| Vec::with_capacity(max_results.min(candidates.len()))) + .collect::>(); + + let config = nucleo::Config::DEFAULT; + let mut matchers = matcher::get_matchers(num_cpus, config); + + executor + .scoped(|scope| { + for (segment_idx, (results, matcher)) in segment_results + .iter_mut() + .zip(matchers.iter_mut()) + .enumerate() + { + let query = &query; + scope.spawn(async move { + let segment_start = segment_idx * base_size + segment_idx.min(remainder); + let segment_end = + (segment_idx + 1) * base_size + (segment_idx + 1).min(remainder); + + match_string_helper( + &candidates[segment_start..segment_end], + query, + matcher, + length_penalty, + results, + cancel_flag, + ) + .ok(); + }); + } + }) + .await; + + matcher::return_matchers(matchers); + + if cancel_flag.load(atomic::Ordering::Acquire) { + return Vec::new(); + } + + let mut results = segment_results.concat(); + util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a)); + results +} + +pub fn match_strings( + candidates: &[T], + query: &str, + case: Case, + length_penalty: LengthPenalty, + max_results: usize, +) -> Vec +where + T: Borrow, +{ + if candidates.is_empty() || max_results == 0 { + return Vec::new(); + } + + let Some(query) = Query::build(query, case) else { + return empty_query_results(candidates, max_results); + }; + + let config = nucleo::Config::DEFAULT; + let mut matcher = matcher::get_matcher(config); + let mut results = Vec::with_capacity(max_results.min(candidates.len())); + + match_string_helper( + candidates, + &query, + &mut matcher, + length_penalty, + &mut results, + &AtomicBool::new(false), + ) + .ok(); + + matcher::return_matcher(matcher); + util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a)); + results +} + +fn empty_query_results>( + candidates: &[T], + max_results: usize, +) -> Vec { + candidates + .iter() + .take(max_results) + .map(|candidate| { + let borrowed = candidate.borrow(); + StringMatch { + candidate_id: borrowed.id, + score: 0., + positions: Vec::new(), + string: borrowed.string.clone(), + } + }) + .collect() +} + +fn match_string_helper( + candidates: &[T], + query: &Query, + matcher: &mut nucleo::Matcher, + length_penalty: LengthPenalty, + results: &mut Vec, + cancel_flag: &AtomicBool, +) -> Result<(), Cancelled> +where + T: Borrow, +{ + let mut buf = Vec::new(); + let mut matched_chars: Vec = Vec::new(); + let mut atom_matched_chars = Vec::new(); + let mut candidate_chars: Vec = Vec::new(); + + for candidate in candidates { + buf.clear(); + matched_chars.clear(); + if cancel_flag.load(atomic::Ordering::Relaxed) { + return Err(Cancelled); + } + + let borrowed = candidate.borrow(); + + if !borrowed.char_bag.is_superset(query.char_bag) { + continue; + } + + let haystack: Utf32Str = Utf32Str::new(&borrowed.string, &mut buf); + + if query.source_words.is_some() { + candidate_chars.clear(); + candidate_chars.extend(borrowed.string.chars()); + } + + let mut total_score: u32 = 0; + let mut case_mismatches: u32 = 0; + let mut all_matched = true; + + for (atom_idx, atom) in query.atoms.iter().enumerate() { + atom_matched_chars.clear(); + let Some(score) = atom.indices(haystack, matcher, &mut atom_matched_chars) else { + all_matched = false; + break; + }; + total_score = total_score.saturating_add(score as u32); + if let Some(source_words) = query.source_words.as_deref() { + let query_chars = &source_words[atom_idx]; + if query_chars.len() == atom_matched_chars.len() { + for (&query_char, &pos) in query_chars.iter().zip(&atom_matched_chars) { + if let Some(&candidate_char) = candidate_chars.get(pos as usize) + && candidate_char != query_char + && candidate_char.eq_ignore_ascii_case(&query_char) + { + case_mismatches += 1; + } + } + } + } + matched_chars.extend_from_slice(&atom_matched_chars); + } + + if all_matched { + matched_chars.sort_unstable(); + matched_chars.dedup(); + + let positive = total_score as f64 * case_penalty(case_mismatches); + let adjusted_score = + positive - length_penalty_for(borrowed.string.as_ref(), length_penalty); + let positions = positions_from_sorted(borrowed.string.as_ref(), &matched_chars); + + results.push(StringMatch { + candidate_id: borrowed.id, + score: adjusted_score, + positions, + string: borrowed.string.clone(), + }); + } + } + Ok(()) +} + +#[inline] +fn case_penalty(mismatches: u32) -> f64 { + if mismatches == 0 { + 1.0 + } else { + SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32) + } +} + +#[inline] +fn length_penalty_for(s: &str, length_penalty: LengthPenalty) -> f64 { + if length_penalty.is_on() { + s.len() as f64 * LENGTH_PENALTY + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::BackgroundExecutor; + + fn candidates(strings: &[&str]) -> Vec { + strings + .iter() + .enumerate() + .map(|(id, s)| StringMatchCandidate::new(id, s)) + .collect() + } + + #[gpui::test] + async fn test_basic_match(executor: BackgroundExecutor) { + let cs = candidates(&["hello", "world", "help"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "hel", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect(); + assert!(matched.contains(&"hello")); + assert!(matched.contains(&"help")); + assert!(!matched.contains(&"world")); + } + + #[gpui::test] + async fn test_multi_word_query(executor: BackgroundExecutor) { + let cs = candidates(&[ + "src/lib/parser.rs", + "src/bin/main.rs", + "tests/parser_test.rs", + ]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "src parser", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert_eq!(results.len(), 1); + assert_eq!(results[0].string, "src/lib/parser.rs"); + } + + #[gpui::test] + async fn test_empty_query_returns_all(executor: BackgroundExecutor) { + let cs = candidates(&["alpha", "beta", "gamma"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert_eq!(results.len(), 3); + assert!(results.iter().all(|m| m.score == 0.0)); + } + + #[gpui::test] + async fn test_whitespace_only_query_returns_all(executor: BackgroundExecutor) { + let cs = candidates(&["alpha", "beta", "gamma"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + " \t\n", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert_eq!(results.len(), 3); + } + + #[gpui::test] + async fn test_empty_candidates(executor: BackgroundExecutor) { + let cs: Vec = vec![]; + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "query", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert!(results.is_empty()); + } + + #[gpui::test] + async fn test_cancellation(executor: BackgroundExecutor) { + let cs = candidates(&["hello", "world"]); + let cancel = AtomicBool::new(true); + let results = match_strings_async( + &cs, + "hel", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert!(results.is_empty()); + } + + #[gpui::test] + async fn test_max_results_limit(executor: BackgroundExecutor) { + let cs = candidates(&["ab", "abc", "abcd", "abcde"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "ab", + Case::Ignore, + LengthPenalty::Off, + 2, + &cancel, + executor, + ) + .await; + assert_eq!(results.len(), 2); + } + + #[gpui::test] + async fn test_scoring_order(executor: BackgroundExecutor) { + let cs = candidates(&[ + "some_very_long_variable_name_fuzzy", + "fuzzy", + "a_fuzzy_thing", + ]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "fuzzy", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor.clone(), + ) + .await; + + let ordered = matches!( + ( + results[0].string.as_ref(), + results[1].string.as_ref(), + results[2].string.as_ref() + ), + ( + "fuzzy", + "a_fuzzy_thing", + "some_very_long_variable_name_fuzzy" + ) + ); + assert!(ordered, "matches are not in the proper order."); + + let results_penalty = match_strings_async( + &cs, + "fuzzy", + Case::Ignore, + LengthPenalty::On, + 10, + &cancel, + executor, + ) + .await; + let greater = results[2].score > results_penalty[2].score; + assert!(greater, "penalize length not affecting long candidates"); + } + + #[gpui::test] + async fn test_utf8_positions(executor: BackgroundExecutor) { + let cs = candidates(&["café"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "caf", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert_eq!(results.len(), 1); + let m = &results[0]; + assert_eq!(m.positions, vec![0, 1, 2]); + for &pos in &m.positions { + assert!(m.string.is_char_boundary(pos)); + } + } + + #[gpui::test] + async fn test_smart_case(executor: BackgroundExecutor) { + let cs = candidates(&["FooBar", "foobar", "FOOBAR"]); + let cancel = AtomicBool::new(false); + + let case_insensitive = match_strings_async( + &cs, + "foobar", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor.clone(), + ) + .await; + assert_eq!(case_insensitive.len(), 3); + + let smart = match_strings_async( + &cs, + "FooBar", + Case::Smart, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert!(smart.iter().any(|m| m.string == "FooBar")); + let foobar_score = smart.iter().find(|m| m.string == "FooBar").map(|m| m.score); + let lower_score = smart.iter().find(|m| m.string == "foobar").map(|m| m.score); + if let (Some(exact), Some(lower)) = (foobar_score, lower_score) { + assert!(exact >= lower); + } + } + + #[gpui::test] + async fn test_smart_case_does_not_flip_order_when_length_penalty_on( + executor: BackgroundExecutor, + ) { + // Regression for the sign bug: with a length penalty large enough to push + // `total_score - length_penalty` negative, case mismatches used to make + // scores *better* (less negative). Exact-case match must still rank first. + let cs = candidates(&[ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaa_FooBar", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaa_foobar", + ]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "FooBar", + Case::Smart, + LengthPenalty::On, + 10, + &cancel, + executor, + ) + .await; + let exact = results + .iter() + .find(|m| m.string.as_ref() == "aaaaaaaaaaaaaaaaaaaaaaaaaaaa_FooBar") + .map(|m| m.score) + .expect("exact-case candidate should match"); + let mismatch = results + .iter() + .find(|m| m.string.as_ref() == "aaaaaaaaaaaaaaaaaaaaaaaaaaaa_foobar") + .map(|m| m.score) + .expect("mismatch-case candidate should match"); + assert!( + exact >= mismatch, + "exact-case score ({exact}) should be >= mismatch-case score ({mismatch})" + ); + } + + #[gpui::test] + async fn test_char_bag_prefilter(executor: BackgroundExecutor) { + let cs = candidates(&["abcdef", "abc", "def", "aabbcc"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "abc", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect(); + assert!(matched.contains(&"abcdef")); + assert!(matched.contains(&"abc")); + assert!(matched.contains(&"aabbcc")); + assert!(!matched.contains(&"def")); + } + + #[test] + fn test_sync_basic_match() { + let cs = candidates(&["hello", "world", "help"]); + let results = match_strings(&cs, "hel", Case::Ignore, LengthPenalty::Off, 10); + let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect(); + assert!(matched.contains(&"hello")); + assert!(matched.contains(&"help")); + assert!(!matched.contains(&"world")); + } + + #[test] + fn test_sync_empty_query_returns_all() { + let cs = candidates(&["alpha", "beta", "gamma"]); + let results = match_strings(&cs, "", Case::Ignore, LengthPenalty::Off, 10); + assert_eq!(results.len(), 3); + } + + #[test] + fn test_sync_whitespace_only_query_returns_all() { + let cs = candidates(&["alpha", "beta", "gamma"]); + let results = match_strings(&cs, " ", Case::Ignore, LengthPenalty::Off, 10); + assert_eq!(results.len(), 3); + } + + #[test] + fn test_sync_max_results() { + let cs = candidates(&["ab", "abc", "abcd", "abcde"]); + let results = match_strings(&cs, "ab", Case::Ignore, LengthPenalty::Off, 2); + assert_eq!(results.len(), 2); + } + + #[gpui::test] + async fn test_empty_query_respects_max_results(executor: BackgroundExecutor) { + let cs = candidates(&["alpha", "beta", "gamma", "delta"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "", + Case::Ignore, + LengthPenalty::Off, + 2, + &cancel, + executor, + ) + .await; + assert_eq!(results.len(), 2); + } + + #[gpui::test] + async fn test_multi_word_with_nonmatching_word(executor: BackgroundExecutor) { + let cs = candidates(&["src/parser.rs", "src/main.rs"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "src xyzzy", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert!( + results.is_empty(), + "no candidate contains 'xyzzy', so nothing should match" + ); + } + + #[gpui::test] + async fn test_segment_size_not_divisible_by_cpus(executor: BackgroundExecutor) { + executor.set_num_cpus(4); + let cs = candidates(&["alpha", "beta", "gamma", "delta", "epsilon"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "a", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect(); + assert!(matched.contains(&"alpha")); + assert!(matched.contains(&"gamma")); + assert!(matched.contains(&"delta")); + } + + #[gpui::test] + async fn test_segment_size_with_many_cpus_few_candidates(executor: BackgroundExecutor) { + executor.set_num_cpus(16); + let cs = candidates(&["one", "two", "three"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "o", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect(); + assert!(matched.contains(&"one")); + assert!(matched.contains(&"two")); + } + + #[gpui::test] + async fn test_segment_size_single_candidate(executor: BackgroundExecutor) { + executor.set_num_cpus(8); + let cs = candidates(&["lonely"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "lone", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert_eq!(results.len(), 1); + assert_eq!(results[0].string.as_ref(), "lonely"); + } + + #[gpui::test] + async fn test_segment_size_candidates_equal_cpus(executor: BackgroundExecutor) { + executor.set_num_cpus(4); + let cs = candidates(&["aaa", "bbb", "ccc", "ddd"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "a", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + assert_eq!(results.len(), 1); + assert_eq!(results[0].string.as_ref(), "aaa"); + } + + #[gpui::test] + async fn test_segment_size_candidates_one_more_than_cpus(executor: BackgroundExecutor) { + executor.set_num_cpus(3); + let cs = candidates(&["ant", "ape", "dog", "axe"]); + let cancel = AtomicBool::new(false); + let results = match_strings_async( + &cs, + "a", + Case::Ignore, + LengthPenalty::Off, + 10, + &cancel, + executor, + ) + .await; + let matched: Vec<&str> = results.iter().map(|m| m.string.as_ref()).collect(); + assert!(matched.contains(&"ant")); + assert!(matched.contains(&"ape")); + assert!(matched.contains(&"axe")); + assert!(!matched.contains(&"dog")); + } +} diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 1d06ecb1c49fd0..e0175db09f1ef9 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -11,7 +11,7 @@ use git::{ }; use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon}; use gpui::{ - AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent, + Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels, Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, @@ -27,7 +27,6 @@ use search::{ SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, buffer_search, }; -use settings::Settings; use smallvec::{SmallVec, smallvec}; use std::{ cell::Cell, @@ -37,7 +36,6 @@ use std::{ time::{Duration, Instant}, }; use theme::AccentColors; -use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, @@ -58,6 +56,9 @@ const LEFT_PADDING: Pixels = px(12.0); const LINE_WIDTH: Pixels = px(1.5); const RESIZE_HANDLE_WIDTH: f32 = 8.0; const COPIED_STATE_DURATION: Duration = Duration::from_secs(2); +// Extra vertical breathing room added to the UI line height when computing +// the git graph's row height, so commit dots and lines have space around them. +const ROW_VERTICAL_PADDING: Pixels = px(4.0); struct CopiedState { copied_at: Option, @@ -901,7 +902,6 @@ pub struct GitGraph { git_store: Entity, workspace: WeakEntity, context_menu: Option<(Entity, Point, Subscription)>, - row_height: Pixels, table_interaction_state: Entity, column_widths: Entity, selected_entry_idx: Option, @@ -927,10 +927,19 @@ impl GitGraph { cx.notify(); } - fn row_height(cx: &App) -> Pixels { - let settings = ThemeSettings::get_global(cx); - let font_size = settings.buffer_font_size(cx); - font_size + px(12.0) + /// Computes the height of a single commit row in the git graph. + /// + /// The returned value is snapped to the nearest physical pixel. This is + /// required so that the canvas's float math and the `uniform_list` layout + /// (which snaps to device pixels) agree on row positions; otherwise rows + /// drift apart as the user scrolls when `ui_font_size` is fractional. + fn row_height(window: &Window, _cx: &App) -> Pixels { + let rem_size = window.rem_size(); + let line_height = window.text_style().line_height_in_pixels(rem_size); + let raw = line_height + ROW_VERTICAL_PADDING; + let scale = window.scale_factor(); + + (raw * scale).round() / scale } fn graph_canvas_content_width(&self) -> Pixels { @@ -1035,12 +1044,14 @@ impl GitGraph { ], ) }); - let mut row_height = Self::row_height(cx); + let mut row_height = Self::row_height(window, cx); - cx.observe_global_in::(window, move |this, _window, cx| { - let new_row_height = Self::row_height(cx); + cx.observe_global_in::(window, move |this, window, cx| { + let new_row_height = Self::row_height(window, cx); if new_row_height != row_height { - this.row_height = new_row_height; + // The `uniform_list` powering the table caches the item size + // from its last layout; invalidate it so it re-measures with + // the new row height on the next frame. this.table_interaction_state.update(cx, |state, _cx| { state.scroll_handle.0.borrow_mut().last_item_size = None; }); @@ -1064,7 +1075,6 @@ impl GitGraph { graph_data: graph, _commit_diff_task: None, context_menu: None, - row_height, table_interaction_state, column_widths, selected_entry_idx: None, @@ -1216,7 +1226,7 @@ impl GitGraph { fn render_table_rows( &mut self, range: Range, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Vec> { let repository = self.get_repository(cx); @@ -1229,7 +1239,7 @@ impl GitGraph { .map(|branch| SharedString::from(branch.name().to_string())) }); - let row_height = self.row_height; + let row_height = Self::row_height(window, cx); // We fetch data outside the visible viewport to avoid loading entries when // users scroll through the git graph @@ -2100,9 +2110,17 @@ impl GitGraph { .w_full() .justify_between() .child( - Label::new(format!("{} Changed Files", changed_files_count)) - .size(LabelSize::Small) - .color(Color::Muted), + Label::new(format!( + "{} Changed {}", + changed_files_count, + if changed_files_count == 1 { + "File" + } else { + "Files" + } + )) + .size(LabelSize::Small) + .color(Color::Muted), ) .child(DiffStat::new( "commit-diff-stat", @@ -2160,7 +2178,7 @@ impl GitGraph { } pub fn render_graph(&self, window: &Window, cx: &mut Context) -> impl IntoElement { - let row_height = self.row_height; + let row_height = Self::row_height(window, cx); let table_state = self.table_interaction_state.read(cx); let viewport_height = table_state .scroll_handle @@ -2168,7 +2186,7 @@ impl GitGraph { .borrow() .last_item_size .map(|size| size.item.height) - .unwrap_or(px(600.0)); + .unwrap_or(window.viewport_size().height); let loaded_commit_count = self.graph_data.commits.len(); let content_height = row_height * loaded_commit_count; @@ -2426,7 +2444,12 @@ impl GitGraph { .h_full() } - fn row_at_position(&self, position_y: Pixels, cx: &Context) -> Option { + fn row_at_position( + &self, + position_y: Pixels, + window: &Window, + cx: &Context, + ) -> Option { let canvas_bounds = self.graph_canvas_bounds.get()?; let table_state = self.table_interaction_state.read(cx); let scroll_offset_y = -table_state.scroll_offset().y; @@ -2435,7 +2458,8 @@ impl GitGraph { if local_y >= px(0.) && local_y < canvas_bounds.size.height { let absolute_y = local_y + scroll_offset_y; - let absolute_row = (absolute_y / self.row_height).floor() as usize; + let row_height = Self::row_height(window, cx); + let absolute_row = (absolute_y / row_height).floor() as usize; if absolute_row < self.graph_data.commits.len() { return Some(absolute_row); @@ -2448,10 +2472,10 @@ impl GitGraph { fn handle_graph_mouse_move( &mut self, event: &gpui::MouseMoveEvent, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { - if let Some(row) = self.row_at_position(event.position.y, cx) { + if let Some(row) = self.row_at_position(event.position.y, window, cx) { if self.hovered_entry_idx != Some(row) { self.hovered_entry_idx = Some(row); cx.notify(); @@ -2468,7 +2492,7 @@ impl GitGraph { window: &mut Window, cx: &mut Context, ) { - if let Some(row) = self.row_at_position(event.position().y, cx) { + if let Some(row) = self.row_at_position(event.position().y, window, cx) { self.select_entry(row, ScrollStrategy::Nearest, cx); if event.click_count() >= 2 { self.open_commit_view(row, window, cx); @@ -2494,7 +2518,7 @@ impl GitGraph { AllCommitCount::Loaded(count) => count, AllCommitCount::NotLoaded => self.graph_data.commits.len(), }; - let content_height = self.row_height * commit_count; + let content_height = Self::row_height(window, cx) * commit_count; let max_vertical_scroll = (viewport_height - content_height).min(px(0.)); let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.)); @@ -2652,7 +2676,7 @@ impl Render for GitGraph { cx, )) .child({ - let row_height = self.row_height; + let row_height = Self::row_height(window, cx); let selected_entry_idx = self.selected_entry_idx; let hovered_entry_idx = self.hovered_entry_idx; let weak_self = cx.weak_entity(); @@ -2855,7 +2879,7 @@ impl Render for GitGraph { deferred( anchored() .position(*position) - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) @@ -3081,12 +3105,12 @@ mod tests { use fs::FakeFs; use git::Oid; use git::repository::InitialGraphCommitData; - use gpui::TestAppContext; + use gpui::{TestAppContext, UpdateGlobal}; use project::Project; use project::git_store::{GitStoreEvent, RepositoryEvent}; use rand::prelude::*; use serde_json::json; - use settings::SettingsStore; + use settings::{SettingsStore, ThemeSettingsContent}; use smallvec::{SmallVec, smallvec}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -4155,24 +4179,27 @@ mod tests { }); cx.run_until_parked(); - git_graph.update(cx, |graph, cx| { + git_graph.update_in(cx, |graph, window, cx| { assert!( graph.graph_data.commits.len() >= 10, "graph should load dummy commits" ); - graph.row_height = px(20.0); + let row_height = GitGraph::row_height(window, cx); let origin_y = px(100.0); graph.graph_canvas_bounds.set(Some(Bounds { origin: point(px(0.0), origin_y), - size: gpui::size(px(100.0), px(1000.0)), + size: gpui::size(px(100.0), row_height * 50.0), })); + // Scroll down by half a row so the row under a position near the + // top of the canvas is row 1 rather than row 0. + let scroll_offset = row_height * 0.75; graph.table_interaction_state.update(cx, |state, _| { - state.set_scroll_offset(point(px(0.0), px(-15.0))) + state.set_scroll_offset(point(px(0.0), -scroll_offset)) }); - let pos_y = origin_y + px(10.0); - let absolute_calc_row = graph.row_at_position(pos_y, cx); + let pos_y = origin_y + row_height * 0.5; + let absolute_calc_row = graph.row_at_position(pos_y, window, cx); assert_eq!( absolute_calc_row, @@ -4181,4 +4208,93 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_row_height_matches_uniform_list_item_height(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + *settings.theme = ThemeSettingsContent { + ui_font_size: Some(12.7.into()), + ..Default::default() + } + }); + }) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + serde_json::json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + + let mut rng = StdRng::seed_from_u64(99); + let commits = generate_random_commit_dag(&mut rng, 20, false); + fs.set_graph_commits(Path::new("/project/.git"), commits); + + let project = Project::test(fs.clone(), [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_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + window, + cx, + ) + }); + cx.run_until_parked(); + + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| git_graph.clone().into_any_element(), + ); + cx.run_until_parked(); + + git_graph.update_in(cx, |graph, window, cx| { + let commit_count = graph.graph_data.commits.len(); + assert!( + commit_count > 0, + "need at least one commit to measure item height" + ); + + let table_state = graph.table_interaction_state.read(cx); + let item_size = table_state.scroll_handle.0.borrow().last_item_size.expect( + "uniform_list should have populated last_item_size after draw(); \ + the table has not been laid out", + ); + + let measured_item_height = item_size.contents.height / commit_count as f32; + let computed_row_height = GitGraph::row_height(window, cx); + + assert_eq!( + computed_row_height, measured_item_height, + "GitGraph::row_height ({}) must exactly match the height that \ + uniform_list measured for each table row ({}). \ + A mismatch means the canvas and table rows will drift when scrolling.", + computed_row_height, measured_item_height, + ); + }); + } } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 6927ae16a5c4aa..5a9350f8aec7ae 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -27,8 +27,10 @@ component.workspace = true db.workspace = true editor.workspace = true file_icons.workspace = true +fs.workspace = true futures.workspace = true fuzzy.workspace = true +fuzzy_nucleo.workspace = true git.workspace = true gpui.workspace = true itertools.workspace = true @@ -45,6 +47,7 @@ picker.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true +rand.workspace = true remote_connection.workspace = true remote.workspace = true schemars.workspace = true diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index a78e933008f97c..a2cf0cb65865b0 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1,6 +1,6 @@ use anyhow::Context as _; use editor::Editor; -use fuzzy::StringMatchCandidate; +use fuzzy_nucleo::StringMatchCandidate; use collections::HashSet; use git::repository::Branch; @@ -97,10 +97,11 @@ pub fn create_embedded( workspace: WeakEntity, repository: Option>, width: Rems, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> BranchList { - BranchList::new_embedded(workspace, repository, width, window, cx) + BranchList::new_embedded(workspace, repository, width, show_footer, window, cx) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -164,6 +165,7 @@ impl BranchList { picker.update(cx, |picker, _| { picker.delegate.focus_handle = picker_focus_handle.clone(); + picker.delegate.show_footer = !embedded; }); let mut subscriptions = Vec::new(); @@ -223,6 +225,7 @@ impl BranchList { workspace: WeakEntity, repository: Option>, width: Rems, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> Self { @@ -235,6 +238,9 @@ impl BranchList { window, cx, ); + this.picker.update(cx, |picker, _| { + picker.delegate.show_footer = show_footer; + }); this._subscriptions .push(cx.subscribe(&this.picker, |_, _, _, cx| { cx.emit(DismissEvent); @@ -386,6 +392,7 @@ pub struct BranchListDelegate { state: PickerState, focus_handle: FocusHandle, restore_selected_branch: Option, + show_footer: bool, } #[derive(Debug)] @@ -452,6 +459,7 @@ impl BranchListDelegate { state: PickerState::List, focus_handle: cx.focus_handle(), restore_selected_branch: None, + show_footer: false, } } @@ -729,11 +737,11 @@ impl PickerDelegate for BranchListDelegate { .enumerate() .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name())) .collect::>(); - let mut matches: Vec = fuzzy::match_strings( + let mut matches: Vec = fuzzy_nucleo::match_strings_async( &candidates, &query, - true, - true, + fuzzy_nucleo::Case::Smart, + fuzzy_nucleo::LengthPenalty::On, 10000, &Default::default(), cx.background_executor().clone(), @@ -1172,7 +1180,7 @@ impl PickerDelegate for BranchListDelegate { } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { - if self.editor_position() == PickerEditorPosition::End { + if !self.show_footer || self.editor_position() == PickerEditorPosition::End { return None; } let focus_handle = self.focus_handle.clone(); diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 2088ad77ec5d7e..ad6d960a307ffd 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -324,7 +324,7 @@ impl CommitModal { } }) .with_handle(self.commit_menu_handle.clone()) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) } pub fn render_footer(&self, _: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -392,7 +392,7 @@ impl CommitModal { branch_picker_button, Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch), ) - .anchor(Corner::BottomLeft) + .anchor(Anchor::BottomLeft) .offset(gpui::Point { x: px(0.0), y: px(-2.0), diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index c8b249a7dff602..23d9c728c058f1 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -37,7 +37,7 @@ use git::{ StashApply, StashPop, TrashUntrackedFiles, UnstageAll, }; use gpui::{ - Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Empty, Entity, + Action, Anchor, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task, TextStyle, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, size, uniform_list, @@ -3109,6 +3109,14 @@ impl GitPanel { let remote = match remote.await { Ok(Some(remote)) => remote, Ok(None) => { + this.update(cx, |this, cx| { + this.show_error_toast( + "push", + anyhow::anyhow!("No remote available to push to. Add a remote to be able to publish changes."), + cx, + ) + }) + .ok(); return Ok(()); } Err(e) => { @@ -4024,7 +4032,7 @@ impl GitPanel { cx, )) }) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) } pub(crate) fn render_generate_commit_message_button( @@ -4196,7 +4204,7 @@ impl GitPanel { })) } }) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) } pub fn configure_commit_button(&self, cx: &mut Context) -> (bool, &'static str) { @@ -4961,6 +4969,7 @@ impl GitPanel { ) -> AnyElement { let id: ElementId = ElementId::Name(format!("header_{}", ix).into()); let checkbox_id: ElementId = ElementId::Name(format!("header_{}_checkbox", ix).into()); + let group_name: SharedString = format!("header_{}", ix).into(); let toggle_state = self.header_state(header.header); let section = header.header; let weak = cx.weak_entity(); @@ -4968,6 +4977,7 @@ impl GitPanel { h_flex() .id(id) + .group(group_name.clone()) .h(self.list_item_height()) .w_full() .items_center() @@ -5011,7 +5021,7 @@ impl GitPanel { }), ) .when(!show_checkbox_persistently, |this| { - this.visible_on_hover("entries") + this.visible_on_hover(group_name) }), ) .into_any_element() @@ -5818,7 +5828,7 @@ impl Render for GitPanel { deferred( anchored() .position(*position) - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) @@ -6171,7 +6181,7 @@ impl RenderOnce for PanelRepoFooter { } }, ) - .anchor(Corner::BottomLeft) + .anchor(Anchor::BottomLeft) .offset(gpui::Point { x: px(0.0), y: px(-2.0), @@ -6196,7 +6206,7 @@ impl RenderOnce for PanelRepoFooter { branch_selector_button, Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch), ) - .anchor(Corner::BottomLeft) + .anchor(Anchor::BottomLeft) .offset(gpui::Point { x: px(0.0), y: px(-2.0), diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index 1a1ea84aaa16ba..a1f55ce9fad106 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/crates/git_ui/src/git_picker.rs @@ -14,18 +14,11 @@ use workspace::{ModalView, Workspace, pane}; use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes}; use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList}; -use crate::worktree_picker::{ - self, DeleteWorktree, WorktreeFromDefault, WorktreeFromDefaultOnWindow, WorktreeList, -}; -actions!( - git_picker, - [ActivateBranchesTab, ActivateWorktreesTab, ActivateStashTab,] -); +actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GitPickerTab { - Worktrees, Branches, Stash, } @@ -34,7 +27,6 @@ impl Display for GitPickerTab { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let label = match self { GitPickerTab::Branches => "Branches", - GitPickerTab::Worktrees => "Worktrees", GitPickerTab::Stash => "Stash", }; write!(f, "{}", label) @@ -47,7 +39,6 @@ pub struct GitPicker { repository: Option>, width: Rems, branch_list: Option>, - worktree_list: Option>, stash_list: Option>, _subscriptions: Vec, popover_style: bool, @@ -80,7 +71,6 @@ impl GitPicker { repository, width, branch_list: None, - worktree_list: None, stash_list: None, _subscriptions: Vec::new(), popover_style, @@ -95,9 +85,6 @@ impl GitPicker { GitPickerTab::Branches => { self.ensure_branch_list(window, cx); } - GitPickerTab::Worktrees => { - self.ensure_worktree_list(window, cx); - } GitPickerTab::Stash => { self.ensure_stash_list(window, cx); } @@ -110,11 +97,13 @@ impl GitPicker { cx: &mut Context, ) -> Entity { if self.branch_list.is_none() { + let show_footer = !self.popover_style; let branch_list = cx.new(|cx| { branch_picker::create_embedded( self.workspace.clone(), self.repository.clone(), self.width, + show_footer, window, cx, ) @@ -132,45 +121,19 @@ impl GitPicker { self.branch_list.clone().unwrap() } - fn ensure_worktree_list( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - if self.worktree_list.is_none() { - let worktree_list = cx.new(|cx| { - worktree_picker::create_embedded( - self.repository.clone(), - self.workspace.clone(), - self.width, - window, - cx, - ) - }); - - let subscription = cx.subscribe(&worktree_list, |this, _, _: &DismissEvent, cx| { - if this.tab == GitPickerTab::Worktrees { - cx.emit(DismissEvent); - } - }); - - self._subscriptions.push(subscription); - self.worktree_list = Some(worktree_list); - } - self.worktree_list.clone().unwrap() - } - fn ensure_stash_list( &mut self, window: &mut Window, cx: &mut Context, ) -> Entity { if self.stash_list.is_none() { + let show_footer = !self.popover_style; let stash_list = cx.new(|cx| { stash_picker::create_embedded( self.repository.clone(), self.workspace.clone(), self.width, + show_footer, window, cx, ) @@ -190,9 +153,8 @@ impl GitPicker { fn activate_next_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Worktrees => GitPickerTab::Branches, GitPickerTab::Branches => GitPickerTab::Stash, - GitPickerTab::Stash => GitPickerTab::Worktrees, + GitPickerTab::Stash => GitPickerTab::Branches, }; self.ensure_active_picker(window, cx); self.focus_active_picker(window, cx); @@ -201,8 +163,7 @@ impl GitPicker { fn activate_previous_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Worktrees => GitPickerTab::Stash, - GitPickerTab::Branches => GitPickerTab::Worktrees, + GitPickerTab::Branches => GitPickerTab::Stash, GitPickerTab::Stash => GitPickerTab::Branches, }; self.ensure_active_picker(window, cx); @@ -217,11 +178,6 @@ impl GitPicker { branch_list.focus_handle(cx).focus(window, cx); } } - GitPickerTab::Worktrees => { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.focus_handle(cx).focus(window, cx); - } - } GitPickerTab::Stash => { if let Some(stash_list) = &self.stash_list { stash_list.focus_handle(cx).focus(window, cx); @@ -233,30 +189,12 @@ impl GitPicker { fn render_tab_bar(&self, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); let branches_focus_handle = focus_handle.clone(); - let worktrees_focus_handle = focus_handle.clone(); let stash_focus_handle = focus_handle; h_flex().p_2().pb_0p5().w_full().child( ToggleButtonGroup::single_row( "git-picker-tabs", [ - ToggleButtonSimple::new( - GitPickerTab::Worktrees.to_string(), - cx.listener(|this, _, window, cx| { - this.tab = GitPickerTab::Worktrees; - this.ensure_active_picker(window, cx); - this.focus_active_picker(window, cx); - cx.notify(); - }), - ) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - "Toggle Worktree Picker", - &ActivateWorktreesTab, - &worktrees_focus_handle, - cx, - ) - }), ToggleButtonSimple::new( GitPickerTab::Branches.to_string(), cx.listener(|this, _, window, cx| { @@ -297,9 +235,8 @@ impl GitPicker { .style(ToggleButtonGroupStyle::Outlined) .auto_width() .selected_index(match self.tab { - GitPickerTab::Worktrees => 0, - GitPickerTab::Branches => 1, - GitPickerTab::Stash => 2, + GitPickerTab::Branches => 0, + GitPickerTab::Stash => 1, }), ) } @@ -314,10 +251,6 @@ impl GitPicker { let branch_list = self.ensure_branch_list(window, cx); branch_list.into_any_element() } - GitPickerTab::Worktrees => { - let worktree_list = self.ensure_worktree_list(window, cx); - worktree_list.into_any_element() - } GitPickerTab::Stash => { let stash_list = self.ensure_stash_list(window, cx); stash_list.into_any_element() @@ -339,13 +272,6 @@ impl GitPicker { }); } } - GitPickerTab::Worktrees => { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.update(cx, |list, cx| { - list.handle_modifiers_changed(ev, window, cx); - }); - } - } GitPickerTab::Stash => { if let Some(stash_list) = &self.stash_list { stash_list.update(cx, |list, cx| { @@ -382,45 +308,6 @@ impl GitPicker { } } - fn handle_worktree_from_default( - &mut self, - _: &WorktreeFromDefault, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.update(cx, |list, cx| { - list.handle_new_worktree(false, window, cx); - }); - } - } - - fn handle_worktree_from_default_on_window( - &mut self, - _: &WorktreeFromDefaultOnWindow, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.update(cx, |list, cx| { - list.handle_new_worktree(true, window, cx); - }); - } - } - - fn handle_worktree_delete( - &mut self, - _: &DeleteWorktree, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.update(cx, |list, cx| { - list.handle_delete(&DeleteWorktree, window, cx); - }); - } - } - fn handle_drop_stash( &mut self, _: &DropStashItem, @@ -459,11 +346,6 @@ impl Focusable for GitPicker { return branch_list.focus_handle(cx); } } - GitPickerTab::Worktrees => { - if let Some(worktree_list) = &self.worktree_list { - return worktree_list.focus_handle(cx); - } - } GitPickerTab::Stash => { if let Some(stash_list) = &self.stash_list { return stash_list.focus_handle(cx); @@ -492,7 +374,6 @@ impl Render for GitPicker { key_context.add("GitPicker"); match self.tab { GitPickerTab::Branches => key_context.add("GitBranchSelector"), - GitPickerTab::Worktrees => key_context.add("GitWorktreeSelector"), GitPickerTab::Stash => key_context.add("StashList"), } key_context @@ -517,12 +398,6 @@ impl Render for GitPicker { this.focus_active_picker(window, cx); cx.notify(); })) - .on_action(cx.listener(|this, _: &ActivateWorktreesTab, window, cx| { - this.tab = GitPickerTab::Worktrees; - this.ensure_active_picker(window, cx); - this.focus_active_picker(window, cx); - cx.notify(); - })) .on_action(cx.listener(|this, _: &ActivateStashTab, window, cx| { this.tab = GitPickerTab::Stash; this.ensure_active_picker(window, cx); @@ -534,11 +409,6 @@ impl Render for GitPicker { el.on_action(cx.listener(Self::handle_delete_branch)) .on_action(cx.listener(Self::handle_filter_remotes)) }) - .when(self.tab == GitPickerTab::Worktrees, |el| { - el.on_action(cx.listener(Self::handle_worktree_from_default)) - .on_action(cx.listener(Self::handle_worktree_from_default_on_window)) - .on_action(cx.listener(Self::handle_worktree_delete)) - }) .when(self.tab == GitPickerTab::Stash, |el| { el.on_action(cx.listener(Self::handle_drop_stash)) .on_action(cx.listener(Self::handle_show_stash)) @@ -557,15 +427,6 @@ pub fn open_branches( open_with_tab(workspace, GitPickerTab::Branches, window, cx); } -pub fn open_worktrees( - workspace: &mut Workspace, - _: &zed_actions::git::Worktree, - window: &mut Window, - cx: &mut Context, -) { - open_with_tab(workspace, GitPickerTab::Worktrees, window, cx); -} - pub fn open_stash( workspace: &mut Workspace, _: &zed_actions::git::ViewStash, @@ -617,9 +478,6 @@ pub fn register(workspace: &mut Workspace) { open_with_tab(workspace, GitPickerTab::Branches, window, cx); }, ); - workspace.register_action(|workspace, _: &zed_actions::git::Worktree, window, cx| { - open_with_tab(workspace, GitPickerTab::Worktrees, window, cx); - }); workspace.register_action(|workspace, _: &zed_actions::git::ViewStash, window, cx| { open_with_tab(workspace, GitPickerTab::Stash, window, cx); }); diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 350999164dca6c..0b44b1c51757b2 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -25,7 +25,7 @@ use project::git_store::Repository; use project_diff::ProjectDiff; use time::OffsetDateTime; use ui::prelude::*; -use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr}; +use workspace::{ModalView, OpenMode, Workspace, notifications::DetachAndPromptErr}; use zed_actions; use crate::{commit_view::CommitView, git_panel::GitPanel, text_diff_view::TextDiffView}; @@ -48,7 +48,9 @@ pub(crate) mod remote_output; pub mod repository_selector; pub mod stash_picker; pub mod text_diff_view; +pub mod worktree_names; pub mod worktree_picker; +pub mod worktree_service; pub use conflict_view::MergeConflictIndicator; @@ -68,6 +70,64 @@ pub fn init(cx: &mut App) { repository_selector::register(workspace); git_picker::register(workspace); + workspace.register_action( + |workspace, action: &zed_actions::CreateWorktree, window, cx| { + worktree_service::handle_create_worktree(workspace, action, window, None, cx); + }, + ); + workspace.register_action( + |workspace, action: &zed_actions::SwitchWorktree, window, cx| { + worktree_service::handle_switch_worktree(workspace, action, window, None, cx); + }, + ); + + workspace.register_action(|workspace, _: &zed_actions::git::Worktree, window, cx| { + let focused_dock = workspace.focused_dock_position(window, cx); + let project = workspace.project().clone(); + let workspace_handle = workspace.weak_handle(); + workspace.toggle_modal(window, cx, |window, cx| { + worktree_picker::WorktreePicker::new_modal( + project, + workspace_handle, + focused_dock, + window, + cx, + ) + }); + }); + + workspace.register_action( + |workspace, action: &zed_actions::OpenWorktreeInNewWindow, window, cx| { + let path = action.path.clone(); + let is_remote = !workspace.project().read(cx).is_local(); + + if is_remote { + let connection_options = + workspace.project().read(cx).remote_connection_options(cx); + let app_state = workspace.app_state().clone(); + let workspace_handle = workspace.weak_handle(); + cx.spawn_in(window, async move |_, cx| { + if let Some(connection_options) = connection_options { + crate::worktree_picker::open_remote_worktree( + connection_options, + vec![path], + app_state, + workspace_handle, + cx, + ) + .await?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else { + workspace + .open_workspace_for_paths(OpenMode::NewWindow, vec![path], window, cx) + .detach_and_log_err(cx); + } + }, + ); + let project = workspace.project().read(cx); if project.is_read_only(cx) { return; @@ -675,7 +735,7 @@ fn render_remote_button( } mod remote_button { - use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle}; + use gpui::{Action, Anchor, AnyView, ClickEvent, FocusHandle}; use ui::{ App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName, IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement, @@ -863,7 +923,7 @@ mod remote_button { .action("Force Push", git::ForcePush.boxed_clone()) })) }) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) } #[allow(clippy::too_many_arguments)] diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index c4aad77396f973..9f5b65d75601ae 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -6,7 +6,7 @@ use crate::{ use agent_settings::AgentSettings; use anyhow::{Context as _, Result, anyhow}; use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus}; -use collections::{HashMap, HashSet}; +use collections::HashMap; use editor::{ Addon, Editor, EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent}, @@ -378,7 +378,6 @@ impl ProjectDiff { editor.register_addon(BranchDiffAddon { branch_diff: branch_diff.clone(), }); - editor.start_temporary_diff_override(); } } }); @@ -769,7 +768,7 @@ impl ProjectDiff { needs_fold } - #[instrument(skip_all)] + #[instrument(skip(this, cx))] pub async fn refresh( this: WeakEntity, reason: RefreshReason, @@ -781,13 +780,13 @@ impl ProjectDiff { let load_buffers = branch_diff.load_buffers(cx); (branch_diff.repo().cloned(), load_buffers) }); - let mut previous_paths = this + let mut previous_buffers = this .multibuffer .read(cx) .snapshot(cx) .buffers_with_paths() - .map(|(_, path_key)| path_key.clone()) - .collect::>(); + .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) + .collect::>(); if let Some(repo) = repo { let repo = repo.read(cx); @@ -797,14 +796,14 @@ impl ProjectDiff { let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx); let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone()); - previous_paths.remove(&path_key); + previous_buffers.remove(&path_key); path_keys.push(path_key) } } this.editor.update(cx, |editor, cx| { - for path in previous_paths { - if let Some(buffer) = this.multibuffer.read(cx).buffer_for_path(&path, cx) { + for (path, buffer_id) in previous_buffers { + if let Some(buffer) = this.multibuffer.read(cx).buffer(buffer_id) { let skip = match reason { RefreshReason::DiffChanged | RefreshReason::EditorSaved => { buffer.read(cx).is_dirty() @@ -817,6 +816,8 @@ impl ProjectDiff { } this.buffer_diff_subscriptions.remove(&path.path); + let _span = ztracing::info_span!("remove_excerpts_for_path"); + _span.enter(); editor.remove_excerpts_for_path(path, cx); } }); diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index 963fa9d22bc78a..6e6833f3cb4833 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -46,10 +46,11 @@ pub fn create_embedded( repository: Option>, workspace: WeakEntity, width: Rems, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> StashList { - StashList::new_embedded(repository, workspace, width, window, cx) + StashList::new_embedded(repository, workspace, width, show_footer, window, cx) } pub struct StashList { @@ -133,6 +134,7 @@ impl StashList { let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { picker.delegate.focus_handle = picker_focus_handle.clone(); + picker.delegate.show_footer = !embedded; }); Self { @@ -147,10 +149,14 @@ impl StashList { repository: Option>, workspace: WeakEntity, width: Rems, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> Self { let mut this = Self::new_inner(repository, workspace, width, true, window, cx); + this.picker.update(cx, |picker, _| { + picker.delegate.show_footer = show_footer; + }); this._subscriptions .push(cx.subscribe(&this.picker, |_, _, _, cx| { cx.emit(DismissEvent); @@ -236,6 +242,7 @@ pub struct StashListDelegate { modifiers: Modifiers, focus_handle: FocusHandle, timezone: UtcOffset, + show_footer: bool, } impl StashListDelegate { @@ -257,6 +264,7 @@ impl StashListDelegate { modifiers: Default::default(), focus_handle: cx.focus_handle(), timezone, + show_footer: false, } } @@ -614,7 +622,7 @@ impl PickerDelegate for StashListDelegate { } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { - if self.matches.is_empty() { + if !self.show_footer || self.matches.is_empty() { return None; } diff --git a/crates/agent_ui/src/worktree_names.rs b/crates/git_ui/src/worktree_names.rs similarity index 100% rename from crates/agent_ui/src/worktree_names.rs rename to crates/git_ui/src/worktree_names.rs diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index f9069d2920eedc..49a42438f45d7e 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -1,95 +1,88 @@ +use std::path::PathBuf; +use std::sync::Arc; + use anyhow::Context as _; use collections::HashSet; use fuzzy::StringMatchCandidate; - use git::repository::Worktree as GitWorktree; use gpui::{ - Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, - Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, + Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, + Window, actions, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; -use project::project_settings::ProjectSettings; -use project::{ - git_store::{Repository, RepositoryEvent}, - trusted_worktrees::{PathTrust, TrustedWorktrees}, +use project::Project; +use project::git_store::RepositoryEvent; +use ui::{ + Button, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem, ListItemSpacing, Tooltip, + prelude::*, }; -use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier}; -use remote_connection::{RemoteConnectionModal, connect}; -use settings::Settings; -use std::{path::PathBuf, sync::Arc}; -use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; -use util::{ResultExt, debug_panic, paths::PathExt}; +use util::ResultExt as _; +use util::paths::PathExt; use workspace::{ - ModalView, MultiWorkspace, OpenMode, Workspace, notifications::DetachAndPromptErr, + ModalView, MultiWorkspace, Workspace, dock::DockPosition, notifications::DetachAndPromptErr, }; use crate::git_panel::show_error_toast; +use zed_actions::{ + CreateWorktree, NewWorktreeBranchTarget, OpenWorktreeInNewWindow, SwitchWorktree, +}; -actions!( - git, - [ - WorktreeFromDefault, - WorktreeFromDefaultOnWindow, - DeleteWorktree - ] -); - -pub fn open( - workspace: &mut Workspace, - _: &zed_actions::git::Worktree, - window: &mut Window, - cx: &mut Context, -) { - let repository = workspace.project().read(cx).active_repository(cx); - let workspace_handle = workspace.weak_handle(); - workspace.toggle_modal(window, cx, |window, cx| { - WorktreeList::new(repository, workspace_handle, rems(34.), window, cx) - }) -} - -pub fn create_embedded( - repository: Option>, - workspace: WeakEntity, - width: Rems, - window: &mut Window, - cx: &mut Context, -) -> WorktreeList { - WorktreeList::new_embedded(repository, workspace, width, window, cx) -} +actions!(worktree_picker, [DeleteWorktree]); -pub struct WorktreeList { - width: Rems, - pub picker: Entity>, - picker_focus_handle: FocusHandle, +pub struct WorktreePicker { + picker: Entity>, + focus_handle: FocusHandle, _subscriptions: Vec, - embedded: bool, } -impl WorktreeList { - fn new( - repository: Option>, +impl WorktreePicker { + pub fn new( + project: Entity, workspace: WeakEntity, - width: Rems, window: &mut Window, cx: &mut Context, ) -> Self { - let mut this = Self::new_inner(repository, workspace, width, false, window, cx); - this._subscriptions - .push(cx.subscribe(&this.picker, |_, _, _, cx| { - cx.emit(DismissEvent); - })); - this + let focused_dock = workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).focused_dock_position(window, cx)); + Self::new_inner(project, workspace, focused_dock, false, window, cx) + } + + pub fn new_modal( + project: Entity, + workspace: WeakEntity, + focused_dock: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new_inner(project, workspace, focused_dock, true, window, cx) } fn new_inner( - repository: Option>, + project: Entity, workspace: WeakEntity, - width: Rems, - embedded: bool, + focused_dock: Option, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> Self { + let project_ref = project.read(cx); + let project_worktree_paths: HashSet = project_ref + .visible_worktrees(cx) + .map(|wt| wt.read(cx).abs_path().to_path_buf()) + .collect(); + + let has_multiple_repositories = project_ref.repositories(cx).len() > 1; + let repository = project_ref.active_repository(cx); + + let current_branch_name = repository.as_ref().and_then(|repo| { + repo.read(cx) + .branch + .as_ref() + .map(|branch| branch.name().to_string()) + }); + let all_worktrees_request = repository .clone() .map(|repository| repository.update(cx, |repository, _| repository.worktrees())); @@ -98,65 +91,94 @@ impl WorktreeList { repository.update(cx, |repository, _| repository.default_branch(false)) }); - cx.spawn_in(window, async move |this, cx| { - let all_worktrees: Vec<_> = all_worktrees_request - .context("No active repository")? - .await?? - .into_iter() - .filter(|worktree| !worktree.is_bare) // hide bare repositories - .collect(); - - let default_branch = default_branch_request - .context("No active repository")? - .await - .map(Result::ok) - .ok() - .flatten() - .flatten(); - - this.update_in(cx, |this, window, cx| { - this.picker.update(cx, |picker, cx| { - picker.delegate.all_worktrees = Some(all_worktrees); - picker.delegate.default_branch = default_branch; - picker.delegate.refresh_forbidden_deletion_path(cx); - picker.refresh(window, cx); - }) - })?; + let initial_matches = vec![WorktreeEntry::CreateFromCurrentBranch]; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + let delegate = WorktreePickerDelegate { + matches: initial_matches, + all_worktrees: Vec::new(), + project_worktree_paths, + selected_index: 0, + project, + workspace, + focused_dock, + current_branch_name, + default_branch_name: None, + has_multiple_repositories, + focus_handle: cx.focus_handle(), + show_footer, + }; - let delegate = WorktreeListDelegate::new(workspace, repository.clone(), window, cx); let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) + Picker::list(delegate, window, cx) + .list_measure_all() .show_scrollbar(true) - .modal(!embedded) + .modal(false) + .max_height(Some(rems(20.).into())) }); + let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { - picker.delegate.focus_handle = picker_focus_handle.clone(); + picker.delegate.focus_handle = picker_focus_handle; }); let mut subscriptions = Vec::new(); + + { + let picker_handle = picker.downgrade(); + cx.spawn_in(window, async move |_this, cx| { + let all_worktrees: Vec<_> = match all_worktrees_request { + Some(req) => match req.await { + Ok(Ok(worktrees)) => { + worktrees.into_iter().filter(|wt| !wt.is_bare).collect() + } + Ok(Err(err)) => { + log::warn!("WorktreePicker: git worktree list failed: {err}"); + return anyhow::Ok(()); + } + Err(_) => { + log::warn!("WorktreePicker: worktree request was cancelled"); + return anyhow::Ok(()); + } + }, + None => Vec::new(), + }; + + let default_branch = match default_branch_request { + Some(req) => req.await.ok().and_then(Result::ok).flatten(), + None => None, + }; + + picker_handle.update_in(cx, |picker, window, cx| { + picker.delegate.all_worktrees = all_worktrees; + picker.delegate.default_branch_name = + default_branch.map(|branch| branch.to_string()); + picker.refresh(window, cx); + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + if let Some(repo) = &repository { - let picker_entity = picker.clone(); - subscriptions.push(cx.subscribe( + let picker_entity = picker.downgrade(); + subscriptions.push(cx.subscribe_in( repo, - move |_this, repo, event: &RepositoryEvent, cx| { + window, + move |_this, repo, event: &RepositoryEvent, window, cx| { if matches!(event, RepositoryEvent::GitWorktreeListChanged) { let worktrees_request = repo.update(cx, |repo, _| repo.worktrees()); let picker = picker_entity.clone(); - cx.spawn(async move |_, cx| { + cx.spawn_in(window, async move |_, cx| { let all_worktrees: Vec<_> = worktrees_request .await?? .into_iter() - .filter(|worktree| !worktree.is_bare) + .filter(|wt| !wt.is_bare) .collect(); - picker.update(cx, |picker, cx| { - picker.delegate.all_worktrees = Some(all_worktrees); - picker.delegate.refresh_forbidden_deletion_path(cx); - }); + picker.update_in(cx, |picker, window, cx| { + picker.delegate.all_worktrees = all_worktrees; + picker.refresh(window, cx); + })?; anyhow::Ok(()) }) .detach_and_log_err(cx); @@ -165,386 +187,166 @@ impl WorktreeList { )); } + subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + })); + Self { + focus_handle: picker.focus_handle(cx), picker, - picker_focus_handle, - width, _subscriptions: subscriptions, - embedded, } } - - fn new_embedded( - repository: Option>, - workspace: WeakEntity, - width: Rems, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let mut this = Self::new_inner(repository, workspace, width, true, window, cx); - this._subscriptions - .push(cx.subscribe(&this.picker, |_, _, _, cx| { - cx.emit(DismissEvent); - })); - this - } - - pub fn handle_modifiers_changed( - &mut self, - ev: &ModifiersChangedEvent, - _: &mut Window, - cx: &mut Context, - ) { - self.picker - .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) - } - - pub fn handle_new_worktree( - &mut self, - replace_current_window: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.picker.update(cx, |picker, cx| { - let ix = picker.delegate.selected_index(); - let Some(entry) = picker.delegate.matches.get(ix) else { - return; - }; - let Some(default_branch) = picker.delegate.default_branch.clone() else { - return; - }; - if !entry.is_new { - return; - } - picker.delegate.create_worktree( - entry.worktree.display_name(), - replace_current_window, - Some(default_branch.into()), - window, - cx, - ); - }) - } - - pub fn handle_delete( - &mut self, - _: &DeleteWorktree, - window: &mut Window, - cx: &mut Context, - ) { - self.picker.update(cx, |picker, cx| { - picker - .delegate - .delete_at(picker.delegate.selected_index, window, cx) - }) - } } -impl ModalView for WorktreeList {} -impl EventEmitter for WorktreeList {} -impl Focusable for WorktreeList { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.picker_focus_handle.clone() +impl Focusable for WorktreePicker { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() } } -impl Render for WorktreeList { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { +impl ModalView for WorktreePicker {} +impl EventEmitter for WorktreePicker {} + +impl Render for WorktreePicker { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .key_context("GitWorktreeSelector") - .w(self.width) - .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) - .on_action(cx.listener(|this, _: &WorktreeFromDefault, w, cx| { - this.handle_new_worktree(false, w, cx) - })) - .on_action(cx.listener(|this, _: &WorktreeFromDefaultOnWindow, w, cx| { - this.handle_new_worktree(true, w, cx) + .key_context("WorktreePicker") + .w(rems(34.)) + .elevation_3(cx) + .child(self.picker.clone()) + .on_mouse_down_out(cx.listener(|_, _, _, cx| { + cx.emit(DismissEvent); })) .on_action(cx.listener(|this, _: &DeleteWorktree, window, cx| { - this.handle_delete(&DeleteWorktree, window, cx) + this.picker.update(cx, |picker, cx| { + let ix = picker.delegate.selected_index; + picker.delegate.delete_worktree(ix, window, cx); + }); })) - .child(self.picker.clone()) - .when(!self.embedded, |el| { - el.on_mouse_down_out({ - cx.listener(move |this, _, window, cx| { - this.picker.update(cx, |this, cx| { - this.cancel(&Default::default(), window, cx); - }) - }) - }) - }) } } -#[derive(Debug, Clone)] -struct WorktreeEntry { - worktree: GitWorktree, - positions: Vec, - is_new: bool, +#[derive(Clone)] +enum WorktreeEntry { + CreateFromCurrentBranch, + CreateFromDefaultBranch { + default_branch_name: String, + }, + Separator, + Worktree { + worktree: GitWorktree, + positions: Vec, + }, + CreateNamed { + name: String, + from_branch: Option, + disabled_reason: Option, + }, } -impl WorktreeEntry { - fn can_delete(&self, forbidden_deletion_path: Option<&PathBuf>) -> bool { - !self.is_new - && !self.worktree.is_main - && forbidden_deletion_path != Some(&self.worktree.path) - } -} - -pub struct WorktreeListDelegate { +struct WorktreePickerDelegate { matches: Vec, - all_worktrees: Option>, - workspace: WeakEntity, - repo: Option>, + all_worktrees: Vec, + project_worktree_paths: HashSet, selected_index: usize, - last_query: String, - modifiers: Modifiers, + project: Entity, + workspace: WeakEntity, + focused_dock: Option, + current_branch_name: Option, + default_branch_name: Option, + has_multiple_repositories: bool, focus_handle: FocusHandle, - default_branch: Option, - forbidden_deletion_path: Option, - current_worktree_path: Option, + show_footer: bool, } -impl WorktreeListDelegate { - fn new( - workspace: WeakEntity, - repo: Option>, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - let current_worktree_path = repo - .as_ref() - .map(|r| r.read(cx).work_directory_abs_path.to_path_buf()); +impl WorktreePickerDelegate { + fn build_fixed_entries(&self) -> Vec { + let mut entries = Vec::new(); - Self { - matches: vec![], - all_worktrees: None, - workspace, - selected_index: 0, - repo, - last_query: Default::default(), - modifiers: Default::default(), - focus_handle: cx.focus_handle(), - default_branch: None, - forbidden_deletion_path: None, - current_worktree_path, - } - } + entries.push(WorktreeEntry::CreateFromCurrentBranch); - fn create_worktree( - &self, - worktree_branch: &str, - replace_current_window: bool, - commit: Option, - window: &mut Window, - cx: &mut Context>, - ) { - let Some(repo) = self.repo.clone() else { - return; - }; - - let branch = worktree_branch.to_string(); - let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |_, cx| { - let (receiver, new_worktree_path) = repo.update(cx, |repo, cx| { - let worktree_directory_setting = ProjectSettings::get_global(cx) - .git - .worktree_directory - .clone(); - let new_worktree_path = - repo.path_for_new_linked_worktree(&branch, &worktree_directory_setting)?; - let receiver = repo.create_worktree( - git::repository::CreateWorktreeTarget::NewBranch { - branch_name: branch.clone(), - base_sha: commit, - }, - new_worktree_path.clone(), - ); - anyhow::Ok((receiver, new_worktree_path)) - })?; - receiver.await??; - - workspace.update(cx, |workspace, cx| { - if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { - let repo_path = &repo.read(cx).snapshot().work_directory_abs_path; - let project = workspace.project(); - if let Some((parent_worktree, _)) = - project.read(cx).find_worktree(repo_path, cx) - { - let worktree_store = project.read(cx).worktree_store(); - trusted_worktrees.update(cx, |trusted_worktrees, cx| { - if trusted_worktrees.can_trust( - &worktree_store, - parent_worktree.read(cx).id(), - cx, - ) { - trusted_worktrees.trust( - &worktree_store, - HashSet::from_iter([PathTrust::AbsPath( - new_worktree_path.clone(), - )]), - cx, - ); - } - }); - } + if !self.has_multiple_repositories { + if let Some(ref default_branch) = self.default_branch_name { + let is_different = self + .current_branch_name + .as_ref() + .is_none_or(|current| current != default_branch); + if is_different { + entries.push(WorktreeEntry::CreateFromDefaultBranch { + default_branch_name: default_branch.clone(), + }); } - })?; - - let (connection_options, app_state, is_local) = - workspace.update(cx, |workspace, cx| { - let project = workspace.project().clone(); - let connection_options = project.read(cx).remote_connection_options(cx); - let app_state = workspace.app_state().clone(); - let is_local = project.read(cx).is_local(); - (connection_options, app_state, is_local) - })?; - - if is_local { - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_workspace_for_paths( - OpenMode::Activate, - vec![new_worktree_path], - window, - cx, - ) - })? - .await?; - } else if let Some(connection_options) = connection_options { - open_remote_worktree( - connection_options, - vec![new_worktree_path], - app_state, - workspace.clone(), - replace_current_window, - cx, - ) - .await?; } + } - anyhow::Ok(()) - }) - .detach_and_prompt_err("Failed to create worktree", window, cx, |e, _, _| { - let msg = e.to_string(); - if msg.contains("git.worktree_directory") { - Some(format!("Invalid git.worktree_directory setting: {}", e)) - } else { - Some(msg) - } - }); + entries } - fn open_worktree( - &self, - worktree_path: &PathBuf, - replace_current_window: bool, - window: &mut Window, - cx: &mut Context>, - ) { - let workspace = self.workspace.clone(); - let path = worktree_path.clone(); - - let Some((connection_options, app_state, is_local)) = workspace - .update(cx, |workspace, cx| { - let project = workspace.project().clone(); - let connection_options = project.read(cx).remote_connection_options(cx); - let app_state = workspace.app_state().clone(); - let is_local = project.read(cx).is_local(); - (connection_options, app_state, is_local) - }) - .log_err() - else { - return; - }; - let open_mode = if replace_current_window { - OpenMode::Activate - } else { - OpenMode::NewWindow - }; + fn all_repo_worktrees(&self) -> &[GitWorktree] { + &self.all_worktrees + } - if is_local { - let open_task = workspace.update(cx, |workspace, cx| { - workspace.open_workspace_for_paths(open_mode, vec![path], window, cx) - }); - cx.spawn(async move |_, _| { - open_task?.await?; - anyhow::Ok(()) - }) - .detach_and_prompt_err( - "Failed to open worktree", - window, - cx, - |e, _, _| Some(e.to_string()), - ); - } else if let Some(connection_options) = connection_options { - cx.spawn_in(window, async move |_, cx| { - open_remote_worktree( - connection_options, - vec![path], - app_state, - workspace, - replace_current_window, - cx, - ) - .await - }) - .detach_and_prompt_err( - "Failed to open worktree", - window, - cx, - |e, _, _| Some(e.to_string()), - ); + fn creation_blocked_reason(&self, cx: &App) -> Option { + let project = self.project.read(cx); + if project.is_via_collab() { + Some("Worktree creation is not supported in collaborative projects".into()) + } else if project.repositories(cx).is_empty() { + Some("Requires a Git repository in the project".into()) + } else { + None } - - cx.emit(DismissEvent); } - fn base_branch<'a>(&'a self, cx: &'a mut Context>) -> Option<&'a str> { - self.repo - .as_ref() - .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name())) + fn can_delete_worktree(&self, worktree: &GitWorktree) -> bool { + !worktree.is_main && !self.project_worktree_paths.contains(&worktree.path) } - fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(idx).cloned() else { + fn delete_worktree(&self, ix: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry) = self.matches.get(ix) else { + return; + }; + let WorktreeEntry::Worktree { worktree, .. } = entry else { return; }; - if !entry.can_delete(self.forbidden_deletion_path.as_ref()) { + if !self.can_delete_worktree(worktree) { return; } - let Some(repo) = self.repo.clone() else { + + let repo = self.project.read(cx).active_repository(cx); + let Some(repo) = repo else { return; }; + let path = worktree.path.clone(); let workspace = self.workspace.clone(); - let path = entry.worktree.path; cx.spawn_in(window, async move |picker, cx| { let result = repo .update(cx, |repo, _| repo.remove_worktree(path.clone(), false)) .await?; - if let Err(e) = result { - log::error!("Failed to remove worktree: {}", e); + if let Err(error) = result { + log::error!("Failed to remove worktree: {}", error); + if let Some(workspace) = workspace.upgrade() { cx.update(|_window, cx| { show_error_toast( workspace, format!("worktree remove {}", path.display()), - e, + error, cx, ) })?; } + return Ok(()); } - picker.update_in(cx, |picker, _, cx| { - picker.delegate.matches.retain(|e| e.worktree.path != path); - if let Some(all_worktrees) = &mut picker.delegate.all_worktrees { - all_worktrees.retain(|w| w.path != path); - } - picker.delegate.refresh_forbidden_deletion_path(cx); + picker.update_in(cx, |picker, _window, cx| { + picker.delegate.matches.retain(|e| { + !matches!(e, WorktreeEntry::Worktree { worktree, .. } if worktree.path == path) + }); + picker.delegate.all_worktrees.retain(|w| w.path != path); if picker.delegate.matches.is_empty() { picker.delegate.selected_index = 0; } else if picker.delegate.selected_index >= picker.delegate.matches.len() { @@ -555,139 +357,37 @@ impl WorktreeListDelegate { anyhow::Ok(()) }) - .detach(); + .detach_and_log_err(cx); } - fn refresh_forbidden_deletion_path(&mut self, cx: &App) { - let Some(workspace) = self.workspace.upgrade() else { - debug_panic!("Workspace should always be available or else the picker would be closed"); - self.forbidden_deletion_path = None; + fn sync_selected_index(&mut self, has_query: bool) { + if !has_query { return; - }; - - let visible_worktree_paths = workspace.read_with(cx, |workspace, cx| { - workspace - .project() - .read(cx) - .visible_worktrees(cx) - .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) - .collect::>() - }); + } - self.forbidden_deletion_path = if visible_worktree_paths.len() == 1 { - visible_worktree_paths.into_iter().next() + if let Some(index) = self + .matches + .iter() + .position(|entry| matches!(entry, WorktreeEntry::Worktree { .. })) + { + self.selected_index = index; + } else if let Some(index) = self + .matches + .iter() + .position(|entry| matches!(entry, WorktreeEntry::CreateNamed { .. })) + { + self.selected_index = index; } else { - None - }; + self.selected_index = 0; + } } } -async fn open_remote_worktree( - connection_options: RemoteConnectionOptions, - paths: Vec, - app_state: Arc, - workspace: WeakEntity, - replace_current_window: bool, - cx: &mut AsyncWindowContext, -) -> anyhow::Result<()> { - let workspace_window = cx - .window_handle() - .downcast::() - .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?; - - let connect_task = workspace.update_in(cx, |workspace, window, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx) - }); - - let prompt = workspace - .active_modal::(cx) - .expect("Modal just created") - .read(cx) - .prompt - .clone(); - - connect( - ConnectionIdentifier::setup(), - connection_options.clone(), - prompt, - window, - cx, - ) - .prompt_err("Failed to connect", window, cx, |_, _, _| None) - })?; - - let session = connect_task.await; - - workspace - .update_in(cx, |workspace, _window, cx| { - if let Some(prompt) = workspace.active_modal::(cx) { - prompt.update(cx, |prompt, cx| prompt.finished(cx)) - } - }) - .ok(); - - let Some(Some(session)) = session else { - return Ok(()); - }; - - let new_project: Entity = cx.update(|_, cx| { - project::Project::remote( - session, - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - true, - cx, - ) - })?; - - let window_to_use = if replace_current_window { - workspace_window - } else { - let workspace_position = cx - .update(|_, cx| { - workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx) - })? - .await - .context("fetching workspace position from db")?; - - let mut options = - cx.update(|_, cx| (app_state.build_window_options)(workspace_position.display, cx))?; - options.window_bounds = workspace_position.window_bounds; - - cx.open_window(options, |window, cx| { - let workspace = cx.new(|cx| { - let mut workspace = - Workspace::new(None, new_project.clone(), app_state.clone(), window, cx); - workspace.centered_layout = workspace_position.centered_layout; - workspace - }); - cx.new(|cx| MultiWorkspace::new(workspace, window, cx)) - })? - }; - - workspace::open_remote_project_with_existing_connection( - connection_options, - new_project, - paths, - app_state, - window_to_use, - None, - cx, - ) - .await?; - - Ok(()) -} - -impl PickerDelegate for WorktreeListDelegate { - type ListItem = ListItem; +impl PickerDelegate for WorktreePickerDelegate { + type ListItem = AnyElement; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select worktree…".into() + "Select a worktree…".into() } fn editor_position(&self) -> PickerEditorPosition { @@ -706,115 +406,276 @@ impl PickerDelegate for WorktreeListDelegate { &mut self, ix: usize, _window: &mut Window, - _: &mut Context>, + _cx: &mut Context>, ) { self.selected_index = ix; } + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + !matches!(self.matches.get(ix), Some(WorktreeEntry::Separator)) + } + fn update_matches( &mut self, query: String, window: &mut Window, cx: &mut Context>, ) -> Task<()> { - let Some(all_worktrees) = self.all_worktrees.clone() else { - return Task::ready(()); + let repo_worktrees = self.all_repo_worktrees().to_vec(); + + let normalized_query = query.replace(' ', "-"); + let main_worktree_path = self + .all_worktrees + .iter() + .find(|wt| wt.is_main) + .map(|wt| wt.path.clone()); + let has_named_worktree = self.all_worktrees.iter().any(|worktree| { + worktree.directory_name(main_worktree_path.as_deref()) == normalized_query + }); + let create_named_disabled_reason: Option = if self.has_multiple_repositories { + Some("Cannot create a named worktree in a project with multiple repositories".into()) + } else if has_named_worktree { + Some("A worktree with this name already exists".into()) + } else { + None }; - cx.spawn_in(window, async move |picker, cx| { - let main_worktree_path = all_worktrees - .iter() - .find(|wt| wt.is_main) - .map(|wt| wt.path.clone()); - - let mut matches: Vec = if query.is_empty() { - all_worktrees - .into_iter() - .map(|worktree| WorktreeEntry { - worktree, - positions: Vec::new(), - is_new: false, - }) - .collect() - } else { - let candidates = all_worktrees + let show_default_branch_create = !self.has_multiple_repositories + && self.default_branch_name.as_ref().is_some_and(|default| { + self.current_branch_name + .as_ref() + .is_none_or(|current| current != default) + }); + let default_branch_name = self.default_branch_name.clone(); + + if query.is_empty() { + let mut matches = self.build_fixed_entries(); + + if !repo_worktrees.is_empty() { + let main_worktree_path = repo_worktrees .iter() - .enumerate() - .map(|(ix, worktree)| { - StringMatchCandidate::new( - ix, - &worktree.directory_name(main_worktree_path.as_deref()), - ) + .find(|wt| wt.is_main) + .map(|wt| wt.path.clone()); + + let mut sorted = repo_worktrees; + let project_paths = &self.project_worktree_paths; + + sorted.sort_by(|a, b| { + let a_is_current = project_paths.contains(&a.path); + let b_is_current = project_paths.contains(&b.path); + b_is_current.cmp(&a_is_current).then_with(|| { + a.directory_name(main_worktree_path.as_deref()) + .cmp(&b.directory_name(main_worktree_path.as_deref())) }) - .collect::>(); - fuzzy::match_strings( - &candidates, - &query, - true, - true, - 10000, - &Default::default(), - cx.background_executor().clone(), + }); + + matches.push(WorktreeEntry::Separator); + for worktree in sorted { + matches.push(WorktreeEntry::Worktree { + worktree, + positions: Vec::new(), + }); + } + } + + self.matches = matches; + self.sync_selected_index(false); + return Task::ready(()); + } + + let main_worktree_path = repo_worktrees + .iter() + .find(|wt| wt.is_main) + .map(|wt| wt.path.clone()); + let candidates: Vec<_> = repo_worktrees + .iter() + .enumerate() + .map(|(ix, worktree)| { + StringMatchCandidate::new( + ix, + &worktree.directory_name(main_worktree_path.as_deref()), ) - .await - .into_iter() - .map(|candidate| WorktreeEntry { - worktree: all_worktrees[candidate.candidate_id].clone(), - positions: candidate.positions, - is_new: false, - }) - .collect() - }; + }) + .collect(); + + let executor = cx.background_executor().clone(); + + let task = cx.background_executor().spawn(async move { + fuzzy::match_strings( + &candidates, + &query, + true, + true, + 10000, + &Default::default(), + executor, + ) + .await + }); + + let repo_worktrees_clone = repo_worktrees; + cx.spawn_in(window, async move |picker, cx| { + let fuzzy_matches = task.await; + picker - .update(cx, |picker, _| { - if !query.is_empty() - && !matches.first().is_some_and(|entry| { - entry.worktree.directory_name(main_worktree_path.as_deref()) == query - }) - { - let query = query.replace(' ', "-"); - matches.push(WorktreeEntry { - worktree: GitWorktree { - path: Default::default(), - ref_name: Some(format!("refs/heads/{query}").into()), - sha: Default::default(), - is_main: false, - is_bare: false, - }, - positions: Vec::new(), - is_new: true, - }) + .update_in(cx, |picker, _window, cx| { + let mut new_matches: Vec = Vec::new(); + + for candidate in &fuzzy_matches { + new_matches.push(WorktreeEntry::Worktree { + worktree: repo_worktrees_clone[candidate.candidate_id].clone(), + positions: candidate.positions.clone(), + }); } - let delegate = &mut picker.delegate; - delegate.matches = matches; - if delegate.matches.is_empty() { - delegate.selected_index = 0; - } else { - delegate.selected_index = - core::cmp::min(delegate.selected_index, delegate.matches.len() - 1); + + if !new_matches.is_empty() { + new_matches.push(WorktreeEntry::Separator); } - delegate.last_query = query; + new_matches.push(WorktreeEntry::CreateNamed { + name: normalized_query.clone(), + from_branch: None, + disabled_reason: create_named_disabled_reason.clone(), + }); + if show_default_branch_create { + if let Some(ref default_branch) = default_branch_name { + new_matches.push(WorktreeEntry::CreateNamed { + name: normalized_query.clone(), + from_branch: Some(default_branch.clone()), + disabled_reason: create_named_disabled_reason.clone(), + }); + } + } + + picker.delegate.matches = new_matches; + picker.delegate.sync_selected_index(true); + + cx.notify(); }) .log_err(); }) } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(self.selected_index()) else { + let Some(entry) = self.matches.get(self.selected_index) else { return; }; - if entry.is_new { - self.create_worktree(&entry.worktree.display_name(), secondary, None, window, cx); - } else { - self.open_worktree(&entry.worktree.path, !secondary, window, cx); + + match entry { + WorktreeEntry::Separator => return, + WorktreeEntry::CreateFromCurrentBranch => { + if self.creation_blocked_reason(cx).is_some() { + return; + } + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::worktree_service::handle_create_worktree( + workspace, + &CreateWorktree { + worktree_name: None, + branch_target: NewWorktreeBranchTarget::CurrentBranch, + }, + window, + self.focused_dock, + cx, + ); + }); + } + } + WorktreeEntry::CreateFromDefaultBranch { + default_branch_name, + } => { + if self.creation_blocked_reason(cx).is_some() { + return; + } + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::worktree_service::handle_create_worktree( + workspace, + &CreateWorktree { + worktree_name: None, + branch_target: NewWorktreeBranchTarget::ExistingBranch { + name: default_branch_name.clone(), + }, + }, + window, + self.focused_dock, + cx, + ); + }); + } + } + WorktreeEntry::Worktree { worktree, .. } => { + let is_current = self.project_worktree_paths.contains(&worktree.path); + + if !is_current { + if secondary { + window.dispatch_action( + Box::new(OpenWorktreeInNewWindow { + path: worktree.path.clone(), + }), + cx, + ); + } else { + let main_worktree_path = self + .all_worktrees + .iter() + .find(|wt| wt.is_main) + .map(|wt| wt.path.as_path()); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::worktree_service::handle_switch_worktree( + workspace, + &SwitchWorktree { + path: worktree.path.clone(), + display_name: worktree.directory_name(main_worktree_path), + }, + window, + self.focused_dock, + cx, + ); + }); + } + } + } + } + WorktreeEntry::CreateNamed { + name, + from_branch, + disabled_reason: None, + } => { + let branch_target = match from_branch { + Some(branch) => NewWorktreeBranchTarget::ExistingBranch { + name: branch.clone(), + }, + None => NewWorktreeBranchTarget::CurrentBranch, + }; + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::worktree_service::handle_create_worktree( + workspace, + &CreateWorktree { + worktree_name: Some(name.clone()), + branch_target, + }, + window, + self.focused_dock, + cx, + ); + }); + } + } + WorktreeEntry::CreateNamed { + disabled_reason: Some(_), + .. + } => { + return; + } } cx.emit(DismissEvent); } - fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - cx.emit(DismissEvent); - } + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} fn render_match( &self, @@ -823,199 +684,249 @@ impl PickerDelegate for WorktreeListDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { - let entry = &self.matches.get(ix)?; - let path = entry.worktree.path.compact().to_string_lossy().to_string(); - let sha = entry - .worktree - .sha - .clone() - .chars() - .take(7) - .collect::(); - - let (branch_name, sublabel) = if entry.is_new { - ( - Label::new(format!( - "Create Worktree: \"{}\"…", - entry.worktree.display_name() - )) - .truncate() - .into_any_element(), - format!( - "based off {}", - self.base_branch(cx).unwrap_or("the current branch") - ), - ) - } else { - let main_worktree_path = self - .all_worktrees - .as_ref() - .and_then(|wts| wts.iter().find(|wt| wt.is_main)) - .map(|wt| wt.path.as_path()); - let display_name = entry.worktree.directory_name(main_worktree_path); - let first_line = display_name.lines().next().unwrap_or(&display_name); - let positions: Vec<_> = entry - .positions - .iter() - .copied() - .filter(|&pos| pos < first_line.len()) - .collect(); - - ( - HighlightedLabel::new(first_line.to_owned(), positions) - .truncate() - .into_any_element(), - path, - ) - }; - - let focus_handle = self.focus_handle.clone(); - - let can_delete = entry.can_delete(self.forbidden_deletion_path.as_ref()); - - let delete_button = |entry_ix: usize| { - IconButton::new(("delete-worktree", entry_ix), IconName::Trash) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action_in("Delete Worktree", &DeleteWorktree, &focus_handle, cx) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.delegate.delete_at(entry_ix, window, cx); - })) - }; + let entry = self.matches.get(ix)?; - let is_current = !entry.is_new - && self - .current_worktree_path - .as_ref() - .is_some_and(|current| *current == entry.worktree.path); + match entry { + WorktreeEntry::Separator => Some( + div() + .py(DynamicSpacing::Base04.rems(cx)) + .child(Divider::horizontal()) + .into_any_element(), + ), + WorktreeEntry::CreateFromCurrentBranch => { + let branch_label = if self.has_multiple_repositories { + "current branches".to_string() + } else { + self.current_branch_name + .clone() + .unwrap_or_else(|| "HEAD".to_string()) + }; + + let label = format!("Create new worktree based on {branch_label}"); + + let item = create_new_list_item( + "create-from-current".to_string().into(), + label.into(), + self.creation_blocked_reason(cx), + selected, + ); - let entry_icon = if entry.is_new { - IconName::Plus - } else if is_current { - IconName::Check - } else { - IconName::GitWorktree - }; + Some(item.into_any_element()) + } + WorktreeEntry::CreateFromDefaultBranch { + default_branch_name, + } => { + let label = format!("Create new worktree based on {default_branch_name}"); + + let item = create_new_list_item( + "create-from-main".to_string().into(), + label.into(), + self.creation_blocked_reason(cx), + selected, + ); - Some( - ListItem::new(format!("worktree-menu-{ix}")) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_2p5() + Some(item.into_any_element()) + } + WorktreeEntry::Worktree { + worktree, + positions, + } => { + let main_worktree_path = self + .all_worktrees + .iter() + .find(|wt| wt.is_main) + .map(|wt| wt.path.as_path()); + let display_name = worktree.directory_name(main_worktree_path); + let first_line = display_name.lines().next().unwrap_or(&display_name); + let positions: Vec<_> = positions + .iter() + .copied() + .filter(|&pos| pos < first_line.len()) + .collect(); + let path = worktree.path.compact().to_string_lossy().to_string(); + let sha = worktree.sha.chars().take(7).collect::(); + + let is_current = self.project_worktree_paths.contains(&worktree.path); + let can_delete = self.can_delete_worktree(worktree); + + let entry_icon = if is_current { + IconName::Check + } else { + IconName::GitWorktree + }; + + Some( + ListItem::new(SharedString::from(format!("worktree-{ix}"))) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) .child( - Icon::new(entry_icon) - .color(if is_current { - Color::Accent - } else { - Color::Muted - }) - .size(IconSize::Small), - ) - .child(v_flex().w_full().min_w_0().child(branch_name).map(|this| { - if entry.is_new { - this.child( - Label::new(sublabel) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), + h_flex() + .w_full() + .gap_2p5() + .child( + Icon::new(entry_icon) + .color(if is_current { + Color::Accent + } else { + Color::Muted + }) + .size(IconSize::Small), ) - } else { - this.child( - h_flex() + .child( + v_flex() .w_full() .min_w_0() - .gap_1p5() - .when_some( - entry.worktree.branch_name().map(|b| b.to_string()), - |this, branch| { - this.child( - Label::new(branch) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("•") - .alpha(0.5) - .color(Color::Muted) - .size(LabelSize::Small), - ) - }, - ) .child( - Label::new(sha) - .size(LabelSize::Small) - .color(Color::Muted), + HighlightedLabel::new(first_line.to_owned(), positions) + .truncate(), ) .child( - Label::new("•") - .alpha(0.5) - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - Label::new(sublabel) - .truncate_start() - .color(Color::Muted) - .size(LabelSize::Small) - .flex_1(), + h_flex() + .w_full() + .min_w_0() + .gap_1p5() + .when_some( + worktree.branch_name().map(|b| b.to_string()), + |this, branch| { + this.child( + Label::new(branch) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("\u{2022}") + .alpha(0.5) + .color(Color::Muted) + .size(LabelSize::Small), + ) + }, + ) + .when(!sha.is_empty(), |this| { + this.child( + Label::new(sha) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("\u{2022}") + .alpha(0.5) + .color(Color::Muted) + .size(LabelSize::Small), + ) + }) + .child( + Label::new(path) + .truncate_start() + .color(Color::Muted) + .size(LabelSize::Small) + .flex_1(), + ), + ), + ), + ) + .when(!is_current, |this| { + let open_in_new_window_button = + IconButton::new(("open-new-window", ix), IconName::ArrowUpRight) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Open in New Window")) + .on_click(cx.listener(move |picker, _, window, cx| { + let Some(entry) = picker.delegate.matches.get(ix) else { + return; + }; + if let WorktreeEntry::Worktree { worktree, .. } = entry { + window.dispatch_action( + Box::new(OpenWorktreeInNewWindow { + path: worktree.path.clone(), + }), + cx, + ); + cx.emit(DismissEvent); + } + })); + + let focus_handle_delete = self.focus_handle.clone(); + let delete_button = + IconButton::new(("delete-worktree", ix), IconName::Trash) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Delete Worktree", + &DeleteWorktree, + &focus_handle_delete, + cx, ) - .into_any_element(), - ) - } - })), + }) + .on_click(cx.listener(move |picker, _, window, cx| { + picker.delegate.delete_worktree(ix, window, cx); + })); + + this.end_slot( + h_flex() + .gap_0p5() + .child(open_in_new_window_button) + .when(can_delete, |this| this.child(delete_button)), + ) + .show_end_slot_on_hover() + }) + .into_any_element(), ) - .when(!entry.is_new && !is_current, |this| { - let focus_handle = self.focus_handle.clone(); - let open_in_new_window_button = - IconButton::new(("open-new-window", ix), IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - "Open in New Window", - &menu::SecondaryConfirm, - &focus_handle, - cx, - ) - }) - .on_click(|_, window, cx| { - window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); - }); - - this.end_slot( - h_flex() - .gap_0p5() - .child(open_in_new_window_button) - .when(can_delete, |this| this.child(delete_button(ix))), - ) - .show_end_slot_on_hover() - }), - ) - } + } + WorktreeEntry::CreateNamed { + name, + from_branch, + disabled_reason, + } => { + let branch_label = from_branch + .as_deref() + .unwrap_or(self.current_branch_name.as_deref().unwrap_or("HEAD")); + let label = format!("Create \"{name}\" based on {branch_label}"); + let element_id = match from_branch { + Some(branch) => format!("create-named-from-{branch}"), + None => "create-named-from-current".to_string(), + }; + + let item = create_new_list_item( + element_id.into(), + label.into(), + disabled_reason.clone().map(SharedString::from), + selected, + ); - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - Some("No worktrees found".into()) + Some(item.into_any_element()) + } + } } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + if !self.show_footer { + return None; + } + let focus_handle = self.focus_handle.clone(); let selected_entry = self.matches.get(self.selected_index); - let is_creating = selected_entry.is_some_and(|entry| entry.is_new); - let can_delete = selected_entry - .is_some_and(|entry| entry.can_delete(self.forbidden_deletion_path.as_ref())); - let is_current = selected_entry.is_some_and(|entry| { - !entry.is_new - && self - .current_worktree_path - .as_ref() - .is_some_and(|current| *current == entry.worktree.path) + + let is_creating = selected_entry.is_some_and(|e| { + matches!( + e, + WorktreeEntry::CreateFromCurrentBranch + | WorktreeEntry::CreateFromDefaultBranch { .. } + | WorktreeEntry::CreateNamed { .. } + ) + }); + + let is_existing_worktree = + selected_entry.is_some_and(|e| matches!(e, WorktreeEntry::Worktree { .. })); + + let can_delete = selected_entry.is_some_and(|e| { + matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.can_delete_worktree(worktree)) + }); + + let is_current = selected_entry.is_some_and(|e| { + matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.project_worktree_paths.contains(&worktree.path)) }); - let footer_container = h_flex() + let footer = h_flex() .w_full() .p_1p5() .gap_0p5() @@ -1024,44 +935,25 @@ impl PickerDelegate for WorktreeListDelegate { .border_color(cx.theme().colors().border_variant); if is_creating { - let from_default_button = self.default_branch.as_ref().map(|default_branch| { - Button::new( - "worktree-from-default", - format!("Create from: {default_branch}"), - ) - .key_binding( - KeyBinding::for_action_in(&WorktreeFromDefault, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(WorktreeFromDefault.boxed_clone(), cx) - }) - }); - - let current_branch = self.base_branch(cx).unwrap_or("current branch"); - Some( - footer_container - .when_some(from_default_button, |this, button| this.child(button)) + footer .child( - Button::new( - "worktree-from-current", - format!("Create from: {current_branch}"), - ) - .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx) - }), + Button::new("create-worktree", "Create") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), ) .into_any(), ) - } else { + } else if is_existing_worktree { Some( - footer_container + footer .when(can_delete, |this| { + let focus_handle = focus_handle.clone(); this.child( Button::new("delete-worktree", "Delete") .key_binding( @@ -1074,6 +966,7 @@ impl PickerDelegate for WorktreeListDelegate { ) }) .when(!is_current, |this| { + let focus_handle = focus_handle.clone(); this.child( Button::new("open-in-new-window", "Open in New Window") .key_binding( @@ -1090,7 +983,7 @@ impl PickerDelegate for WorktreeListDelegate { ) }) .child( - Button::new("open-in-window", "Open") + Button::new("open-worktree", "Open") .key_binding( KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), @@ -1101,6 +994,142 @@ impl PickerDelegate for WorktreeListDelegate { ) .into_any(), ) + } else { + None } } } + +fn create_new_list_item( + id: SharedString, + label: SharedString, + disabled_tooltip: Option, + selected: bool, +) -> AnyElement { + let is_disabled = disabled_tooltip.is_some(); + + ListItem::new(id) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + h_flex() + .w_full() + .gap_2p5() + .child( + Icon::new(IconName::Plus) + .map(|this| { + if is_disabled { + this.color(Color::Disabled) + } else { + this.color(Color::Muted) + } + }) + .size(IconSize::Small), + ) + .child(Label::new(label).when(is_disabled, |this| this.color(Color::Disabled))), + ) + .when_some(disabled_tooltip, |this, reason| { + this.tooltip(Tooltip::text(reason)) + }) + .into_any_element() +} + +pub async fn open_remote_worktree( + connection_options: remote::RemoteConnectionOptions, + paths: Vec, + app_state: Arc, + workspace: gpui::WeakEntity, + cx: &mut gpui::AsyncWindowContext, +) -> anyhow::Result<()> { + let connect_task = workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + remote_connection::RemoteConnectionModal::new( + &connection_options, + Vec::new(), + window, + cx, + ) + }); + + let prompt = workspace + .active_modal::(cx) + .expect("Modal just created") + .read(cx) + .prompt + .clone(); + + remote_connection::connect( + remote::remote_client::ConnectionIdentifier::setup(), + connection_options.clone(), + prompt, + window, + cx, + ) + .prompt_err("Failed to connect", window, cx, |_, _, _| None) + })?; + + let session = connect_task.await; + + workspace + .update_in(cx, |workspace, _window, cx| { + if let Some(prompt) = + workspace.active_modal::(cx) + { + prompt.update(cx, |prompt, cx| prompt.finished(cx)) + } + }) + .ok(); + + let Some(Some(session)) = session else { + return Ok(()); + }; + + let new_project = cx.update(|_, cx| { + project::Project::remote( + session, + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + true, + cx, + ) + })?; + + let workspace_position = cx + .update(|_, cx| { + workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx) + })? + .await + .context("fetching workspace position from db")?; + + let mut options = + cx.update(|_, cx| (app_state.build_window_options)(workspace_position.display, cx))?; + options.window_bounds = workspace_position.window_bounds; + + let new_window = cx.open_window(options, |window, cx| { + let workspace = cx.new(|cx| { + let mut workspace = + Workspace::new(None, new_project.clone(), app_state.clone(), window, cx); + workspace.centered_layout = workspace_position.centered_layout; + workspace + }); + cx.new(|cx| MultiWorkspace::new(workspace, window, cx)) + })?; + + workspace::open_remote_project_with_existing_connection( + connection_options, + new_project, + paths, + app_state, + new_window, + None, + None, + cx, + ) + .await?; + + Ok(()) +} diff --git a/crates/git_ui/src/worktree_service.rs b/crates/git_ui/src/worktree_service.rs new file mode 100644 index 00000000000000..c568b007f76377 --- /dev/null +++ b/crates/git_ui/src/worktree_service.rs @@ -0,0 +1,809 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::anyhow; +use collections::HashSet; +use fs::Fs; +use gpui::{AsyncWindowContext, Entity, SharedString, WeakEntity}; +use project::Project; +use project::git_store::Repository; +use project::project_settings::ProjectSettings; +use project::trusted_worktrees::{PathTrust, TrustedWorktrees}; +use remote::RemoteConnectionOptions; +use settings::Settings; +use workspace::{MultiWorkspace, OpenMode, PreviousWorkspaceState, Workspace, dock::DockPosition}; +use zed_actions::NewWorktreeBranchTarget; + +use util::ResultExt as _; + +use crate::git_panel::show_error_toast; +use crate::worktree_names; + +/// Whether a worktree operation is creating a new one or switching to an +/// existing one. Controls whether the source workspace's state (dock layout, +/// open files, agent panel draft) is inherited by the destination. +enum WorktreeOperation { + Create, + Switch, +} + +/// Classifies the project's visible worktrees into git-managed repositories +/// and non-git paths. Each unique repository is returned only once. +pub fn classify_worktrees( + project: &Project, + cx: &gpui::App, +) -> (Vec>, Vec) { + let repositories = project.repositories(cx).clone(); + let mut git_repos: Vec> = Vec::new(); + let mut non_git_paths: Vec = Vec::new(); + let mut seen_repo_ids = HashSet::default(); + + for worktree in project.visible_worktrees(cx) { + let wt_path = worktree.read(cx).abs_path(); + + let matching_repo = repositories + .iter() + .filter_map(|(id, repo)| { + let work_dir = repo.read(cx).work_directory_abs_path.clone(); + if wt_path.starts_with(work_dir.as_ref()) { + Some((*id, repo.clone(), work_dir.as_ref().components().count())) + } else { + None + } + }) + .max_by( + |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| { + left_depth + .cmp(right_depth) + .then_with(|| left_id.cmp(right_id)) + }, + ); + + if let Some((id, repo, _)) = matching_repo { + if seen_repo_ids.insert(id) { + git_repos.push(repo); + } + } else { + non_git_paths.push(wt_path.to_path_buf()); + } + } + + (git_repos, non_git_paths) +} + +/// Resolves a branch target into the ref the new worktree should be based on. +/// Returns `None` for `CurrentBranch`, meaning "use the current HEAD". +pub fn resolve_worktree_branch_target(branch_target: &NewWorktreeBranchTarget) -> Option { + match branch_target { + NewWorktreeBranchTarget::CurrentBranch => None, + NewWorktreeBranchTarget::ExistingBranch { name } => Some(name.clone()), + } +} + +/// Kicks off an async git-worktree creation for each repository. Returns: +/// +/// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples. +/// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs for remapping editor tabs. +fn start_worktree_creations( + git_repos: &[Entity], + worktree_name: Option, + existing_worktree_names: &[String], + existing_worktree_paths: &HashSet, + base_ref: Option, + worktree_directory_setting: &str, + rng: &mut impl rand::Rng, + cx: &mut gpui::App, +) -> anyhow::Result<( + Vec<( + Entity, + PathBuf, + futures::channel::oneshot::Receiver>, + )>, + Vec<(PathBuf, PathBuf)>, +)> { + let mut creation_infos = Vec::new(); + let mut path_remapping = Vec::new(); + + let worktree_name = worktree_name.unwrap_or_else(|| { + let existing_refs: Vec<&str> = existing_worktree_names.iter().map(|s| s.as_str()).collect(); + worktree_names::generate_worktree_name(&existing_refs, rng) + .unwrap_or_else(|| "worktree".to_string()) + }); + + for repo in git_repos { + let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| { + let new_path = + repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?; + if existing_worktree_paths.contains(&new_path) { + anyhow::bail!("A worktree already exists at {}", new_path.display()); + } + let target = git::repository::CreateWorktreeTarget::Detached { + base_sha: base_ref.clone(), + }; + let receiver = repo.create_worktree(target, new_path.clone()); + let work_dir = repo.work_directory_abs_path.clone(); + anyhow::Ok((work_dir, new_path, receiver)) + })?; + path_remapping.push((work_dir.to_path_buf(), new_path.clone())); + creation_infos.push((repo.clone(), new_path, receiver)); + } + + Ok((creation_infos, path_remapping)) +} + +/// Waits for every in-flight worktree creation to complete. If any +/// creation fails, all successfully-created worktrees are rolled back +/// (removed) so the project isn't left in a half-migrated state. +pub async fn await_and_rollback_on_failure( + creation_infos: Vec<( + Entity, + PathBuf, + futures::channel::oneshot::Receiver>, + )>, + fs: Arc, + cx: &mut AsyncWindowContext, +) -> anyhow::Result> { + let mut created_paths: Vec = Vec::new(); + let mut repos_and_paths: Vec<(Entity, PathBuf)> = Vec::new(); + let mut first_error: Option = None; + + for (repo, new_path, receiver) in creation_infos { + repos_and_paths.push((repo.clone(), new_path.clone())); + match receiver.await { + Ok(Ok(())) => { + created_paths.push(new_path); + } + Ok(Err(err)) => { + if first_error.is_none() { + first_error = Some(err); + } + } + Err(_canceled) => { + if first_error.is_none() { + first_error = Some(anyhow!("Worktree creation was canceled")); + } + } + } + } + + let Some(err) = first_error else { + return Ok(created_paths); + }; + + // Rollback all attempted worktrees + let mut rollback_futures = Vec::new(); + for (rollback_repo, rollback_path) in &repos_and_paths { + let receiver = cx + .update(|_, cx| { + rollback_repo.update(cx, |repo, _cx| { + repo.remove_worktree(rollback_path.clone(), true) + }) + }) + .ok(); + + rollback_futures.push((rollback_path.clone(), receiver)); + } + + let mut rollback_failures: Vec = Vec::new(); + for (path, receiver_opt) in rollback_futures { + let mut git_remove_failed = false; + + if let Some(receiver) = receiver_opt { + match receiver.await { + Ok(Ok(())) => {} + Ok(Err(rollback_err)) => { + log::error!( + "git worktree remove failed for {}: {rollback_err}", + path.display() + ); + git_remove_failed = true; + } + Err(canceled) => { + log::error!( + "git worktree remove failed for {}: {canceled}", + path.display() + ); + git_remove_failed = true; + } + } + } else { + log::error!( + "failed to dispatch git worktree remove for {}", + path.display() + ); + git_remove_failed = true; + } + + if git_remove_failed { + if let Err(fs_err) = fs + .remove_dir( + &path, + fs::RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + { + let msg = format!("{}: failed to remove directory: {fs_err}", path.display()); + log::error!("{}", msg); + rollback_failures.push(msg); + } + } + } + let mut error_message = format!("Failed to create worktree: {err}"); + if !rollback_failures.is_empty() { + error_message.push_str("\n\nFailed to clean up: "); + error_message.push_str(&rollback_failures.join(", ")); + } + Err(anyhow!(error_message)) +} + +/// Propagates worktree trust from the source workspace to the new workspace. +/// If the source project's worktrees are all trusted, the new worktree paths +/// will also be trusted automatically. +fn maybe_propagate_worktree_trust( + source_workspace: &WeakEntity, + new_workspace: &Entity, + paths: &[PathBuf], + cx: &mut AsyncWindowContext, +) { + cx.update(|_, cx| { + if ProjectSettings::get_global(cx).session.trust_all_worktrees { + return; + } + let Some(trusted_store) = TrustedWorktrees::try_get_global(cx) else { + return; + }; + + let source_is_trusted = source_workspace + .upgrade() + .map(|workspace| { + let source_worktree_store = workspace.read(cx).project().read(cx).worktree_store(); + !trusted_store + .read(cx) + .has_restricted_worktrees(&source_worktree_store, cx) + }) + .unwrap_or(false); + + if !source_is_trusted { + return; + } + + let worktree_store = new_workspace.read(cx).project().read(cx).worktree_store(); + let paths_to_trust: HashSet<_> = paths + .iter() + .filter_map(|path| { + let (worktree, _) = worktree_store.read(cx).find_worktree(path, cx)?; + Some(PathTrust::Worktree(worktree.read(cx).id())) + }) + .collect(); + + if !paths_to_trust.is_empty() { + trusted_store.update(cx, |store, cx| { + store.trust(&worktree_store, paths_to_trust, cx); + }); + } + }) + .ok(); +} + +/// Handles the `CreateWorktree` action generically, without any agent panel involvement. +/// Creates a new git worktree, opens the workspace, restores layout and files. +pub fn handle_create_worktree( + workspace: &mut Workspace, + action: &zed_actions::CreateWorktree, + window: &mut gpui::Window, + fallback_focused_dock: Option, + cx: &mut gpui::Context, +) { + let project = workspace.project().clone(); + + if project.read(cx).repositories(cx).is_empty() { + log::error!("create_worktree: no git repository in the project"); + return; + } + if project.read(cx).is_via_collab() { + log::error!("create_worktree: not supported in collab projects"); + return; + } + + // Guard against concurrent creation + if workspace.active_worktree_creation().label.is_some() { + return; + } + + let previous_state = + workspace.capture_state_for_worktree_switch(window, fallback_focused_dock, cx); + let workspace_handle = workspace.weak_handle(); + let window_handle = window.window_handle().downcast::(); + let remote_connection_options = project.read(cx).remote_connection_options(cx); + + let (git_repos, non_git_paths) = classify_worktrees(project.read(cx), cx); + + if git_repos.is_empty() { + show_error_toast( + cx.entity(), + "worktree create", + anyhow!("No git repositories found in the project"), + cx, + ); + return; + } + + if remote_connection_options.is_some() { + let is_disconnected = project + .read(cx) + .remote_client() + .is_some_and(|client| client.read(cx).is_disconnected()); + if is_disconnected { + show_error_toast( + cx.entity(), + "worktree create", + anyhow!("Cannot create worktree: remote connection is not active"), + cx, + ); + return; + } + } + + let worktree_name = action.worktree_name.clone(); + let branch_target = action.branch_target.clone(); + let display_name: SharedString = worktree_name + .as_deref() + .unwrap_or("worktree") + .to_string() + .into(); + + workspace.set_active_worktree_creation(Some(display_name), false, cx); + + cx.spawn_in(window, async move |_workspace_entity, mut cx| { + let result = do_create_worktree( + git_repos, + non_git_paths, + worktree_name, + branch_target, + previous_state, + workspace_handle.clone(), + window_handle, + remote_connection_options, + &mut cx, + ) + .await; + + if let Err(err) = &result { + log::error!("Failed to create worktree: {err}"); + workspace_handle + .update(cx, |workspace, cx| { + workspace.set_active_worktree_creation(None, false, cx); + show_error_toast(cx.entity(), "worktree create", anyhow!("{err:#}"), cx); + }) + .ok(); + } + + result + }) + .detach_and_log_err(cx); +} + +pub fn handle_switch_worktree( + workspace: &mut Workspace, + action: &zed_actions::SwitchWorktree, + window: &mut gpui::Window, + fallback_focused_dock: Option, + cx: &mut gpui::Context, +) { + let project = workspace.project().clone(); + + if project.read(cx).repositories(cx).is_empty() { + log::error!("switch_to_worktree: no git repository in the project"); + return; + } + if project.read(cx).is_via_collab() { + log::error!("switch_to_worktree: not supported in collab projects"); + return; + } + + // Guard against concurrent creation + if workspace.active_worktree_creation().label.is_some() { + return; + } + + let previous_state = + workspace.capture_state_for_worktree_switch(window, fallback_focused_dock, cx); + let workspace_handle = workspace.weak_handle(); + let window_handle = window.window_handle().downcast::(); + let remote_connection_options = project.read(cx).remote_connection_options(cx); + + let (git_repos, non_git_paths) = classify_worktrees(project.read(cx), cx); + + let git_repo_work_dirs: Vec = git_repos + .iter() + .map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf()) + .collect(); + + let display_name: SharedString = action.display_name.clone().into(); + + workspace.set_active_worktree_creation(Some(display_name), true, cx); + + let worktree_path = action.path.clone(); + + cx.spawn_in(window, async move |_workspace_entity, mut cx| { + let result = do_switch_worktree( + worktree_path, + git_repo_work_dirs, + non_git_paths, + previous_state, + workspace_handle.clone(), + window_handle, + remote_connection_options, + &mut cx, + ) + .await; + + if let Err(err) = &result { + log::error!("Failed to switch worktree: {err}"); + workspace_handle + .update(cx, |workspace, cx| { + workspace.set_active_worktree_creation(None, false, cx); + show_error_toast(cx.entity(), "worktree switch", anyhow!("{err:#}"), cx); + }) + .ok(); + } + + result + }) + .detach_and_log_err(cx); +} + +async fn do_create_worktree( + git_repos: Vec>, + non_git_paths: Vec, + worktree_name: Option, + branch_target: NewWorktreeBranchTarget, + previous_state: PreviousWorkspaceState, + workspace: WeakEntity, + window_handle: Option>, + remote_connection_options: Option, + cx: &mut AsyncWindowContext, +) -> anyhow::Result<()> { + // List existing worktrees from all repos to detect name collisions + let worktree_receivers: Vec<_> = cx.update(|_, cx| { + git_repos + .iter() + .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees())) + .collect() + })?; + let worktree_directory_setting = cx.update(|_, cx| { + ProjectSettings::get_global(cx) + .git + .worktree_directory + .clone() + })?; + + let mut existing_worktree_names = Vec::new(); + let mut existing_worktree_paths = HashSet::default(); + for result in futures::future::join_all(worktree_receivers).await { + match result { + Ok(Ok(worktrees)) => { + for worktree in worktrees { + if let Some(name) = worktree + .path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + { + existing_worktree_names.push(name.to_string()); + } + existing_worktree_paths.insert(worktree.path.clone()); + } + } + Ok(Err(err)) => { + Err::<(), _>(err).log_err(); + } + Err(_) => {} + } + } + + let mut rng = rand::rng(); + + let base_ref = resolve_worktree_branch_target(&branch_target); + + let (creation_infos, path_remapping) = cx.update(|_, cx| { + start_worktree_creations( + &git_repos, + worktree_name, + &existing_worktree_names, + &existing_worktree_paths, + base_ref, + &worktree_directory_setting, + &mut rng, + cx, + ) + })??; + + let fs = cx.update(|_, cx| ::global(cx))?; + + let created_paths = await_and_rollback_on_failure(creation_infos, fs, cx).await?; + + let mut all_paths = created_paths; + let has_non_git = !non_git_paths.is_empty(); + all_paths.extend(non_git_paths.iter().cloned()); + + open_worktree_workspace( + all_paths, + path_remapping, + non_git_paths, + has_non_git, + previous_state, + workspace, + window_handle, + remote_connection_options, + WorktreeOperation::Create, + cx, + ) + .await +} + +async fn do_switch_worktree( + worktree_path: PathBuf, + git_repo_work_dirs: Vec, + non_git_paths: Vec, + previous_state: PreviousWorkspaceState, + workspace: WeakEntity, + window_handle: Option>, + remote_connection_options: Option, + cx: &mut AsyncWindowContext, +) -> anyhow::Result<()> { + let path_remapping: Vec<(PathBuf, PathBuf)> = git_repo_work_dirs + .iter() + .map(|work_dir| (work_dir.clone(), worktree_path.clone())) + .collect(); + + let mut all_paths = vec![worktree_path]; + let has_non_git = !non_git_paths.is_empty(); + all_paths.extend(non_git_paths.iter().cloned()); + + open_worktree_workspace( + all_paths, + path_remapping, + non_git_paths, + has_non_git, + previous_state, + workspace, + window_handle, + remote_connection_options, + WorktreeOperation::Switch, + cx, + ) + .await +} + +/// Core workspace opening logic shared by both create and switch flows. +async fn open_worktree_workspace( + all_paths: Vec, + path_remapping: Vec<(PathBuf, PathBuf)>, + non_git_paths: Vec, + has_non_git: bool, + previous_state: PreviousWorkspaceState, + workspace: WeakEntity, + window_handle: Option>, + remote_connection_options: Option, + operation: WorktreeOperation, + cx: &mut AsyncWindowContext, +) -> anyhow::Result<()> { + let window_handle = window_handle + .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?; + + let focused_dock = previous_state.focused_dock; + + let is_creating_new_worktree = matches!(operation, WorktreeOperation::Create); + + let source_for_transfer = if is_creating_new_worktree { + Some(workspace.clone()) + } else { + None + }; + + let (workspace_task, modal_workspace) = + window_handle.update(cx, |multi_workspace, window, cx| { + let path_list = util::path_list::PathList::new(&all_paths); + let active_workspace = multi_workspace.workspace().clone(); + let modal_workspace = active_workspace.clone(); + + let init: Option< + Box< + dyn FnOnce(&mut Workspace, &mut gpui::Window, &mut gpui::Context) + + Send, + >, + > = if is_creating_new_worktree { + let dock_structure = previous_state.dock_structure; + Some(Box::new( + move |workspace: &mut Workspace, + window: &mut gpui::Window, + cx: &mut gpui::Context| { + workspace.set_dock_structure(dock_structure, window, cx); + }, + )) + } else { + None + }; + + let task = multi_workspace.find_or_create_workspace_with_source_workspace( + path_list, + remote_connection_options, + None, + move |connection_options, window, cx| { + remote_connection::connect_with_modal( + &active_workspace, + connection_options, + window, + cx, + ) + }, + &[], + init, + OpenMode::Add, + source_for_transfer.clone(), + window, + cx, + ); + (task, modal_workspace) + })?; + + let result = workspace_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let new_workspace = result?; + + let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()); + + if let Some(task) = panels_task { + task.await.log_err(); + } + + new_workspace + .update(cx, |workspace, cx| { + workspace.project().read(cx).wait_for_initial_scan(cx) + }) + .await; + + new_workspace + .update(cx, |workspace, cx| { + let repos = workspace + .project() + .read(cx) + .repositories(cx) + .values() + .cloned() + .collect::>(); + + let tasks = repos + .into_iter() + .map(|repo| repo.update(cx, |repo, _| repo.barrier())); + futures::future::join_all(tasks) + }) + .await; + + maybe_propagate_worktree_trust(&workspace, &new_workspace, &all_paths, cx); + + if is_creating_new_worktree { + window_handle.update(cx, |_multi_workspace, window, cx| { + new_workspace.update(cx, |workspace, cx| { + if has_non_git { + struct WorktreeCreationToast; + let toast_id = + workspace::notifications::NotificationId::unique::(); + workspace.show_toast( + workspace::Toast::new( + toast_id, + "Some project folders are not git repositories. \ + They were included as-is without creating a worktree.", + ), + cx, + ); + } + + // Remap every previously-open file path into the new worktree. + let remap_path = |original_path: PathBuf| -> Option { + let best_match = path_remapping + .iter() + .filter_map(|(old_root, new_root)| { + original_path.strip_prefix(old_root).ok().map(|relative| { + (old_root.components().count(), new_root.join(relative)) + }) + }) + .max_by_key(|(depth, _)| *depth); + + if let Some((_, remapped_path)) = best_match { + return Some(remapped_path); + } + + for non_git in &non_git_paths { + if original_path.starts_with(non_git) { + return Some(original_path); + } + } + None + }; + + let remapped_active_path = + previous_state.active_file_path.and_then(|p| remap_path(p)); + + let mut paths_to_open: Vec = Vec::new(); + let mut seen = HashSet::default(); + for path in previous_state.open_file_paths { + if let Some(remapped) = remap_path(path) { + if remapped_active_path.as_ref() != Some(&remapped) + && seen.insert(remapped.clone()) + { + paths_to_open.push(remapped); + } + } + } + + if let Some(active) = &remapped_active_path { + if seen.insert(active.clone()) { + paths_to_open.push(active.clone()); + } + } + + if !paths_to_open.is_empty() { + let should_focus_center = focused_dock.is_none(); + let open_task = workspace.open_paths( + paths_to_open, + workspace::OpenOptions { + focus: Some(false), + ..Default::default() + }, + None, + window, + cx, + ); + cx.spawn_in(window, async move |workspace, cx| { + for item in open_task.await.into_iter().flatten() { + item.log_err(); + } + if should_focus_center { + workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_center_pane(window, cx); + })?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + }); + })?; + } + + // Clear the creation status on the SOURCE workspace so its title bar + // stops showing the loading indicator immediately. + workspace + .update(cx, |ws, cx| { + ws.set_active_worktree_creation(None, false, cx); + }) + .ok(); + + window_handle.update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(new_workspace.clone(), source_for_transfer, window, cx); + + new_workspace.update(cx, |workspace, cx| { + workspace.run_create_worktree_tasks(window, cx); + }); + })?; + + if is_creating_new_worktree { + if let Some(dock_position) = focused_dock { + window_handle.update(cx, |_multi_workspace, window, cx| { + new_workspace.update(cx, |workspace, cx| { + let dock = workspace.dock_at_position(dock_position); + if let Some(panel) = dock.read(cx).active_panel() { + panel.panel_focus_handle(cx).focus(window, cx); + } + }); + })?; + } + } + + anyhow::Ok(()) +} diff --git a/crates/google_ai/Cargo.toml b/crates/google_ai/Cargo.toml index d91d2885199772..3848ed5f87514d 100644 --- a/crates/google_ai/Cargo.toml +++ b/crates/google_ai/Cargo.toml @@ -24,4 +24,3 @@ schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true strum.workspace = true -tiktoken-rs.workspace = true diff --git a/crates/google_ai/src/completion.rs b/crates/google_ai/src/completion.rs index 3a15fdaa0187e5..efbd1dc9ff731f 100644 --- a/crates/google_ai/src/completion.rs +++ b/crates/google_ai/src/completion.rs @@ -313,29 +313,6 @@ impl GoogleEventMapper { } } -/// Count tokens for a Google AI model using tiktoken. This is synchronous; -/// callers should spawn it on a background thread if needed. -pub fn count_google_tokens(request: LanguageModelRequest) -> Result { - let messages = request - .messages - .into_iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(message.string_contents()), - name: None, - function_call: None, - }) - .collect::>(); - - // Tiktoken doesn't yet support these models, so we manually use the - // same tokenizer as GPT-4. - tiktoken_rs::num_tokens_from_messages("gpt-4", &messages).map(|tokens| tokens as u64) -} - fn update_usage(usage: &mut UsageMetadata, new: &UsageMetadata) { if let Some(prompt_token_count) = new.prompt_token_count { usage.prompt_token_count = Some(prompt_token_count); diff --git a/crates/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index 7917eb45c6292d..1461197bd97212 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -64,38 +64,6 @@ pub async fn stream_generate_content( } } -pub async fn count_tokens( - client: &dyn HttpClient, - api_url: &str, - api_key: &str, - request: CountTokensRequest, -) -> Result { - validate_generate_content_request(&request.generate_content_request)?; - - let uri = format!( - "{api_url}/v1beta/models/{model_id}:countTokens?key={api_key}", - model_id = &request.generate_content_request.model.model_id, - ); - - let request = serde_json::to_string(&request)?; - let request_builder = HttpRequest::builder() - .method(Method::POST) - .uri(&uri) - .header("Content-Type", "application/json"); - let http_request = request_builder.body(AsyncBody::from(request))?; - - let mut response = client.send(http_request).await?; - let mut text = String::new(); - response.body_mut().read_to_string(&mut text).await?; - anyhow::ensure!( - response.status().is_success(), - "error during countTokens, status code: {:?}, body: {}", - response.status(), - text - ); - Ok(serde_json::from_str::(&text)?) -} - pub fn validate_generate_content_request(request: &GenerateContentRequest) -> Result<()> { if request.model.is_empty() { bail!("Model must be specified"); @@ -123,8 +91,6 @@ pub enum Task { GenerateContent, #[serde(rename = "streamGenerateContent")] StreamGenerateContent, - #[serde(rename = "countTokens")] - CountTokens, #[serde(rename = "embedContent")] EmbedContent, #[serde(rename = "batchEmbedContents")] @@ -382,18 +348,6 @@ pub struct SafetyRating { pub probability: HarmProbability, } -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CountTokensRequest { - pub generate_content_request: GenerateContentRequest, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CountTokensResponse { - pub total_tokens: u64, -} - #[derive(Debug, Serialize, Deserialize)] pub struct FunctionCall { pub name: String, diff --git a/crates/gpui/examples/anchor.rs b/crates/gpui/examples/anchor.rs new file mode 100644 index 00000000000000..7aa9bb4cf92639 --- /dev/null +++ b/crates/gpui/examples/anchor.rs @@ -0,0 +1,201 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +use gpui::{ + Anchor, AnchoredPositionMode, App, Axis, Bounds, Context, Half as _, InteractiveElement, + ParentElement, Pixels, Point, Render, SharedString, Size, Window, WindowBounds, WindowOptions, + anchored, deferred, div, point, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +struct AnchorDemo { + hovered_button: Option, +} + +struct ButtonDemo { + label: SharedString, + corner: Option, +} + +fn resolved_position(corner: Anchor, button_size: Size) -> Point { + let offset = Point { + x: px(0.), + y: -button_size.height, + }; + + offset + + match corner.other_side_along(Axis::Vertical) { + Anchor::TopLeft => point(px(0.0), px(0.0)), + Anchor::TopCenter => point(button_size.width.half(), px(0.0)), + Anchor::TopRight => point(button_size.width, px(0.0)), + Anchor::LeftCenter => point(button_size.width, button_size.height.half()), + Anchor::RightCenter => point(px(0.), button_size.height.half()), + Anchor::BottomLeft => point(px(0.0), button_size.height), + Anchor::BottomCenter => point(button_size.width / 2.0, button_size.height), + Anchor::BottomRight => point(button_size.width, button_size.height), + } +} + +impl AnchorDemo { + fn buttons() -> Vec { + vec![ + ButtonDemo { + label: "TopLeft".into(), + corner: Some(Anchor::TopLeft), + }, + ButtonDemo { + label: "TopCenter".into(), + corner: Some(Anchor::TopCenter), + }, + ButtonDemo { + label: "TopRight".into(), + corner: Some(Anchor::TopRight), + }, + ButtonDemo { + label: "LeftCenter".into(), + corner: Some(Anchor::LeftCenter), + }, + ButtonDemo { + label: "Center".into(), + corner: None, + }, + ButtonDemo { + label: "RightCenter".into(), + corner: Some(Anchor::RightCenter), + }, + ButtonDemo { + label: "BottomLeft".into(), + corner: Some(Anchor::BottomLeft), + }, + ButtonDemo { + label: "BottomCenter".into(), + corner: Some(Anchor::BottomCenter), + }, + ButtonDemo { + label: "BottomRight".into(), + corner: Some(Anchor::BottomRight), + }, + ] + } +} + +impl Render for AnchorDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let buttons = Self::buttons(); + let button_size = size(px(120.0), px(65.0)); + + div() + .flex() + .flex_col() + .size_full() + .items_center() + .justify_center() + .bg(gpui::white()) + .gap_4() + .p_10() + .child("Popover with Anchor") + .child( + div() + .size_128() + .grid() + .grid_cols(3) + .gap_6() + .relative() + .children(buttons.iter().enumerate().map(|(index, button)| { + let is_hovered = self.hovered_button == Some(index); + let is_hoverable = button.corner.is_some(); + div() + .relative() + .child( + div() + .id(("button", index)) + .w(button_size.width) + .h(button_size.height) + .flex() + .items_center() + .justify_center() + .bg(gpui::white()) + .when(is_hoverable, |this| { + this.border_1() + .rounded_lg() + .border_color(gpui::black()) + .hover(|style| { + style.bg(gpui::black()).text_color(gpui::white()) + }) + .on_hover(cx.listener( + move |this, hovered, _window, cx| { + if *hovered { + this.hovered_button = Some(index); + } else if this.hovered_button == Some(index) { + this.hovered_button = None; + } + cx.notify(); + }, + )) + .child(button.label.clone()) + }), + ) + .when_some(self.hovered_button.filter(|_| is_hovered), |this, index| { + let button = &buttons[index]; + let Some(corner) = button.corner else { + return this; + }; + + let position = resolved_position(corner, button_size); + this.child(deferred( + anchored() + .anchor(corner) + .position(position) + .position_mode(AnchoredPositionMode::Local) + .snap_to_window() + .child( + div() + .py_0p5() + .px_2() + .bg(gpui::black().opacity(0.75)) + .text_color(rgb(0xffffff)) + .rounded_sm() + .shadow_sm() + .min_w(px(100.0)) + .text_sm() + .child(button.label.clone()), + ), + )) + }) + })), + ) + } +} + +fn run_example() { + application().run(|cx: &mut App| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::centered( + None, + size(px(750.), px(600.)), + cx, + ))), + ..Default::default() + }, + |_, cx| { + cx.new(|_| AnchorDemo { + hovered_button: None, + }) + }, + ) + .unwrap(); + 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/examples/popover.rs b/crates/gpui/examples/popover.rs index 9d5f84a1f43462..e4f0ac0a3ca371 100644 --- a/crates/gpui/examples/popover.rs +++ b/crates/gpui/examples/popover.rs @@ -1,7 +1,7 @@ #![cfg_attr(target_family = "wasm", no_main)] use gpui::{ - App, Context, Corner, Div, Hsla, Stateful, Window, WindowOptions, anchored, deferred, div, + Anchor, App, Context, Div, Hsla, Stateful, Window, WindowOptions, anchored, deferred, div, prelude::*, px, }; use gpui_platform::application; @@ -59,7 +59,7 @@ impl HelloWorld { // Now GPUI supports nested deferred! deferred( anchored() - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .snap_to_window_with_margin(px(8.)) .child( popover() @@ -98,7 +98,7 @@ impl Render for HelloWorld { button("popover0").child("Opened Popover").child( deferred( anchored() - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .snap_to_window_with_margin(px(8.)) .child(popover().w_96().gap_3().child( "This is a default opened Popover, \ @@ -120,7 +120,7 @@ impl Render for HelloWorld { this.child( deferred( anchored() - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .snap_to_window_with_margin(px(8.)) .child( popover() diff --git a/crates/gpui/src/bounds_tree.rs b/crates/gpui/src/bounds_tree.rs index 9cf86a2cc9b6de..e95e32477b912b 100644 --- a/crates/gpui/src/bounds_tree.rs +++ b/crates/gpui/src/bounds_tree.rs @@ -3,6 +3,7 @@ use std::{ cmp, fmt::Debug, ops::{Add, Sub}, + ptr::NonNull, }; /// Maximum children per internal node (R-tree style branching factor). @@ -30,7 +31,7 @@ where /// Reusable stack for tree traversal during insertion. insert_path: Vec, /// Reusable stack for search operations. - search_stack: Vec, + search_stack: Vec>>, } /// A node in the bounds tree. @@ -150,12 +151,14 @@ where // Slow path: search the tree self.search_stack.clear(); - self.search_stack.push(root_idx); + self.search_stack.push(NonNull::from(&self.nodes[root_idx])); let mut max_found = 0u32; - while let Some(node_idx) = self.search_stack.pop() { - let node = &self.nodes[node_idx]; + while let Some(node) = self.search_stack.pop() { + // SAFETY: `node` is guaranteed to be valid as the `nodes` stack is unmodified in this function + // and the `search_stack` only contains pointers from this function call. + let node = unsafe { node.as_ref() }; // Pruning: skip if this subtree can't improve our result if node.max_order <= max_found { @@ -174,11 +177,14 @@ where NodeKind::Internal { children } => { // Children are maintained with highest max_order at the end. // Push in forward order to highest (last) is popped first. - for &child_idx in children.as_slice() { - if self.nodes[child_idx].max_order > max_found { - self.search_stack.push(child_idx); - } - } + self.search_stack.extend( + children + .as_slice() + .iter() + .map(|&child_idx| &self.nodes[child_idx]) + .filter(|node| node.max_order > max_found) + .map(NonNull::from), + ); } } } diff --git a/crates/gpui/src/elements/anchored.rs b/crates/gpui/src/elements/anchored.rs index f92593ef8db992..ad8fa11b71ee82 100644 --- a/crates/gpui/src/elements/anchored.rs +++ b/crates/gpui/src/elements/anchored.rs @@ -1,7 +1,7 @@ use smallvec::SmallVec; use crate::{ - AnyElement, App, Axis, Bounds, Corner, Display, Edges, Element, GlobalElementId, + Anchor, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style, Window, point, px, }; @@ -15,7 +15,7 @@ pub struct AnchoredState { /// will avoid overflowing the window bounds. pub struct Anchored { children: SmallVec<[AnyElement; 2]>, - anchor_corner: Corner, + anchor: Anchor, fit_mode: AnchoredFitMode, anchor_position: Option>, position_mode: AnchoredPositionMode, @@ -27,7 +27,7 @@ pub struct Anchored { pub fn anchored() -> Anchored { Anchored { children: SmallVec::new(), - anchor_corner: Corner::TopLeft, + anchor: Anchor::TopLeft, fit_mode: AnchoredFitMode::SwitchAnchor, anchor_position: None, position_mode: AnchoredPositionMode::Window, @@ -37,8 +37,8 @@ pub fn anchored() -> Anchored { impl Anchored { /// Sets which corner of the anchored element should be anchored to the current position. - pub fn anchor(mut self, anchor: Corner) -> Self { - self.anchor_corner = anchor; + pub fn anchor(mut self, anchor: Anchor) -> Self { + self.anchor = anchor; self } @@ -143,7 +143,7 @@ impl Element for Anchored { let (origin, mut desired) = self.position_mode.get_position_and_bounds( self.anchor_position, - self.anchor_corner, + self.anchor, size, bounds, self.offset, @@ -155,23 +155,23 @@ impl Element for Anchored { }; if self.fit_mode == AnchoredFitMode::SwitchAnchor { - let mut anchor_corner = self.anchor_corner; + let mut anchor = self.anchor; if desired.left() < limits.left() || desired.right() > limits.right() { - let switched = Bounds::from_corner_and_size( - anchor_corner.other_side_corner_along(Axis::Horizontal), + let switched = Bounds::from_anchor_and_size( + anchor.other_side_along(Axis::Horizontal), origin, size, ); if !(switched.left() < limits.left() || switched.right() > limits.right()) { - anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal); + anchor = anchor.other_side_along(Axis::Horizontal); desired = switched } } if desired.top() < limits.top() || desired.bottom() > limits.bottom() { - let switched = Bounds::from_corner_and_size( - anchor_corner.other_side_corner_along(Axis::Vertical), + let switched = Bounds::from_anchor_and_size( + anchor.other_side_along(Axis::Vertical), origin, size, ); @@ -264,7 +264,7 @@ impl AnchoredPositionMode { fn get_position_and_bounds( &self, anchor_position: Option>, - anchor_corner: Corner, + anchor: Anchor, size: Size, bounds: Bounds, offset: Option>, @@ -274,14 +274,13 @@ impl AnchoredPositionMode { match self { AnchoredPositionMode::Window => { let anchor_position = anchor_position.unwrap_or(bounds.origin); - let bounds = - Bounds::from_corner_and_size(anchor_corner, anchor_position + offset, size); + let bounds = Bounds::from_anchor_and_size(anchor, anchor_position + offset, size); (anchor_position, bounds) } AnchoredPositionMode::Local => { let anchor_position = anchor_position.unwrap_or_default(); - let bounds = Bounds::from_corner_and_size( - anchor_corner, + let bounds = Bounds::from_anchor_and_size( + anchor, bounds.origin + anchor_position + offset, size, ); diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 76157a06a587ac..e5951a129667ce 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -826,24 +826,45 @@ where }; Bounds { origin, size } } +} +impl Bounds +where + T: Sub + Half + Clone + Debug + Default + PartialEq, +{ /// Constructs a `Bounds` from a corner point and size. The specified corner will be placed at /// the specified origin. - pub fn from_corner_and_size(corner: Corner, origin: Point, size: Size) -> Bounds { + pub fn from_anchor_and_size(corner: Anchor, origin: Point, size: Size) -> Bounds { let origin = match corner { - Corner::TopLeft => origin, - Corner::TopRight => Point { + Anchor::TopLeft => origin, + Anchor::TopRight => Point { x: origin.x - size.width.clone(), y: origin.y, }, - Corner::BottomLeft => Point { + Anchor::BottomLeft => Point { x: origin.x, y: origin.y - size.height.clone(), }, - Corner::BottomRight => Point { + Anchor::BottomRight => Point { x: origin.x - size.width.clone(), y: origin.y - size.height.clone(), }, + Anchor::TopCenter => Point { + x: origin.x - size.width.half(), + y: origin.y, + }, + Anchor::BottomCenter => Point { + x: origin.x - size.width.half(), + y: origin.y - size.height.clone(), + }, + Anchor::LeftCenter => Point { + x: origin.x, + y: origin.y - size.height.half(), + }, + Anchor::RightCenter => Point { + x: origin.x - size.width.clone(), + y: origin.y - size.height.half(), + }, }; Bounds { origin, size } @@ -864,6 +885,43 @@ where } } +impl Bounds +where + T: Add + Half + Clone + Debug + Default + PartialEq, +{ + /// Returns the top center point of the bounds. + pub fn top_center(&self) -> Point { + Point { + x: self.origin.x.clone() + self.size.width.half(), + y: self.origin.y.clone(), + } + } + + /// Returns the bottom center point of the bounds. + pub fn bottom_center(&self) -> Point { + Point { + x: self.origin.x.clone() + self.size.width.half(), + y: self.origin.y.clone() + self.size.height.clone(), + } + } + + /// Returns the left center point of the bounds. + pub fn left_center(&self) -> Point { + Point { + x: self.origin.x.clone(), + y: self.origin.y.clone() + self.size.height.half(), + } + } + + /// Returns the right center point of the bounds. + pub fn right_center(&self) -> Point { + Point { + x: self.origin.x.clone() + self.size.width.clone(), + y: self.origin.y.clone() + self.size.height.half(), + } + } +} + impl Bounds where T: PartialOrd + Add + Clone + Debug + Default + PartialEq, @@ -1334,7 +1392,12 @@ where y: self.origin.y.clone() + self.size.height.clone(), } } +} +impl Bounds +where + T: Add + Half + Clone + Debug + Default + PartialEq, +{ /// Returns the requested corner point of the bounds. /// /// # Returns @@ -1344,20 +1407,24 @@ where /// # Examples /// /// ``` - /// use gpui::{Bounds, Corner, Point, Size}; + /// use gpui::{Bounds, Anchor, Point, Size}; /// let bounds = Bounds { /// origin: Point { x: 0, y: 0 }, /// size: Size { width: 10, height: 20 }, /// }; - /// let bottom_left = bounds.corner(Corner::BottomLeft); + /// let bottom_left = bounds.corner(Anchor::BottomLeft); /// assert_eq!(bottom_left, Point { x: 0, y: 20 }); /// ``` - pub fn corner(&self, corner: Corner) -> Point { + pub fn corner(&self, corner: Anchor) -> Point { match corner { - Corner::TopLeft => self.origin.clone(), - Corner::TopRight => self.top_right(), - Corner::BottomLeft => self.bottom_left(), - Corner::BottomRight => self.bottom_right(), + Anchor::TopLeft => self.origin.clone(), + Anchor::TopRight => self.top_right(), + Anchor::BottomLeft => self.bottom_left(), + Anchor::BottomRight => self.bottom_right(), + Anchor::TopCenter => self.top_center(), + Anchor::BottomCenter => self.bottom_center(), + Anchor::LeftCenter => self.left_center(), + Anchor::RightCenter => self.right_center(), } } } @@ -2093,9 +2160,9 @@ impl From for Edges { } } -/// Identifies a corner of a 2d box. +/// Identifies a reference point on a 2D box, used to anchor positioned elements. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Corner { +pub enum Anchor { /// The top left corner TopLeft, /// The top right corner @@ -2104,24 +2171,36 @@ pub enum Corner { BottomLeft, /// The bottom right corner BottomRight, + /// The top center position + TopCenter, + /// The bottom center position + BottomCenter, + /// The left center position + LeftCenter, + /// The right center position + RightCenter, } -impl Corner { +impl Anchor { /// Returns the directly opposite corner. /// /// # Examples /// /// ``` - /// # use gpui::Corner; - /// assert_eq!(Corner::TopLeft.opposite_corner(), Corner::BottomRight); + /// # use gpui::Anchor; + /// assert_eq!(Anchor::TopLeft.opposite(), Anchor::BottomRight); /// ``` #[must_use] - pub fn opposite_corner(self) -> Self { + pub fn opposite(self) -> Self { match self { - Corner::TopLeft => Corner::BottomRight, - Corner::TopRight => Corner::BottomLeft, - Corner::BottomLeft => Corner::TopRight, - Corner::BottomRight => Corner::TopLeft, + Anchor::TopLeft => Anchor::BottomRight, + Anchor::TopRight => Anchor::BottomLeft, + Anchor::BottomLeft => Anchor::TopRight, + Anchor::BottomRight => Anchor::TopLeft, + Anchor::TopCenter => Anchor::BottomCenter, + Anchor::BottomCenter => Anchor::TopCenter, + Anchor::LeftCenter => Anchor::RightCenter, + Anchor::RightCenter => Anchor::LeftCenter, } } @@ -2130,27 +2209,44 @@ impl Corner { /// # Examples /// /// ``` - /// # use gpui::{Axis, Corner}; - /// let result = Corner::TopLeft.other_side_corner_along(Axis::Horizontal); - /// assert_eq!(result, Corner::TopRight); + /// # use gpui::{Axis, Anchor}; + /// let result = Anchor::TopLeft.other_side_along(Axis::Horizontal); + /// assert_eq!(result, Anchor::TopRight); /// ``` #[must_use] - pub fn other_side_corner_along(self, axis: Axis) -> Self { + pub fn other_side_along(self, axis: Axis) -> Self { match axis { Axis::Vertical => match self { - Corner::TopLeft => Corner::BottomLeft, - Corner::TopRight => Corner::BottomRight, - Corner::BottomLeft => Corner::TopLeft, - Corner::BottomRight => Corner::TopRight, + Anchor::TopLeft => Anchor::BottomLeft, + Anchor::TopRight => Anchor::BottomRight, + Anchor::BottomLeft => Anchor::TopLeft, + Anchor::BottomRight => Anchor::TopRight, + Anchor::TopCenter => Anchor::BottomCenter, + Anchor::BottomCenter => Anchor::TopCenter, + Anchor::LeftCenter => Anchor::LeftCenter, + Anchor::RightCenter => Anchor::RightCenter, }, Axis::Horizontal => match self { - Corner::TopLeft => Corner::TopRight, - Corner::TopRight => Corner::TopLeft, - Corner::BottomLeft => Corner::BottomRight, - Corner::BottomRight => Corner::BottomLeft, + Anchor::TopLeft => Anchor::TopRight, + Anchor::TopRight => Anchor::TopLeft, + Anchor::BottomLeft => Anchor::BottomRight, + Anchor::BottomRight => Anchor::BottomLeft, + Anchor::TopCenter => Anchor::TopCenter, + Anchor::BottomCenter => Anchor::BottomCenter, + Anchor::LeftCenter => Anchor::RightCenter, + Anchor::RightCenter => Anchor::LeftCenter, }, } } + + /// Returns true if at the center. + #[inline] + pub fn is_center(&self) -> bool { + matches!( + self, + Self::TopCenter | Self::BottomCenter | Self::LeftCenter | Self::RightCenter + ) + } } /// Represents the corners of a box in a 2D space, such as border radius. @@ -2172,7 +2268,7 @@ pub struct Corners { impl Corners where - T: Clone + Debug + Default + PartialEq, + T: Add + Half + Clone + Debug + Default + PartialEq, { /// Constructs `Corners` where all sides are set to the same specified value. /// @@ -2207,31 +2303,60 @@ where } } - /// Returns the requested corner. + /// Returns the requested corner value, supporting all eight corner positions. + /// + /// For the four basic corners (TopLeft, TopRight, BottomLeft, BottomRight), + /// this returns the corresponding field value directly. + /// + /// For the center positions (TopCenter, BottomCenter, LeftCenter, RightCenter), + /// this calculates the average of the two adjacent corners. /// /// # Returns /// - /// A `Point` representing the corner requested by the parameter. + /// A value of type `T` representing the corner requested by the parameter. /// /// # Examples /// + /// Basic corner positions: + /// + /// ``` + /// # use gpui::{Anchor, Corners}; + /// let corners = Corners { + /// top_left: 10, + /// top_right: 20, + /// bottom_left: 30, + /// bottom_right: 40 + /// }; + /// assert_eq!(corners.corner(Anchor::TopLeft), 10); + /// assert_eq!(corners.corner(Anchor::BottomRight), 40); + /// ``` + /// + /// Center positions (calculated as average of adjacent corners): + /// /// ``` - /// # use gpui::{Corner, Corners}; + /// # use gpui::{Anchor, Corners}; /// let corners = Corners { - /// top_left: 1, - /// top_right: 2, - /// bottom_left: 3, - /// bottom_right: 4 + /// top_left: 10, + /// top_right: 20, + /// bottom_left: 30, + /// bottom_right: 40 /// }; - /// assert_eq!(corners.corner(Corner::BottomLeft), 3); + /// assert_eq!(corners.corner(Anchor::TopCenter), 15); + /// assert_eq!(corners.corner(Anchor::BottomCenter), 35); + /// assert_eq!(corners.corner(Anchor::LeftCenter), 20); + /// assert_eq!(corners.corner(Anchor::RightCenter), 30); /// ``` #[must_use] - pub fn corner(&self, corner: Corner) -> T { + pub fn corner(&self, corner: Anchor) -> T { match corner { - Corner::TopLeft => self.top_left.clone(), - Corner::TopRight => self.top_right.clone(), - Corner::BottomLeft => self.bottom_left.clone(), - Corner::BottomRight => self.bottom_right.clone(), + Anchor::TopLeft => self.top_left.clone(), + Anchor::TopRight => self.top_right.clone(), + Anchor::BottomLeft => self.bottom_left.clone(), + Anchor::BottomRight => self.bottom_right.clone(), + Anchor::TopCenter => (self.top_left.clone() + self.top_right.clone()).half(), + Anchor::BottomCenter => (self.bottom_left.clone() + self.bottom_right.clone()).half(), + Anchor::LeftCenter => (self.top_left.clone() + self.bottom_left.clone()).half(), + Anchor::RightCenter => (self.top_right.clone() + self.bottom_right.clone()).half(), } } } @@ -2337,7 +2462,7 @@ impl + Ord + Clone + Debug + Default + PartialEq> Corner /// /// # Returns /// - /// Corner radii values clamped to fit. + /// Anchor radii values clamped to fit. #[must_use] pub fn clamp_radii_for_quad_size(self, size: Size) -> Corners { let max = cmp::min(size.width, size.height) / 2.; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index c67eb8f160f9db..59957a9c6ade0b 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1048,7 +1048,7 @@ impl AtlasTextureList { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[repr(C)] #[expect(missing_docs)] pub struct AtlasTile { @@ -1981,6 +1981,8 @@ pub enum ImageFormat { Tiff, /// .ico Ico, + /// Netpbm image formats (.pbm, .ppm, .pgm). + Pnm, } impl ImageFormat { @@ -1995,20 +1997,25 @@ impl ImageFormat { ImageFormat::Bmp => "image/bmp", ImageFormat::Tiff => "image/tiff", ImageFormat::Ico => "image/ico", + ImageFormat::Pnm => "image/x-portable-anymap", } } - /// Returns the ImageFormat for the given mime type + /// Returns the ImageFormat for the given mime type, including known aliases. pub fn from_mime_type(mime_type: &str) -> Option { + use strum::IntoEnumIterator; + Self::iter() + .find(|format| format.mime_type() == mime_type) + .or_else(|| Self::from_mime_type_alias(mime_type)) + } + + /// Non-canonical mime types that some producers use in the wild. + /// Unlike `mime_type()` which returns the single canonical form, + /// these are legacy or shortened variants we still need to recognize. + fn from_mime_type_alias(mime_type: &str) -> Option { match mime_type { - "image/png" => Some(Self::Png), - "image/jpeg" | "image/jpg" => Some(Self::Jpeg), - "image/webp" => Some(Self::Webp), - "image/gif" => Some(Self::Gif), - "image/svg+xml" => Some(Self::Svg), - "image/bmp" => Some(Self::Bmp), - "image/tiff" | "image/tif" => Some(Self::Tiff), - "image/ico" => Some(Self::Ico), + "image/jpg" => Some(Self::Jpeg), + "image/tif" => Some(Self::Tiff), _ => None, } } @@ -2131,6 +2138,7 @@ impl Image { .render_single_frame(&self.bytes, 1.0) .map_err(Into::into); } + ImageFormat::Pnm => frames_for_image(&self.bytes, image::ImageFormat::Pnm)?, }; Ok(Arc::new(RenderImage::new(frames))) diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 583450c9e93e6b..ac98027b52013c 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -353,8 +353,8 @@ impl PlatformAtlas for TestAtlas { >, ) -> anyhow::Result> { let mut state = self.0.lock(); - if let Some(tile) = state.tiles.get(key) { - return Ok(Some(tile.clone())); + if let Some(&tile) = state.tiles.get(key) { + return Ok(Some(tile)); } drop(state); @@ -384,7 +384,7 @@ impl PlatformAtlas for TestAtlas { }, ); - Ok(Some(state.tiles[key].clone())) + Ok(Some(state.tiles[key])) } fn remove(&self, key: &AtlasKey) { diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 22b1bb468d84b2..ef37caa7b95cfb 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -88,11 +88,11 @@ impl Scene { match &mut primitive { Primitive::Shadow(shadow) => { shadow.order = order; - self.shadows.push(shadow.clone()); + self.shadows.push(*shadow); } Primitive::Quad(quad) => { quad.order = order; - self.quads.push(quad.clone()); + self.quads.push(*quad); } Primitive::Path(path) => { path.order = order; @@ -101,19 +101,19 @@ impl Scene { } Primitive::Underline(underline) => { underline.order = order; - self.underlines.push(underline.clone()); + self.underlines.push(*underline); } Primitive::MonochromeSprite(sprite) => { sprite.order = order; - self.monochrome_sprites.push(sprite.clone()); + self.monochrome_sprites.push(*sprite); } Primitive::SubpixelSprite(sprite) => { sprite.order = order; - self.subpixel_sprites.push(sprite.clone()); + self.subpixel_sprites.push(*sprite); } Primitive::PolychromeSprite(sprite) => { sprite.order = order; - self.polychrome_sprites.push(sprite.clone()); + self.polychrome_sprites.push(*sprite); } Primitive::Surface(surface) => { surface.order = order; @@ -481,7 +481,7 @@ pub enum PrimitiveBatch { Surfaces(Range), } -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug, Copy, Clone)] #[repr(C)] #[expect(missing_docs)] pub struct Quad { @@ -501,7 +501,7 @@ impl From for Primitive { } } -#[derive(Debug, Clone)] +#[derive(Debug, Copy, Clone)] #[repr(C)] #[expect(missing_docs)] pub struct Underline { @@ -520,7 +520,7 @@ impl From for Primitive { } } -#[derive(Debug, Clone)] +#[derive(Debug, Copy, Clone)] #[repr(C)] #[expect(missing_docs)] pub struct Shadow { @@ -652,7 +652,7 @@ impl Default for TransformationMatrix { } } -#[derive(Clone, Debug)] +#[derive(Copy, Clone, Debug)] #[repr(C)] #[expect(missing_docs)] pub struct MonochromeSprite { @@ -671,7 +671,7 @@ impl From for Primitive { } } -#[derive(Clone, Debug)] +#[derive(Copy, Clone, Debug)] #[repr(C)] #[expect(missing_docs)] pub struct SubpixelSprite { @@ -690,7 +690,7 @@ impl From for Primitive { } } -#[derive(Clone, Debug)] +#[derive(Copy, Clone, Debug)] #[repr(C)] #[expect(missing_docs)] pub struct PolychromeSprite { diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 8f3d7563d06897..633474f2b194e1 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -186,7 +186,7 @@ impl LineLayout { if width > wrap_width && boundary > last_boundary { // When used line_clamp, we should limit the number of lines. if let Some(max_lines) = max_lines - && boundaries.len() >= max_lines - 1 + && boundaries.len() >= max_lines.saturating_sub(1) { break; } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f73a59358c3b06..ab195c629492bf 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1627,7 +1627,7 @@ pub struct DispatchEventResult { /// Indicates which region of the window is visible. Content falling outside of this mask will not be /// rendered. Currently, only rectangular content masks are supported, but we give the mask its own type /// to leave room to support more complex shapes in the future. -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] #[repr(C)] pub struct ContentMask { /// The bounds @@ -2713,7 +2713,7 @@ impl Window { .set_active_node(deferred_draw.parent_node); let paint_start = self.paint_index(); - let content_mask = deferred_draw.content_mask.clone(); + let content_mask = deferred_draw.content_mask; if let Some(element) = deferred_draw.element.as_mut() { self.with_rendered_view(deferred_draw.current_view, |window| { window.with_content_mask(content_mask, |window| { @@ -2790,7 +2790,7 @@ impl Window { parent_node: reused_subtree.refresh_node_id(deferred_draw.parent_node), element_id_stack: deferred_draw.element_id_stack.clone(), text_style_stack: deferred_draw.text_style_stack.clone(), - content_mask: deferred_draw.content_mask.clone(), + content_mask: deferred_draw.content_mask, rem_size: deferred_draw.rem_size, priority: deferred_draw.priority, element: None, diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 1e3af66c59858c..9f2556fc2aea66 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1385,6 +1385,10 @@ impl PlatformWindow for WaylandWindow { } state.renderer.draw(scene); + + if state.renderer.needs_redraw() { + state.force_render_after_recovery = true; + } } fn completed_frame(&self) { diff --git a/crates/gpui_linux/src/linux/x11/clipboard.rs b/crates/gpui_linux/src/linux/x11/clipboard.rs index d2ea58b3f8c2ac..fb6cecd904591e 100644 --- a/crates/gpui_linux/src/linux/x11/clipboard.rs +++ b/crates/gpui_linux/src/linux/x11/clipboard.rs @@ -48,6 +48,7 @@ use x11rb::{ }; use gpui::{ClipboardItem, Image, ImageFormat, hash}; +use strum::IntoEnumIterator; type Result = std::result::Result; @@ -87,7 +88,7 @@ x11rb::atom_manager! { BMP__MIME: ImageFormat::mime_type(ImageFormat::Bmp ).as_bytes(), TIFF_MIME: ImageFormat::mime_type(ImageFormat::Tiff).as_bytes(), ICO__MIME: ImageFormat::mime_type(ImageFormat::Ico ).as_bytes(), - + PNM__MIME: ImageFormat::mime_type(ImageFormat::Pnm ).as_bytes(), // This is just some random name for the property on our window, into which // the clipboard owner writes the data we requested. ARBOARD_CLIPBOARD, @@ -989,14 +990,8 @@ impl Clipboard { self.inner.write(data, selection, wait) } - #[allow(unused)] - pub(crate) fn set_image( - &self, - image: Image, - selection: ClipboardKind, - wait: WaitConfig, - ) -> Result<()> { - let format = match image.format { + fn image_format_atom(&self, format: ImageFormat) -> Atom { + match format { ImageFormat::Png => self.inner.atoms.PNG__MIME, ImageFormat::Jpeg => self.inner.atoms.JPEG_MIME, ImageFormat::Webp => self.inner.atoms.WEBP_MIME, @@ -1005,7 +1000,18 @@ impl Clipboard { ImageFormat::Bmp => self.inner.atoms.BMP__MIME, ImageFormat::Tiff => self.inner.atoms.TIFF_MIME, ImageFormat::Ico => self.inner.atoms.ICO__MIME, - }; + ImageFormat::Pnm => self.inner.atoms.PNM__MIME, + } + } + + #[allow(unused)] + pub(crate) fn set_image( + &self, + image: Image, + selection: ClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let format = self.image_format_atom(image.format); let data = vec![ClipboardData { bytes: image.bytes, format: self.inner.atoms.PNG__MIME, @@ -1014,28 +1020,11 @@ impl Clipboard { } pub(crate) fn get_any(&self, selection: ClipboardKind) -> Result { - const IMAGE_FORMAT_COUNT: usize = 7; - let image_format_atoms: [Atom; IMAGE_FORMAT_COUNT] = [ - self.inner.atoms.PNG__MIME, - self.inner.atoms.JPEG_MIME, - self.inner.atoms.WEBP_MIME, - self.inner.atoms.GIF__MIME, - self.inner.atoms.SVG__MIME, - self.inner.atoms.BMP__MIME, - self.inner.atoms.TIFF_MIME, - ]; - let image_formats: [ImageFormat; IMAGE_FORMAT_COUNT] = [ - ImageFormat::Png, - ImageFormat::Jpeg, - ImageFormat::Webp, - ImageFormat::Gif, - ImageFormat::Svg, - ImageFormat::Bmp, - ImageFormat::Tiff, - ]; + let image_entries = ImageFormat::iter() + .map(|format| (self.image_format_atom(format), format)) + .collect::>(); - const TEXT_FORMAT_COUNT: usize = 6; - let text_format_atoms: [Atom; TEXT_FORMAT_COUNT] = [ + let text_format_atoms: &[Atom] = &[ self.inner.atoms.UTF8_STRING, self.inner.atoms.UTF8_MIME_0, self.inner.atoms.UTF8_MIME_1, @@ -1044,17 +1033,11 @@ impl Clipboard { self.inner.atoms.TEXT_MIME_UNKNOWN, ]; - let atom_none: Atom = AtomEnum::NONE.into(); - - const FORMAT_ATOM_COUNT: usize = TEXT_FORMAT_COUNT + IMAGE_FORMAT_COUNT; - - let mut format_atoms: [Atom; FORMAT_ATOM_COUNT] = [atom_none; FORMAT_ATOM_COUNT]; - // image formats first, as they are more specific, and read will return the first // format that the contents can be converted to - format_atoms[0..IMAGE_FORMAT_COUNT].copy_from_slice(&image_format_atoms); - format_atoms[IMAGE_FORMAT_COUNT..].copy_from_slice(&text_format_atoms); - debug_assert!(!format_atoms.contains(&atom_none)); + let mut format_atoms = Vec::with_capacity(image_entries.len() + text_format_atoms.len()); + format_atoms.extend(image_entries.iter().map(|(atom, _)| *atom)); + format_atoms.extend_from_slice(text_format_atoms); let result = self.inner.read(&format_atoms, selection)?; @@ -1063,7 +1046,7 @@ impl Clipboard { self.inner.atom_name(result.format) ); - for (format_atom, image_format) in image_format_atoms.into_iter().zip(image_formats) { + for (format_atom, image_format) in image_entries { if result.format == format_atom { let bytes = result.bytes; let id = hash(&bytes); diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index c21d8baf31de06..285ba8802db744 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1680,6 +1680,10 @@ impl PlatformWindow for X11Window { } inner.renderer.draw(scene); + + if inner.renderer.needs_redraw() { + inner.force_render_after_recovery = true; + } } fn sprite_atlas(&self) -> Arc { diff --git a/crates/gpui_macos/src/metal_atlas.rs b/crates/gpui_macos/src/metal_atlas.rs index e6b8443c520e1b..5bedf9df8cd6f1 100644 --- a/crates/gpui_macos/src/metal_atlas.rs +++ b/crates/gpui_macos/src/metal_atlas.rs @@ -44,7 +44,7 @@ impl PlatformAtlas for MetalAtlas { ) -> Result> { let mut lock = self.0.lock(); if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(tile.clone())) + Ok(Some(*tile)) } else { let Some((size, bytes)) = build()? else { return Ok(None); @@ -54,7 +54,7 @@ impl PlatformAtlas for MetalAtlas { .context("failed to allocate")?; let texture = lock.texture(tile.texture_id); texture.upload(tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile.clone()); + lock.tiles_by_key.insert(key.clone(), tile); Ok(Some(tile)) } } diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index e96d14b15691be..73b53ce6ea5d7e 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -1469,7 +1469,7 @@ impl MetalRenderer { buffer_contents, SurfaceBounds { bounds: surface.bounds, - content_mask: surface.content_mask.clone(), + content_mask: surface.content_mask, }, ); } diff --git a/crates/gpui_macos/src/pasteboard.rs b/crates/gpui_macos/src/pasteboard.rs index d8b7f5627ddc44..8362ab8f3b5c0e 100644 --- a/crates/gpui_macos/src/pasteboard.rs +++ b/crates/gpui_macos/src/pasteboard.rs @@ -272,6 +272,7 @@ impl From for UTType { ImageFormat::Bmp => Self::bmp(), ImageFormat::Svg => Self::svg(), ImageFormat::Ico => Self::ico(), + ImageFormat::Pnm => Self::pnm(), } } } @@ -320,6 +321,11 @@ impl UTType { Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType } + pub fn pnm() -> Self { + //https://en.wikipedia.org/w/index.php?title=Netpbm&oldid=1336679433 under Uniform Type Identifier + Self(unsafe { ns_string("public.pbm") }) + } + fn inner(&self) -> *const Object { self.0 } diff --git a/crates/gpui_shared_string/gpui_shared_string.rs b/crates/gpui_shared_string/gpui_shared_string.rs index 4fd2f8c32112fe..79e6dcd5627dec 100644 --- a/crates/gpui_shared_string/gpui_shared_string.rs +++ b/crates/gpui_shared_string/gpui_shared_string.rs @@ -46,7 +46,7 @@ impl JsonSchema for SharedString { impl Default for SharedString { fn default() -> Self { - Self(ArcCow::Owned(Arc::default())) + Self(ArcCow::Borrowed("")) } } diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs index 4c2c6ab601442f..f3b9de1ca82e16 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -82,6 +82,15 @@ impl WgpuAtlas { } } + /// Clears all cached textures and tiles, forcing them to be recreated. + /// Use this for incremental recovery when the device is still valid. + pub fn clear(&self) { + let mut lock = self.0.lock(); + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); + } + /// Handles device lost by clearing all textures and cached tiles. /// The atlas will lazily recreate textures as needed on subsequent frames. pub fn handle_device_lost(&self, context: &WgpuContext) { @@ -103,7 +112,7 @@ impl PlatformAtlas for WgpuAtlas { ) -> Result> { let mut lock = self.0.lock(); if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(tile.clone())) + Ok(Some(*tile)) } else { profiling::scope!("new tile"); let Some((size, bytes)) = build()? else { @@ -113,7 +122,7 @@ impl PlatformAtlas for WgpuAtlas { .allocate(size, key.texture_kind()) .context("failed to allocate")?; lock.upload_texture(tile.texture_id, tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile.clone()); + lock.tiles_by_key.insert(key.clone(), tile); Ok(Some(tile)) } } diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index c38de02707f9da..39b6f3f7bdfee9 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -121,6 +121,15 @@ struct WgpuResources { path_msaa_view: Option, } +impl WgpuResources { + fn invalidate_intermediate_textures(&mut self) { + self.path_intermediate_texture = None; + self.path_intermediate_view = None; + self.path_msaa_texture = None; + self.path_msaa_view = None; + } +} + pub struct WgpuRenderer { /// Shared GPU context for device recovery coordination (unused on WASM). #[allow(dead_code)] @@ -146,6 +155,7 @@ pub struct WgpuRenderer { failed_frame_count: u32, device_lost: std::sync::Arc, surface_configured: bool, + needs_redraw: bool, } impl WgpuRenderer { @@ -474,6 +484,7 @@ impl WgpuRenderer { failed_frame_count: 0, device_lost: context.device_lost_flag(), surface_configured: true, + needs_redraw: false, }) } @@ -973,10 +984,7 @@ impl WgpuRenderer { // Invalidate intermediate textures - they will be lazily recreated // in draw() after we confirm the surface is healthy. This avoids // panics when the device/surface is in an invalid state during resize. - resources.path_intermediate_texture = None; - resources.path_intermediate_view = None; - resources.path_msaa_texture = None; - resources.path_msaa_view = None; + resources.invalidate_intermediate_textures(); } } @@ -1077,10 +1085,19 @@ impl WgpuRenderer { if let Some(error) = last_error { self.failed_frame_count += 1; log::error!( - "GPU error during frame (failure {} of 20): {error}", + "GPU error during frame (failure {} of 10): {error}", self.failed_frame_count ); - if self.failed_frame_count > 20 { + + // TBD. Does retrying more actually help? + if self.failed_frame_count > 5 { + if let Some(res) = self.resources.as_mut() { + res.invalidate_intermediate_textures(); + } + self.atlas.clear(); + self.needs_redraw = true; + return; + } else if self.failed_frame_count > 10 { panic!("Too many consecutive GPU errors. Last error: {error}"); } } else { @@ -1668,10 +1685,7 @@ impl WgpuRenderer { self.surface_configured = false; // Drop intermediate textures since they reference the old surface size. if let Some(res) = self.resources.as_mut() { - res.path_intermediate_texture = None; - res.path_intermediate_view = None; - res.path_msaa_texture = None; - res.path_msaa_view = None; + res.invalidate_intermediate_textures(); } } @@ -1721,10 +1735,7 @@ impl WgpuRenderer { res.surface = surface; // Invalidate intermediate textures — they'll be recreated lazily. - res.path_intermediate_texture = None; - res.path_intermediate_view = None; - res.path_msaa_texture = None; - res.path_msaa_view = None; + res.invalidate_intermediate_textures(); } self.surface_configured = true; @@ -1743,6 +1754,12 @@ impl WgpuRenderer { self.device_lost.load(std::sync::atomic::Ordering::SeqCst) } + /// Returns true if a redraw is needed because GPU state was cleared. + /// Calling this method clears the flag. + pub fn needs_redraw(&mut self) -> bool { + std::mem::take(&mut self.needs_redraw) + } + /// Recovers from a lost GPU device by recreating the renderer with a new context. /// /// Call this after detecting `device_lost()` returns true. diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs index 03acadb8607ed3..a6642dc7dc6292 100644 --- a/crates/gpui_windows/src/directx_atlas.rs +++ b/crates/gpui_windows/src/directx_atlas.rs @@ -80,7 +80,7 @@ impl PlatformAtlas for DirectXAtlas { ) -> anyhow::Result> { let mut lock = self.0.lock(); if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(tile.clone())) + Ok(Some(*tile)) } else { let Some((size, bytes)) = build()? else { return Ok(None); @@ -90,7 +90,7 @@ impl PlatformAtlas for DirectXAtlas { .ok_or_else(|| anyhow::anyhow!("failed to allocate"))?; let texture = lock.texture(tile.texture_id); texture.upload(&lock.device_context, tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile.clone()); + lock.tiles_by_key.insert(key.clone(), tile); Ok(Some(tile)) } } diff --git a/crates/grammars/src/javascript/outline.scm b/crates/grammars/src/javascript/outline.scm index 7b8e4b2d46c9b8..ce6d9e6bd9469c 100644 --- a/crates/grammars/src/javascript/outline.scm +++ b/crates/grammars/src/javascript/outline.scm @@ -144,20 +144,19 @@ "(" @context ")" @context)) @item) -; Object literal methods -(variable_declarator - value: (object - (method_definition - [ - "get" - "set" - "async" - "*" - ]* @context - name: (_) @name - parameters: (formal_parameters - "(" @context - ")" @context)) @item)) +; Object literal methods (including nested objects) +(object + (method_definition + [ + "get" + "set" + "async" + "*" + ]* @context + name: (_) @name + parameters: (formal_parameters + "(" @context + ")" @context)) @item) (public_field_definition [ diff --git a/crates/grammars/src/jsonc/config.toml b/crates/grammars/src/jsonc/config.toml index 3d9811a042e13e..0d6aa369d38ef8 100644 --- a/crates/grammars/src/jsonc/config.toml +++ b/crates/grammars/src/jsonc/config.toml @@ -1,6 +1,6 @@ name = "JSONC" grammar = "jsonc" -path_suffixes = ["jsonc", "bun.lock", "devcontainer.json", "pyrightconfig.json", "tsconfig.json", "luaurc", "swcrc", "babelrc", "eslintrc", "stylelintrc", "jshintrc"] +path_suffixes = ["jsonc", "bun.lock", "devcontainer.json", "pyrightconfig.json", "tsconfig.json", "renovate.json", "luaurc", "swcrc", "babelrc", "eslintrc", "stylelintrc", "jshintrc"] line_comments = ["// "] block_comment = { start = "/*", prefix = "", end = "*/", tab_size = 1 } autoclose_before = ",]}" diff --git a/crates/grammars/src/tsx/outline.scm b/crates/grammars/src/tsx/outline.scm index 37991965256a0d..2c08c30ad587ff 100644 --- a/crates/grammars/src/tsx/outline.scm +++ b/crates/grammars/src/tsx/outline.scm @@ -150,20 +150,19 @@ "(" @context ")" @context)) @item) -; Object literal methods -(variable_declarator - value: (object - (method_definition - [ - "get" - "set" - "async" - "*" - ]* @context - name: (_) @name - parameters: (formal_parameters - "(" @context - ")" @context)) @item)) +; Object literal methods (including nested objects) +(object + (method_definition + [ + "get" + "set" + "async" + "*" + ]* @context + name: (_) @name + parameters: (formal_parameters + "(" @context + ")" @context)) @item) (public_field_definition [ diff --git a/crates/grammars/src/typescript/outline.scm b/crates/grammars/src/typescript/outline.scm index 37991965256a0d..2c08c30ad587ff 100644 --- a/crates/grammars/src/typescript/outline.scm +++ b/crates/grammars/src/typescript/outline.scm @@ -150,20 +150,19 @@ "(" @context ")" @context)) @item) -; Object literal methods -(variable_declarator - value: (object - (method_definition - [ - "get" - "set" - "async" - "*" - ]* @context - name: (_) @name - parameters: (formal_parameters - "(" @context - ")" @context)) @item)) +; Object literal methods (including nested objects) +(object + (method_definition + [ + "get" + "set" + "async" + "*" + ]* @context + name: (_) @name + parameters: (formal_parameters + "(" @context + ")" @context)) @item) (public_field_definition [ diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs index 629042f745dbee..b0cd3c0b35c7ff 100644 --- a/crates/json_schema_store/src/json_schema_store.rs +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -352,6 +352,11 @@ async fn resolve_dynamic_schema( let icon_theme_names = icon_theme_names.as_slice(); let theme_names = theme_names.as_slice(); + let action_names = cx.all_action_names(); + let action_documentation = cx.action_documentation(); + let deprecations = cx.deprecated_actions_to_preferred_actions(); + let deprecation_messages = cx.action_deprecation_messages(); + let mut schema = settings::SettingsStore::json_schema(&settings::SettingsJsonSchemaParams { language_names, @@ -359,6 +364,10 @@ async fn resolve_dynamic_schema( theme_names, icon_theme_names, lsp_adapter_names: &lsp_adapter_names, + action_names, + action_documentation, + deprecations, + deprecation_messages, }); inject_feature_flags_schema(&mut schema); schema @@ -387,6 +396,10 @@ async fn resolve_dynamic_schema( font_names: &[], theme_names: &[], icon_theme_names: &[], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), }); inject_feature_flags_schema(&mut schema); schema diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 70d6f326a5fd0a..b1f5f7c6af7064 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -1665,7 +1665,7 @@ impl KeymapEditor { } })) }) - .anchor(gpui::Corner::TopRight) + .anchor(gpui::Anchor::TopRight) .offset(gpui::Point { x: px(0.0), y: px(2.0), @@ -2357,7 +2357,7 @@ impl Render for KeymapEditor { deferred( anchored() .position(*position) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 698efbfeed8363..3770e4ccf13a53 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -4251,7 +4251,10 @@ impl BufferSnapshot { items } - pub fn outline_range_containing(&self, range: Range) -> Option> { + pub fn outline_ranges_containing( + &self, + range: Range, + ) -> impl Iterator> + '_ { let range = range.to_offset(self); let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| { grammar.outline_config.as_ref().map(|c| &c.query) @@ -4262,35 +4265,41 @@ impl BufferSnapshot { .map(|g| g.outline_config.as_ref().unwrap()) .collect::>(); - while let Some(mat) = matches.peek() { - let config = &configs[mat.grammar_index]; - let containing_item_node = maybe!({ - let item_node = mat.captures.iter().find_map(|cap| { - if cap.index == config.item_capture_ix { - Some(cap.node) - } else { + std::iter::from_fn(move || { + while let Some(mat) = matches.peek() { + let config = &configs[mat.grammar_index]; + let containing_item_node = maybe!({ + let item_node = mat.captures.iter().find_map(|cap| { + if cap.index == config.item_capture_ix { + Some(cap.node) + } else { + None + } + })?; + + let item_byte_range = item_node.byte_range(); + if item_byte_range.end < range.start || item_byte_range.start > range.end { None + } else { + Some(item_node) } - })?; - - let item_byte_range = item_node.byte_range(); - if item_byte_range.end < range.start || item_byte_range.start > range.end { - None - } else { - Some(item_node) - } - }); + }); - if let Some(item_node) = containing_item_node { - return Some( + let range = containing_item_node.as_ref().map(|item_node| { Point::from_ts_point(item_node.start_position()) - ..Point::from_ts_point(item_node.end_position()), - ); + ..Point::from_ts_point(item_node.end_position()) + }); + matches.advance(); + if range.is_some() { + return range; + } } + None + }) + } - matches.advance(); - } - None + pub fn outline_range_containing(&self, range: Range) -> Option> { + self.outline_ranges_containing(range).next() } pub fn outline_items_containing( diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index c6167a70283583..505fbb4d7d2229 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -202,6 +202,17 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { )) }); +/// Commands that the client (editor) handles locally rather than forwarding +/// to the language server. Servers embed these in code lens and code action +/// responses when they want the editor to perform a well-known UI action. +#[derive(Debug, Clone)] +pub enum ClientCommand { + /// Open a location list (references panel / peek view). + ShowLocations, + /// Schedule a task from an LSP command's arguments. + ScheduleTask(task::TaskTemplate), +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Location { pub buffer: Entity, @@ -555,6 +566,14 @@ pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller { Ok(original) } + fn client_command( + &self, + _command_name: &str, + _arguments: &[serde_json::Value], + ) -> Option { + None + } + /// Method only implemented by the default JSON language server adapter. /// Used to provide dynamic reloading of the JSON schemas used to /// provide autocompletion and diagnostics in Zed setting and keybind diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 986654e6fcd455..adc874b666cc6c 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -6,7 +6,9 @@ use crate::{ use collections::{FxHashMap, HashMap, HashSet}; use ec4rs::{ Properties as EditorconfigProperties, - property::{FinalNewline, IndentSize, IndentStyle, MaxLineLen, TabWidth, TrimTrailingWs}, + property::{ + EndOfLine, FinalNewline, IndentSize, IndentStyle, MaxLineLen, TabWidth, TrimTrailingWs, + }, }; use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder}; use gpui::{App, Modifiers, SharedString}; @@ -16,8 +18,8 @@ use settings::{DocumentFoldingRanges, DocumentSymbols, IntoGpui, SemanticTokens} pub use settings::{ AutoIndentMode, CompletionSettingsContent, EditPredictionPromptFormat, EditPredictionProvider, EditPredictionsMode, FormatOnSave, Formatter, FormatterList, InlayHintKind, - LanguageSettingsContent, LspInsertMode, RewrapBehavior, ShowWhitespaceSetting, SoftWrap, - WordsCompletionMode, + LanguageSettingsContent, LineEndingSetting, LspInsertMode, RewrapBehavior, + ShowWhitespaceSetting, SoftWrap, WordsCompletionMode, }; use settings::{RegisterSetting, Settings, SettingsLocation, SettingsStore, merge_from::MergeFrom}; use shellexpand; @@ -82,6 +84,9 @@ pub struct LanguageSettings { /// Whether or not to ensure there's a single newline at the end of a buffer /// when saving it. pub ensure_final_newline_on_save: bool, + /// How line endings are initialized for new files and normalized during + /// format and save. + pub line_ending: LineEndingSetting, /// How to perform a buffer format. pub formatter: settings::FormatterList, /// Zed's Prettier integration settings. @@ -637,6 +642,11 @@ fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigPr TrimTrailingWs::Value(b) => b, }) .ok(); + let line_ending = cfg.get::().ok().and_then(|v| match v { + EndOfLine::Lf => Some(LineEndingSetting::EnforceLf), + EndOfLine::CrLf => Some(LineEndingSetting::EnforceCrlf), + EndOfLine::Cr => None, + }); settings .preferred_line_length @@ -649,6 +659,7 @@ fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigPr settings .ensure_final_newline_on_save .merge_from_option(ensure_final_newline_on_save.as_ref()); + settings.line_ending.merge_from_option(line_ending.as_ref()); } impl settings::Settings for AllLanguageSettings { @@ -682,6 +693,7 @@ impl settings::Settings for AllLanguageSettings { .remove_trailing_whitespace_on_save .unwrap(), ensure_final_newline_on_save: settings.ensure_final_newline_on_save.unwrap(), + line_ending: settings.line_ending.unwrap(), formatter: settings.formatter.unwrap(), prettier: PrettierSettings { allowed: prettier.allowed.unwrap(), diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index cee65c21e575e7..4466a3f2762b03 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -299,10 +299,6 @@ impl LanguageModel for FakeLanguageModel { 1000000 } - fn count_tokens(&self, _: LanguageModelRequest, _: &App) -> BoxFuture<'static, Result> { - futures::future::ready(Ok(0)).boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 60e8228fec52ff..4f7372777d7c1b 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -121,12 +121,6 @@ pub trait LanguageModel: Send + Sync { None } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result>; - fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index ef73864fe3e2f5..b28e6087e48149 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -118,10 +118,12 @@ impl LanguageModelImageExt for LanguageModelImage { // SAFETY: The base64 encoder should not produce non-UTF8. let source = unsafe { String::from_utf8_unchecked(base64_image) }; + let (final_width, final_height) = processed_image.dimensions(); + Some(LanguageModelImage { size: Some(ImageSize { - width: width as i32, - height: height as i32, + width: final_width as i32, + height: final_height as i32, }), source: source.into(), }) @@ -227,5 +229,15 @@ mod tests { w, h ); + + let size = lm_image.size.expect("ImageSize should be present"); + assert_eq!( + size.width, w as i32, + "ImageSize.width should match the encoded PNG width after downscaling" + ); + assert_eq!( + size.height, h as i32, + "ImageSize.height should match the encoded PNG height after downscaling" + ); } } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 60670114529b07..f5828fa28d7064 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -57,7 +57,6 @@ serde_json.workspace = true settings.workspace = true smol.workspace = true strum.workspace = true -tiktoken-rs.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } ui.workspace = true ui_input.workspace = true diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index bd29dbe08dbd16..d604ee432e487c 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -119,19 +119,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { ); }); - cx.subscribe( - ®istry, - |_registry, event: &language_model::Event, cx| match event { - language_model::Event::ProviderStateChanged(_) - | language_model::Event::AddedProvider(_) - | language_model::Event::RemovedProvider(_) => { - update_environment_fallback_model(cx); - } - _ => {} - }, - ) - .detach(); - let registry = registry.downgrade(); cx.observe_global::(move |cx| { let Some(registry) = registry.upgrade() else { diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 3d2b763a6e4a97..af5e53300a785b 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -22,10 +22,7 @@ use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -pub use anthropic::completion::{ - AnthropicEventMapper, count_anthropic_tokens_with_tiktoken, into_anthropic, - into_anthropic_count_tokens_request, -}; +pub use anthropic::completion::{AnthropicEventMapper, into_anthropic}; pub use settings::AnthropicAvailableModel as AvailableModel; const PROVIDER_ID: LanguageModelProviderId = ANTHROPIC_PROVIDER_ID; @@ -378,52 +375,6 @@ impl LanguageModel for AnthropicModel { Some(self.model.max_output_tokens()) } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - let http_client = self.http_client.clone(); - let model_id = self.model.request_id().to_string(); - let mode = self.model.mode(); - - let (api_key, api_url) = self.state.read_with(cx, |state, cx| { - let api_url = AnthropicLanguageModelProvider::api_url(cx); - ( - state.api_key_state.key(&api_url).map(|k| k.to_string()), - api_url.to_string(), - ) - }); - - let background = cx.background_executor().clone(); - async move { - // If no API key, fall back to tiktoken estimation - let Some(api_key) = api_key else { - return background - .spawn(async move { count_anthropic_tokens_with_tiktoken(request) }) - .await; - }; - - let count_request = - into_anthropic_count_tokens_request(request.clone(), model_id, mode); - - match anthropic::count_tokens(http_client.as_ref(), &api_url, &api_key, count_request) - .await - { - Ok(response) => Ok(response.input_tokens), - Err(err) => { - log::error!( - "Anthropic count_tokens API failed, falling back to tiktoken: {err:?}" - ); - background - .spawn(async move { count_anthropic_tokens_with_tiktoken(request) }) - .await - } - } - } - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 80c758769cd990..1069ad80fc0249 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -706,14 +706,6 @@ impl LanguageModel for BedrockModel { Some(self.model.max_output_tokens()) } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - get_bedrock_tokens(request, cx) - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -1151,68 +1143,6 @@ pub fn into_bedrock( }) } -// TODO: just call the ConverseOutput.usage() method: -// https://docs.rs/aws-sdk-bedrockruntime/latest/aws_sdk_bedrockruntime/operation/converse/struct.ConverseOutput.html#method.output -pub fn get_bedrock_tokens( - request: LanguageModelRequest, - cx: &App, -) -> BoxFuture<'static, Result> { - cx.background_executor() - .spawn(async move { - let messages = request.messages; - let mut tokens_from_images = 0; - let mut string_messages = Vec::with_capacity(messages.len()); - - for message in messages { - use language_model::MessageContent; - - let mut string_contents = String::new(); - - for content in message.content { - match content { - MessageContent::Text(text) | MessageContent::Thinking { text, .. } => { - string_contents.push_str(&text); - } - MessageContent::RedactedThinking(_) => {} - MessageContent::Image(image) => { - tokens_from_images += image.estimate_tokens(); - } - MessageContent::ToolUse(_tool_use) => { - // TODO: Estimate token usage from tool uses. - } - MessageContent::ToolResult(tool_result) => match tool_result.content { - LanguageModelToolResultContent::Text(text) => { - string_contents.push_str(&text); - } - LanguageModelToolResultContent::Image(image) => { - tokens_from_images += image.estimate_tokens(); - } - }, - } - } - - if !string_contents.is_empty() { - string_messages.push(tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(string_contents), - name: None, - function_call: None, - }); - } - } - - // Tiktoken doesn't yet support these models, so we manually use the - // same tokenizer as GPT-4. - tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages) - .map(|tokens| (tokens + tokens_from_images) as u64) - }) - .boxed() -} - pub fn map_to_language_model_completion_events( events: Pin>>>, ) -> impl Stream> { diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 9fef05e7555bc5..8a5059d63b0420 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,5 +1,6 @@ use ai_onboarding::YoungAccountBanner; use anyhow::Result; +use client::Status; use client::{Client, RefreshLlmTokenListener, UserStore, global_llm_token, zed_urls}; use cloud_api_client::LlmApiToken; use cloud_api_types::OrganizationId; @@ -249,11 +250,21 @@ impl LanguageModelProvider for CloudLanguageModelProvider { fn is_authenticated(&self, cx: &App) -> bool { let state = self.state.read(cx); - !state.is_signed_out(cx) + let status = *state.client.status().borrow(); + matches!(status, Status::Authenticated | Status::Connected { .. }) } - fn authenticate(&self, _cx: &mut App) -> Task> { - Task::ready(Ok(())) + fn authenticate(&self, cx: &mut App) -> Task> { + let mut status = self.state.read(cx).client.status(); + if !status.borrow().is_signing_in() { + return Task::ready(Ok(())); + } + cx.background_spawn(async move { + while status.borrow().is_signing_in() { + status.next().await; + } + Ok(()) + }) } fn configuration_view( @@ -275,6 +286,7 @@ impl LanguageModelProvider for CloudLanguageModelProvider { struct ZedAiConfiguration { is_connected: bool, plan: Option, + is_zed_model_provider_enabled: bool, eligible_for_trial: bool, account_too_young: bool, sign_in_callback: Arc, @@ -296,7 +308,11 @@ impl RenderOnce for ZedAiConfiguration { true, ), Some(Plan::ZedBusiness) => ( - "You have access to Zed's hosted models through your Organization.", + if self.is_zed_model_provider_enabled { + "You have access to Zed's hosted models through your organization." + } else { + "Zed's hosted models are disabled by your organization's configuration." + }, true, ), Some(Plan::ZedFree) | None => ( @@ -390,9 +406,14 @@ impl Render for ConfigurationView { let state = self.state.read(cx); let user_store = state.user_store.read(cx); + let is_zed_model_provider_enabled = user_store + .current_organization_configuration() + .map_or(true, |config| config.is_zed_model_provider_enabled); + ZedAiConfiguration { is_connected: !state.is_signed_out(cx), plan: user_store.plan(), + is_zed_model_provider_enabled, eligible_for_trial: user_store.trial_started_at().is_none(), account_too_young: user_store.account_too_young(), sign_in_callback: self.sign_in_callback.clone(), @@ -414,51 +435,110 @@ impl Component for ZedAiConfiguration { } fn preview(_window: &mut Window, _cx: &mut App) -> Option { - fn configuration( - is_connected: bool, + struct PreviewConfiguration { plan: Option, + is_connected: bool, + is_zed_model_provider_enabled: bool, eligible_for_trial: bool, - account_too_young: bool, - ) -> AnyElement { + } + + let configuration = |config: PreviewConfiguration| -> AnyElement { ZedAiConfiguration { - is_connected, - plan, - eligible_for_trial, - account_too_young, + is_connected: config.is_connected, + plan: config.plan, + is_zed_model_provider_enabled: config.is_zed_model_provider_enabled, + eligible_for_trial: config.eligible_for_trial, + account_too_young: false, sign_in_callback: Arc::new(|_, _| {}), } .into_any_element() - } + }; Some( v_flex() .p_4() .gap_4() .children(vec![ - single_example("Not connected", configuration(false, None, false, false)), + single_example( + "Not connected", + configuration(PreviewConfiguration { + plan: None, + is_connected: false, + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + }), + ), single_example( "Accept Terms of Service", - configuration(true, None, true, false), + configuration(PreviewConfiguration { + plan: None, + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), ), single_example( "No Plan - Not eligible for trial", - configuration(true, None, false, false), + configuration(PreviewConfiguration { + plan: None, + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + }), ), single_example( "No Plan - Eligible for trial", - configuration(true, None, true, false), + configuration(PreviewConfiguration { + plan: None, + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), ), single_example( "Free Plan", - configuration(true, Some(Plan::ZedFree), true, false), + configuration(PreviewConfiguration { + plan: Some(Plan::ZedFree), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), ), single_example( "Zed Pro Trial Plan", - configuration(true, Some(Plan::ZedProTrial), true, false), + configuration(PreviewConfiguration { + plan: Some(Plan::ZedProTrial), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), ), single_example( "Zed Pro Plan", - configuration(true, Some(Plan::ZedPro), true, false), + configuration(PreviewConfiguration { + plan: Some(Plan::ZedPro), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: true, + }), + ), + single_example( + "Business Plan - Zed models enabled", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedBusiness), + is_connected: true, + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + }), + ), + single_example( + "Business Plan - Zed models disabled", + configuration(PreviewConfiguration { + plan: Some(Plan::ZedBusiness), + is_connected: true, + is_zed_model_provider_enabled: false, + eligible_for_trial: false, + }), ), ]) .into_any_element(), diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 8b46c38f2524a0..ef9fbae1131a23 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -203,25 +203,6 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { } } -fn collect_tiktoken_messages( - request: LanguageModelRequest, -) -> Vec { - request - .messages - .into_iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(message.string_contents()), - name: None, - function_call: None, - }) - .collect::>() -} - pub struct CopilotChatLanguageModel { model: CopilotChatModel, request_limiter: RateLimiter, @@ -272,6 +253,7 @@ impl LanguageModel for CopilotChatLanguageModel { "low" => "Low".into(), "medium" => "Medium".into(), "high" => "High".into(), + "xhigh" => "Extra High".into(), _ => language_model::SharedString::from(level.clone()), }; LanguageModelEffortLevel { @@ -317,27 +299,6 @@ impl LanguageModel for CopilotChatLanguageModel { self.model.max_token_count() } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - let model = self.model.clone(); - cx.background_spawn(async move { - let messages = collect_tiktoken_messages(request); - // Copilot uses OpenAI tiktoken tokenizer for all it's model irrespective of the underlying provider(vendor). - let tokenizer_model = match model.tokenizer() { - Some("o200k_base") => "gpt-4o", - Some("cl100k_base") => "gpt-4", - _ => "gpt-4o", - }; - - tiktoken_rs::num_tokens_from_messages(tokenizer_model, &messages) - .map(|tokens| tokens as u64) - }) - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -382,7 +343,7 @@ impl LanguageModel for CopilotChatLanguageModel { AnthropicModelMode::Thinking { budget_tokens: None, } - } else if model.can_think() { + } else if model.supports_thinking() { AnthropicModelMode::Thinking { budget_tokens: compute_thinking_budget( model.min_thinking_budget(), @@ -412,11 +373,12 @@ impl LanguageModel for CopilotChatLanguageModel { } } - let anthropic_beta = if !model.supports_adaptive_thinking() && model.can_think() { - Some("interleaved-thinking-2025-05-14".to_string()) - } else { - None - }; + let anthropic_beta = + if !model.supports_adaptive_thinking() && model.supports_thinking() { + Some("interleaved-thinking-2025-05-14".to_string()) + } else { + None + }; let body = serde_json::to_string(&anthropic::StreamingRequest { base: anthropic_request, @@ -883,6 +845,7 @@ fn into_copilot_chat( ) -> Result { let temperature = request.temperature; let tool_choice = request.tool_choice; + let thinking_allowed = request.thinking_allowed; let mut request_messages: Vec = Vec::new(); for message in request.messages { @@ -1052,7 +1015,15 @@ fn into_copilot_chat( LanguageModelToolChoice::Any => ToolChoice::Required, LanguageModelToolChoice::None => ToolChoice::None, }), - thinking_budget: None, + thinking_budget: if thinking_allowed && model.supports_thinking() { + compute_thinking_budget( + model.min_thinking_budget(), + model.max_thinking_budget(), + model.max_output_tokens() as u32, + ) + } else { + None + }, }) } @@ -1104,7 +1075,7 @@ fn into_copilot_responses( stop: _, temperature, thinking_allowed, - thinking_effort: _, + thinking_effort, speed: _, } = request; @@ -1271,8 +1242,12 @@ fn into_copilot_responses( tools: converted_tools, tool_choice: mapped_tool_choice, reasoning: if thinking_allowed { + let effort = thinking_effort + .as_deref() + .and_then(|e| e.parse::().ok()) + .unwrap_or(copilot_responses::ReasoningEffort::Medium); Some(copilot_responses::ReasoningConfig { - effort: copilot_responses::ReasoningEffort::Medium, + effort, summary: Some(copilot_responses::ReasoningSummary::Detailed), }) } else { diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index f3dccd5cc1a2e1..dfc8521154e17a 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -293,32 +293,6 @@ impl LanguageModel for DeepSeekLanguageModel { self.model.max_output_tokens() } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - cx.background_spawn(async move { - let messages = request - .messages - .into_iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(message.string_contents()), - name: None, - function_call: None, - }) - .collect::>(); - - tiktoken_rs::num_tokens_from_messages("gpt-4", &messages).map(|tokens| tokens as u64) - }) - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 92278839c6ff51..87f2eeb26ab0f8 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result}; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -pub use google_ai::completion::{GoogleEventMapper, count_google_tokens, into_google}; +pub use google_ai::completion::{GoogleEventMapper, into_google}; use google_ai::{GenerateContentResponse, GoogleModelMode}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; @@ -327,38 +327,6 @@ impl LanguageModel for GoogleLanguageModel { self.model.max_output_tokens() } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - let model_id = self.model.request_id().to_string(); - let request = into_google(request, model_id, self.model.mode()); - let http_client = self.http_client.clone(); - let api_url = GoogleLanguageModelProvider::api_url(cx); - let api_key = self.state.read(cx).api_key_state.key(&api_url); - - async move { - let Some(api_key) = api_key else { - return Err(LanguageModelCompletionError::NoApiKey { - provider: PROVIDER_NAME, - } - .into()); - }; - let response = google_ai::count_tokens( - http_client.as_ref(), - &api_url, - &api_key, - google_ai::CountTokensRequest { - generate_content_request: request, - }, - ) - .await?; - Ok(response.total_tokens) - } - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index a541da8cd8092d..f035e765f0737d 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -505,22 +505,6 @@ impl LanguageModel for LmStudioLanguageModel { self.model.max_token_count() } - fn count_tokens( - &self, - request: LanguageModelRequest, - _cx: &App, - ) -> BoxFuture<'static, Result> { - // Endpoint for this is coming soon. In the meantime, hacky estimation - let token_count = request - .messages - .iter() - .map(|msg| msg.string_contents().split_whitespace().count()) - .sum::(); - - let estimated_tokens = (token_count as f64 * 0.75) as u64; - async move { Ok(estimated_tokens) }.boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index fdb0fb7b3a7f51..cce5448b9938e3 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -327,32 +327,6 @@ impl LanguageModel for MistralLanguageModel { self.model.max_output_tokens() } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - cx.background_spawn(async move { - let messages = request - .messages - .into_iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(message.string_contents()), - name: None, - function_call: None, - }) - .collect::>(); - - tiktoken_rs::num_tokens_from_messages("gpt-4", &messages).map(|tokens| tokens as u64) - }) - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 49c326683a225b..229b59e2bfded2 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -493,23 +493,6 @@ impl LanguageModel for OllamaLanguageModel { self.model.max_token_count() } - fn count_tokens( - &self, - request: LanguageModelRequest, - _cx: &App, - ) -> BoxFuture<'static, Result> { - // There is no endpoint for this _yet_ in Ollama - // see: https://github.com/ollama/ollama/issues/1716 and https://github.com/ollama/ollama/issues/3582 - let token_count = request - .messages - .iter() - .map(|msg| msg.string_contents().chars().count()) - .sum::() - / 4; - - async move { Ok(token_count as u64) }.boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 358a0ec5a6d517..f5ee65c8d85ff6 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -25,8 +25,7 @@ use ui_input::InputField; use util::ResultExt; pub use open_ai::completion::{ - OpenAiEventMapper, OpenAiResponseEventMapper, collect_tiktoken_messages, count_open_ai_tokens, - into_open_ai, into_open_ai_response, + OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, }; const PROVIDER_ID: LanguageModelProviderId = OPEN_AI_PROVIDER_ID; @@ -369,16 +368,6 @@ impl LanguageModel for OpenAiLanguageModel { self.model.max_output_tokens() } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - let model = self.model.clone(); - cx.background_spawn(async move { count_open_ai_tokens(request, model) }) - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -401,6 +390,7 @@ impl LanguageModel for OpenAiLanguageModel { self.model.supports_prompt_cache_key(), self.max_output_tokens(), self.model.reasoning_effort(), + false, ); let completions = self.stream_completion(request, cx); async move { diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 7a3126f8f33beb..5f7f6db3d36a45 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -360,27 +360,6 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { self.model.max_output_tokens } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - let max_token_count = self.max_token_count(); - cx.background_spawn(async move { - let messages = super::open_ai::collect_tiktoken_messages(request); - let model = if max_token_count >= 100_000 { - // If the max tokens is 100k or more, it is likely the o200k_base tokenizer from gpt4o - "gpt-4o" - } else { - // Otherwise fallback to gpt-4, since only cl100k_base and o200k_base are - // supported with this tiktoken method - "gpt-4" - }; - tiktoken_rs::num_tokens_from_messages(model, &messages).map(|tokens| tokens as u64) - }) - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -403,6 +382,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { self.model.capabilities.prompt_cache_key, self.max_output_tokens(), self.model.reasoning_effort, + self.model.capabilities.interleaved_reasoning, ); let completions = self.stream_completion(request, cx); async move { diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index fba3a6938aecf1..6562d9de085229 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -372,14 +372,6 @@ impl LanguageModel for OpenRouterLanguageModel { self.model.supports_images.unwrap_or(false) } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - count_open_router_tokens(request, self.model.clone(), cx) - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -741,32 +733,6 @@ struct RawToolCall { thought_signature: Option, } -pub fn count_open_router_tokens( - request: LanguageModelRequest, - _model: open_router::Model, - cx: &App, -) -> BoxFuture<'static, Result> { - cx.background_spawn(async move { - let messages = request - .messages - .into_iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(message.string_contents()), - name: None, - function_call: None, - }) - .collect::>(); - - tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages).map(|tokens| tokens as u64) - }) - .boxed() -} - struct ConfigurationView { api_key_editor: Entity, state: Entity, diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index aae3a552544ebf..4b0f8e5992a22c 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -8,7 +8,7 @@ use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var, + LanguageModelRequest, LanguageModelToolChoice, RateLimiter, env_var, }; use opencode::{ApiProtocol, OPENCODE_API_URL}; pub use settings::OpenCodeAvailableModel as AvailableModel; @@ -426,32 +426,6 @@ impl LanguageModel for OpenCodeLanguageModel { self.model.max_output_tokens() } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - cx.background_spawn(async move { - let messages = request - .messages - .into_iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(message.string_contents()), - name: None, - function_call: None, - }) - .collect::>(); - - tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages).map(|tokens| tokens as u64) - }) - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -490,6 +464,7 @@ impl LanguageModel for OpenCodeLanguageModel { false, self.model.max_output_tokens(), None, + false, ); let stream = self.stream_openai_chat(openai_request, cx); async move { diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index cedbc9c3cb9883..188cb6d0322d36 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -8,7 +8,7 @@ use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var, + LanguageModelRequest, LanguageModelToolChoice, RateLimiter, env_var, }; use open_ai::ResponseStreamEvent; pub use settings::VercelAvailableModel as AvailableModel; @@ -18,7 +18,7 @@ use strum::IntoEnumIterator; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use vercel::{Model, VERCEL_API_URL}; +use vercel::VERCEL_API_URL; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Vercel"); @@ -295,14 +295,6 @@ impl LanguageModel for VercelLanguageModel { self.model.max_output_tokens() } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - count_vercel_tokens(request, self.model.clone(), cx) - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -324,6 +316,7 @@ impl LanguageModel for VercelLanguageModel { self.model.supports_prompt_cache_key(), self.max_output_tokens(), None, + false, ); let completions = self.stream_completion(request, cx); async move { @@ -334,51 +327,6 @@ impl LanguageModel for VercelLanguageModel { } } -pub fn count_vercel_tokens( - request: LanguageModelRequest, - model: Model, - cx: &App, -) -> BoxFuture<'static, Result> { - cx.background_spawn(async move { - let messages = request - .messages - .into_iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(message.string_contents()), - name: None, - function_call: None, - }) - .collect::>(); - - match model { - Model::Custom { max_tokens, .. } => { - let model = if max_tokens >= 100_000 { - // If the max tokens is 100k or more, it is likely the o200k_base tokenizer from gpt4o - "gpt-4o" - } else { - // Otherwise fallback to gpt-4, since only cl100k_base and o200k_base are - // supported with this tiktoken method - "gpt-4" - }; - tiktoken_rs::num_tokens_from_messages(model, &messages) - } - // Map Vercel models to appropriate OpenAI models for token counting - // since Vercel uses OpenAI-compatible API - Model::VZeroOnePointFiveMedium => { - // Vercel v0 is similar to GPT-4o, so use gpt-4o for token counting - tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages) - } - } - .map(|tokens| tokens as u64) - }) - .boxed() -} - struct ConfigurationView { api_key_editor: Entity, state: Entity, diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 66767edd809531..789e8e35e8546a 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -422,24 +422,6 @@ impl LanguageModel for VercelAiGatewayLanguageModel { self.model.max_output_tokens } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - let max_token_count = self.max_token_count(); - cx.background_spawn(async move { - let messages = crate::provider::open_ai::collect_tiktoken_messages(request); - let model = if max_token_count >= 100_000 { - "gpt-4o" - } else { - "gpt-4" - }; - tiktoken_rs::num_tokens_from_messages(model, &messages).map(|tokens| tokens as u64) - }) - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -461,6 +443,7 @@ impl LanguageModel for VercelAiGatewayLanguageModel { self.model.capabilities.prompt_cache_key, self.max_output_tokens(), None, + false, ); let completions = self.stream_open_ai(request, cx); async move { @@ -591,6 +574,7 @@ async fn list_models( parallel_tool_calls, prompt_cache_key, chat_completions: true, + interleaved_reasoning: false, }, }); } diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index e95bc1ba72fabc..12f195417b5220 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -20,7 +20,6 @@ use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; use x_ai::XAI_API_URL; -pub use x_ai::completion::count_xai_tokens; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI"); @@ -316,16 +315,6 @@ impl LanguageModel for XAiLanguageModel { true } - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - let model = self.model.clone(); - cx.background_spawn(async move { count_xai_tokens(request, model) }) - .boxed() - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -347,6 +336,7 @@ impl LanguageModel for XAiLanguageModel { self.model.supports_prompt_cache_key(), self.max_output_tokens(), None, + false, ); let completions = self.stream_completion(request, cx); async move { diff --git a/crates/language_models_cloud/Cargo.toml b/crates/language_models_cloud/Cargo.toml index b08acc5ecd5c2a..de82fdfa627829 100644 --- a/crates/language_models_cloud/Cargo.toml +++ b/crates/language_models_cloud/Cargo.toml @@ -27,7 +27,6 @@ serde.workspace = true serde_json.workspace = true smol.workspace = true thiserror.workspace = true -x_ai = { workspace = true, features = ["schemars"] } [dev-dependencies] language_model = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 1300fd42e60f0b..adae72068c508e 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -3,9 +3,8 @@ use anyhow::{Context as _, Result, anyhow}; use cloud_llm_client::{ CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, CLIENT_SUPPORTS_X_AI_HEADER_NAME, CompletionBody, CompletionEvent, CompletionRequestStatus, - CountTokensBody, CountTokensResponse, EXPIRED_LLM_TOKEN_HEADER_NAME, ListModelsResponse, - OUTDATED_LLM_TOKEN_HEADER_NAME, SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, - ZED_VERSION_HEADER_NAME, + EXPIRED_LLM_TOKEN_HEADER_NAME, ListModelsResponse, OUTDATED_LLM_TOKEN_HEADER_NAME, + SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, ZED_VERSION_HEADER_NAME, }; use futures::{ AsyncBufReadExt, FutureExt, Stream, StreamExt, @@ -13,7 +12,7 @@ use futures::{ stream::{self, BoxStream}, }; use google_ai::GoogleModelMode; -use gpui::{App, AppContext, AsyncApp, Context, Task}; +use gpui::{AppContext, AsyncApp, Context, Task}; use http_client::http::{HeaderMap, HeaderValue}; use http_client::{ AsyncBody, HttpClient, HttpClientWithUrl, HttpRequestExt, Method, Response, StatusCode, @@ -40,15 +39,11 @@ use std::task::Poll; use std::time::Duration; use thiserror::Error; -use anthropic::completion::{ - AnthropicEventMapper, count_anthropic_tokens_with_tiktoken, into_anthropic, -}; +use anthropic::completion::{AnthropicEventMapper, into_anthropic}; use google_ai::completion::{GoogleEventMapper, into_google}; use open_ai::completion::{ - OpenAiEventMapper, OpenAiResponseEventMapper, count_open_ai_tokens, into_open_ai, - into_open_ai_response, + OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, }; -use x_ai::completion::count_xai_tokens; const PROVIDER_ID: LanguageModelProviderId = ZED_CLOUD_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = ZED_CLOUD_PROVIDER_NAME; @@ -374,85 +369,6 @@ impl LanguageModel for CloudLanguageModel BoxFuture<'static, Result> { - match self.model.provider { - cloud_llm_client::LanguageModelProvider::Anthropic => cx - .background_spawn(async move { count_anthropic_tokens_with_tiktoken(request) }) - .boxed(), - cloud_llm_client::LanguageModelProvider::OpenAi => { - let model = match open_ai::Model::from_id(&self.model.id.0) { - Ok(model) => model, - Err(err) => return async move { Err(anyhow!(err)) }.boxed(), - }; - cx.background_spawn(async move { count_open_ai_tokens(request, model) }) - .boxed() - } - cloud_llm_client::LanguageModelProvider::XAi => { - let model = match x_ai::Model::from_id(&self.model.id.0) { - Ok(model) => model, - Err(err) => return async move { Err(anyhow!(err)) }.boxed(), - }; - cx.background_spawn(async move { count_xai_tokens(request, model) }) - .boxed() - } - cloud_llm_client::LanguageModelProvider::Google => { - let http_client = self.http_client.clone(); - let token_provider = self.token_provider.clone(); - let model_id = self.model.id.to_string(); - let generate_content_request = - into_google(request, model_id.clone(), GoogleModelMode::Default); - let auth_context = token_provider.auth_context(cx); - async move { - let token = token_provider.acquire_token(auth_context).await?; - - let request_body = CountTokensBody { - provider: cloud_llm_client::LanguageModelProvider::Google, - model: model_id, - provider_request: serde_json::to_value(&google_ai::CountTokensRequest { - generate_content_request, - })?, - }; - let request = http_client::Request::builder() - .method(Method::POST) - .uri( - http_client - .build_zed_llm_url("/count_tokens", &[])? - .as_ref(), - ) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {token}")) - .body(serde_json::to_string(&request_body)?.into())?; - let mut response = http_client.send(request).await?; - let status = response.status(); - let headers = response.headers().clone(); - let mut response_body = String::new(); - response - .body_mut() - .read_to_string(&mut response_body) - .await?; - - if status.is_success() { - let response_body: CountTokensResponse = - serde_json::from_str(&response_body)?; - - Ok(response_body.tokens as u64) - } else { - Err(anyhow!(ApiError { - status, - body: response_body, - headers - })) - } - } - .boxed() - } - } - } - fn stream_completion( &self, request: LanguageModelRequest, @@ -600,6 +516,7 @@ impl LanguageModel for CloudLanguageModel) -> String { + fn query_suggestion( + &mut self, + ignore_settings: bool, + window: &mut Window, + cx: &mut Context, + ) -> String { self.editor - .update(cx, |e, cx| e.query_suggestion(window, cx)) + .update(cx, |e, cx| e.query_suggestion(ignore_settings, window, cx)) } fn activate_match( @@ -956,7 +961,7 @@ impl Render for LspLogToolbarItemView { let log_toolbar_view = cx.weak_entity(); let lsp_menu = PopoverMenu::new("LspLogView") - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .trigger( Button::new( "language_server_menu_header", @@ -1031,7 +1036,7 @@ impl Render for LspLogToolbarItemView { LogKind::ServerInfo => SERVER_INFO, }; PopoverMenu::new("LspViewSelector") - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .trigger( Button::new("language_server_menu_header", label).end_icon( Icon::new(IconName::ChevronDown) @@ -1123,7 +1128,7 @@ impl Render for LspLogToolbarItemView { let log_view = log_view.clone(); div().child( PopoverMenu::new("lsp-trace-level-menu") - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .trigger( Button::new( "language_server_trace_level_selector", @@ -1193,7 +1198,7 @@ impl Render for LspLogToolbarItemView { let log_view = log_view.clone(); div().child( PopoverMenu::new("lsp-log-level-menu") - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .trigger( Button::new( "language_server_log_level_selector", diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 73e9b162f4d6e7..f4d0ce5f4d4b55 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -225,6 +225,9 @@ impl LspAdapter for GoLspAdapter { "parameterNames": true, "rangeVariableTypes": true }, + "codelenses": { + "test": true + }, "semanticTokens": semantic_tokens_enabled }); @@ -438,6 +441,19 @@ impl LspAdapter for GoLspAdapter { )) } + fn client_command( + &self, + command_name: &str, + arguments: &[serde_json::Value], + ) -> Option { + if let "gopls.run_tests" = command_name { + let template = go_test_task_template(arguments.first()?)?; + Some(ClientCommand::ScheduleTask(template)) + } else { + None + } + } + fn diagnostic_message_to_markdown(&self, message: &str) -> Option { static REGEX: LazyLock = LazyLock::new(|| Regex::new(r"(?m)\n\s*").expect("Failed to create REGEX")); @@ -445,6 +461,74 @@ impl LspAdapter for GoLspAdapter { } } +fn json_string_array(value: &serde_json::Value, key: &str) -> Vec { + value + .get(key) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +fn go_test_task_template(arg: &serde_json::Value) -> Option { + let tests = json_string_array(arg, "Tests"); + let benchmarks = json_string_array(arg, "Benchmarks"); + if tests.is_empty() && benchmarks.is_empty() { + return None; + } + + let mut go_args = vec!["test".to_string(), "-test.fullpath=true".to_string()]; + + if tests.is_empty() { + go_args.push("-benchmem".to_string()); + go_args.push("-run=^$".to_string()); + } else { + go_args.push("-timeout".to_string()); + go_args.push("30s".to_string()); + go_args.push("-run".to_string()); + if tests.len() == 1 { + go_args.push(format!("^{}$", tests[0])); + } else { + go_args.push(format!("^({})$", tests.join("|"))); + } + } + + if !benchmarks.is_empty() { + go_args.push("-bench".to_string()); + if benchmarks.len() == 1 { + go_args.push(format!("^{}$", benchmarks[0])); + } else { + go_args.push(format!("^({})$", benchmarks.join("|"))); + } + } + + go_args.push(".".to_string()); + + let label = if !tests.is_empty() { + format!("go test {}", tests.join(", ")) + } else { + format!("go bench {}", benchmarks.join(", ")) + }; + + let cwd = arg + .get("URI") + .and_then(|v| v.as_str()) + .and_then(|uri| uri.strip_prefix("file://")) + .and_then(|path| std::path::Path::new(path).parent()) + .map(|p| p.to_string_lossy().into_owned()); + + Some(task::TaskTemplate { + label, + command: "go".to_string(), + args: go_args, + cwd, + ..task::TaskTemplate::default() + }) +} + fn parse_version_output(output: &Output) -> Result<&str> { let version_stdout = str::from_utf8(&output.stdout).context("version command produced invalid utf8 output")?; diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index d92c1392c128ed..56d1f30f3c4692 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -9,6 +9,7 @@ use http_client::github::{GitHubLspBinaryVersion, latest_github_release}; use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; pub use language::*; use lsp::{InitializeParams, LanguageServerBinary, LanguageServerBinaryOptions}; +use project::lsp_store::lsp_ext_command; use project::lsp_store::rust_analyzer_ext::CARGO_DIAGNOSTICS_SOURCE_NAME; use project::project_settings::ProjectSettings; use regex::Regex; @@ -608,21 +609,61 @@ impl LspAdapter for RustLspAdapter { .lsp .get(&SERVER_NAME) .is_some_and(|s| s.enable_lsp_tasks); - if enable_lsp_tasks { - let experimental = json!({ - "runnables": { - "kinds": [ "cargo", "shell" ], - }, - }); - if let Some(original_experimental) = &mut original.capabilities.experimental { - merge_json_value_into(experimental, original_experimental); - } else { - original.capabilities.experimental = Some(experimental); + + let mut experimental = json!({ + "commands": { + "commands": [ + "rust-analyzer.showReferences", + "rust-analyzer.gotoLocation", + "rust-analyzer.triggerParameterHints", + "rust-analyzer.rename", + ] } + }); + + if enable_lsp_tasks { + merge_json_value_into( + json!({ + "runnables": { + "kinds": [ "cargo", "shell" ], + }, + "commands": { + "commands": [ + "rust-analyzer.runSingle", + ] + } + }), + &mut experimental, + ); + } + + if let Some(original_experimental) = &mut original.capabilities.experimental { + merge_json_value_into(experimental, original_experimental); + } else { + original.capabilities.experimental = Some(experimental); } Ok(original) } + + fn client_command( + &self, + command_name: &str, + arguments: &[serde_json::Value], + ) -> Option { + match command_name { + "rust-analyzer.showReferences" => Some(ClientCommand::ShowLocations), + "rust-analyzer.runSingle" => { + let first_arg = arguments.first()?; + let runnable = + serde_json::from_value::(first_arg.clone()).ok()?; + let template = + lsp_ext_command::runnable_to_task_template(runnable.label, runnable.args); + Some(ClientCommand::ScheduleTask(template)) + } + _ => None, + } + } } impl LspInstaller for RustLspAdapter { diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index 714191ace093aa..a83e36270d2ca1 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -1087,6 +1087,213 @@ mod tests { } } + #[gpui::test] + async fn test_outline_with_nested_object_methods(cx: &mut TestAppContext) { + for language in [ + crate::language( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ), + crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()), + crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into()), + ] { + let text = r#" + // Reproduction from https://github.com/zed-industries/zed/issues/48711 + const a = { + p01: '01', + fn01: () => {}, + fn02() {}, + deep: { + subFn01: () => {}, + subFn02() {}, + subP03: '03', + deep2: { + subFn01: () => {}, + subFn02() {}, + subP03: '03', + }, + }, + }; + + // Edge case: async methods in nested objects + const b = { + async topAsync() {}, + nested: { async nestedAsync() {} }, + }; + + // Edge case: object literal in function argument + foo({ bar() {}, inner: { baz() {} } }); + "# + .unindent(); + + let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); + cx.run_until_parked(); + let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None)); + + let items: Vec<_> = outline + .items + .iter() + .map(|item| (item.text.as_str(), item.depth)) + .collect(); + + assert_eq!( + items, + &[ + ("const a", 0), + ("p01", 1), + ("fn01", 1), + ("fn02()", 1), + ("deep", 1), + ("subFn01", 2), + ("subFn02()", 2), + ("subP03", 2), + ("deep2", 2), + ("subFn01", 3), + ("subFn02()", 3), + ("subP03", 3), + ("const b", 0), + ("async topAsync()", 1), + ("nested", 1), + ("async nestedAsync()", 2), + ("bar()", 0), + ("inner", 0), + ("baz()", 1), + ] + ); + } + } + + #[gpui::test] + async fn test_outline_with_complex_nested_objects(cx: &mut TestAppContext) { + for language in [ + crate::language( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ), + crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()), + crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into()), + ] { + let text = r#" + const config = { + init() {}, + destroy() {}, + api: { + baseUrl: "x", + fetchData() {}, + async submitForm() {}, + errorHandler() {}, + }, + features: { + auth: { + login() {}, + logout() {}, + refreshToken() {}, + }, + cache: { + get() {}, + set() {}, + invalidate() {}, + }, + }, + watch: { + value() {}, + }, + computed: { + fullName() {}, + displayValue() {}, + }, + }; + + registerPlugin({ + name: "my-plugin", + setup() {}, + teardown() {}, + hooks: { + beforeMount() {}, + mounted() {}, + beforeUnmount() {}, + }, + }); + + export const store = { + state: {}, + mutations: { + setUser() {}, + clearUser() {}, + }, + actions: { + async fetchUser() {}, + logout() {}, + }, + getters: { + currentUser() {}, + isAuthenticated() {}, + }, + }; + + function registerPlugin(_plugin: unknown) {} + "# + .unindent(); + + let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); + cx.run_until_parked(); + let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None)); + + let items: Vec<_> = outline + .items + .iter() + .map(|item| (item.text.as_str(), item.depth)) + .collect(); + + assert_eq!( + items, + &[ + ("const config", 0), + ("init()", 1), + ("destroy()", 1), + ("api", 1), + ("baseUrl", 2), + ("fetchData()", 2), + ("async submitForm()", 2), + ("errorHandler()", 2), + ("features", 1), + ("auth", 2), + ("login()", 3), + ("logout()", 3), + ("refreshToken()", 3), + ("cache", 2), + ("get()", 3), + ("set()", 3), + ("invalidate()", 3), + ("watch", 1), + ("value()", 2), + ("computed", 1), + ("fullName()", 2), + ("displayValue()", 2), + ("name", 0), + ("setup()", 0), + ("teardown()", 0), + ("hooks", 0), + ("beforeMount()", 1), + ("mounted()", 1), + ("beforeUnmount()", 1), + ("const store", 0), + ("state", 1), + ("mutations", 1), + ("setUser()", 2), + ("clearUser()", 2), + ("actions", 1), + ("async fetchUser()", 2), + ("logout()", 2), + ("getters", 1), + ("currentUser()", 2), + ("isAuthenticated()", 2), + ("function registerPlugin( )", 0), + ] + ); + } + } + #[gpui::test] async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) { for language in [ diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 7ed170daa39135..23434b81a98589 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -269,6 +269,15 @@ impl LspAdapter for VtslsLspAdapter { "enabled": true } }, + "implementationsCodeLens": { + "enabled": true, + "showOnAllClassMethods": true, + "showOnInterfaceMethods": true + }, + "referencesCodeLens": { + "enabled": true, + "showOnAllFunctions": true + }, "tsserver": { "maxTsServerMemory": 8092 }, diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 5e1bb5729a81a9..c2d8e45f16ea6f 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -157,32 +157,6 @@ impl MarkdownStyle { rule_color: colors.border, block_quote_border_color: colors.border, code_block_overflow_x_scroll: true, - heading_level_styles: Some(HeadingLevelStyles { - h1: Some(TextStyleRefinement { - font_size: Some(rems(1.15).into()), - ..Default::default() - }), - h2: Some(TextStyleRefinement { - font_size: Some(rems(1.1).into()), - ..Default::default() - }), - h3: Some(TextStyleRefinement { - font_size: Some(rems(1.05).into()), - ..Default::default() - }), - h4: Some(TextStyleRefinement { - font_size: Some(rems(1.).into()), - ..Default::default() - }), - h5: Some(TextStyleRefinement { - font_size: Some(rems(0.95).into()), - ..Default::default() - }), - h6: Some(TextStyleRefinement { - font_size: Some(rems(0.875).into()), - ..Default::default() - }), - }), code_block: StyleRefinement { padding: EdgesRefinement { top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))), @@ -263,6 +237,7 @@ pub struct Markdown { mermaid_state: MermaidState, copied_code_blocks: HashSet, code_block_scroll_handles: BTreeMap, + context_menu_link: Option, context_menu_selected_text: Option, search_highlights: Vec>, active_search_highlight: Option, @@ -434,6 +409,7 @@ impl Markdown { mermaid_state: MermaidState::default(), copied_code_blocks: HashSet::default(), code_block_scroll_handles: BTreeMap::default(), + context_menu_link: None, context_menu_selected_text: None, search_highlights: Vec::new(), active_search_highlight: None, @@ -656,8 +632,16 @@ impl Markdown { cx.write_to_clipboard(ClipboardItem::new_string(text)); } - fn capture_selection_for_context_menu(&mut self) { + fn capture_for_context_menu(&mut self, link: Option) { self.context_menu_selected_text = self.selected_text(); + self.context_menu_link = link; + } + + /// Returns the URL of the link that was most recently right-clicked, if any. + /// This is set during a right-click mouse-down event and can be read by parent + /// views to include a "Copy Link" item in their context menus. + pub fn context_menu_link(&self) -> Option<&SharedString> { + self.context_menu_link.as_ref() } fn parse(&mut self, cx: &mut Context) { @@ -1114,7 +1098,7 @@ impl MarkdownElement { text_align_override: Option, ) { let align = text_align_override.unwrap_or(self.style.base_text_style.text_align); - let mut heading = div().mb_2(); + let mut heading = div().mt_4().mb_2(); heading = apply_heading_style(heading, level, self.style.heading_level_styles.as_ref()); heading = match align { @@ -1336,13 +1320,18 @@ impl MarkdownElement { self.on_mouse_event(window, cx, { let hitbox = hitbox.clone(); - move |markdown, event: &MouseDownEvent, phase, window, _| { + let rendered_text = rendered_text.clone(); + move |markdown, event: &MouseDownEvent, phase, window, _cx| { if phase.capture() && event.button == MouseButton::Right && hitbox.is_hovered(window) { - // Capture selected text so it survives until menu item is clicked - markdown.capture_selection_for_context_menu(); + let link = rendered_text + .source_index_for_position(event.position) + .ok() + .and_then(|ix| rendered_text.link_for_source_index(ix)) + .map(|link| link.destination_url.clone()); + markdown.capture_for_context_menu(link); } } }); @@ -1352,7 +1341,7 @@ impl MarkdownElement { let hitbox = hitbox.clone(); move |markdown, event: &MouseDownEvent, phase, window, cx| { if hitbox.is_hovered(window) { - if phase.bubble() { + if phase.bubble() && event.button != MouseButton::Right { let position_result = rendered_text.source_index_for_position(event.position); @@ -3516,6 +3505,90 @@ mod tests { assert!(!has_code_block(&Markdown::escape(diagnostic))); } + #[gpui::test] + fn test_link_detected_for_source_index(cx: &mut TestAppContext) { + let rendered = render_markdown("[Click here](https://example.com)", cx); + + assert_eq!(rendered.links.len(), 1); + assert_eq!(rendered.links[0].destination_url, "https://example.com"); + + // Source index 1 ('C' in "Click") is inside the link's source range + let link = rendered.link_for_source_index(1); + assert!(link.is_some()); + assert_eq!(link.unwrap().destination_url, "https://example.com"); + + // A source index past the end of the link range returns None + let past_end = rendered.links[0].source_range.end; + assert!(rendered.link_for_source_index(past_end).is_none()); + } + + #[gpui::test] + fn test_link_for_source_index_ignores_plain_text(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world", cx); + + assert!(rendered.links.is_empty()); + assert!(rendered.link_for_source_index(0).is_none()); + assert!(rendered.link_for_source_index(5).is_none()); + } + + #[gpui::test] + fn test_context_menu_link_initial_state(cx: &mut TestAppContext) { + struct TestWindow; + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = + cx.new(|cx| Markdown::new("Hello [world](https://example.com)".into(), None, None, cx)); + cx.run_until_parked(); + + cx.update(|_window, cx| { + assert!(markdown.read(cx).context_menu_link().is_none()); + }); + } + + #[gpui::test] + fn test_capture_for_context_menu(cx: &mut TestAppContext) { + struct TestWindow; + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| Markdown::new("text".into(), None, None, cx)); + cx.run_until_parked(); + + // Simulates right-clicking on a link + let url: SharedString = "https://example.com".into(); + markdown.update(cx, |md, _cx| { + md.capture_for_context_menu(Some(url.clone())); + }); + cx.update(|_window, cx| { + assert_eq!( + markdown + .read(cx) + .context_menu_link() + .map(SharedString::as_ref), + Some("https://example.com") + ); + }); + + // Simulates right-clicking on plain text — link is cleared + markdown.update(cx, |md, _cx| { + md.capture_for_context_menu(None); + }); + cx.update(|_window, cx| { + assert!(markdown.read(cx).context_menu_link().is_none()); + }); + } + #[track_caller] fn assert_mappings(rendered: &RenderedText, expected: Vec>) { assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch"); @@ -3551,4 +3624,33 @@ mod tests { } } } + + #[gpui::test] + fn test_heading_font_sizes_are_distinct(cx: &mut TestAppContext) { + let rendered = render_markdown("# H1\n\n## H2\n\n### H3\n\nBody text", cx); + + assert!( + rendered.lines.len() >= 4, + "expected at least 4 rendered lines, got {}", + rendered.lines.len() + ); + + let h1_line_height = rendered.lines[0].layout.line_height(); + let h2_line_height = rendered.lines[1].layout.line_height(); + let h3_line_height = rendered.lines[2].layout.line_height(); + let body_line_height = rendered.lines[3].layout.line_height(); + + assert!( + h1_line_height > h2_line_height, + "H1 line height ({h1_line_height:?}) should be greater than H2 ({h2_line_height:?})" + ); + assert!( + h2_line_height > h3_line_height, + "H2 line height ({h2_line_height:?}) should be greater than H3 ({h3_line_height:?})" + ); + assert!( + h3_line_height > body_line_height, + "H3 line height ({h3_line_height:?}) should be greater than body text ({body_line_height:?})" + ); + } } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index b97a559edf8760..2b7379b0b40ccf 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,3 +1,4 @@ +use std::any::TypeId; use std::cmp::min; use std::ops::Range; use std::path::{Path, PathBuf}; @@ -8,9 +9,9 @@ use anyhow::Result; use editor::scroll::Autoscroll; use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects}; use gpui::{ - App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, InteractiveElement, - IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString, - SharedUri, Subscription, Task, WeakEntity, Window, point, + App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, + InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, + ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, }; use language::LanguageRegistry; use markdown::{ @@ -20,7 +21,7 @@ use markdown::{ use project::search::SearchQuery; use settings::Settings; use theme_settings::ThemeSettings; -use ui::{WithScrollbar, prelude::*}; +use ui::{ContextMenu, WithScrollbar, prelude::*, right_click_menu}; use util::markdown::split_local_url_fragment; use util::normalize_path; use workspace::item::{Item, ItemBufferKind, ItemHandle}; @@ -824,6 +825,23 @@ impl EventEmitter for MarkdownPreviewView {} impl Item for MarkdownPreviewView { type Event = (); + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + self.active_editor + .as_ref() + .map(|state| state.editor.clone().into()) + } else { + None + } + } + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { Some(Icon::new(IconName::FileDoc)) } @@ -882,7 +900,27 @@ impl Render for MarkdownPreviewView { .overflow_y_scroll() .track_scroll(&self.scroll_handle) .p_4() - .child(self.render_markdown_element(window, cx)), + .child({ + let markdown_element = self.render_markdown_element(window, cx); + let markdown = self.markdown.clone(); + right_click_menu("markdown-preview-context-menu") + .trigger(move |_, _, _| markdown_element) + .menu(move |window, cx| { + let focus = window.focused(cx); + let context_menu_link = + markdown.read(cx).context_menu_link().cloned(); + ContextMenu::build(window, cx, move |menu, _, _cx| { + menu.when_some(focus, |menu, focus| menu.context(focus)) + .when_some(context_menu_link, |menu, url| { + menu.entry("Copy Link", None, move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + url.to_string(), + )); + }) + }) + }) + }) + }), ) .vertical_scrollbar_for(&self.scroll_handle, window, cx) } @@ -938,7 +976,12 @@ impl SearchableItem for MarkdownPreviewView { } } - fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context) -> String { + fn query_suggestion( + &mut self, + _ignore_settings: bool, + _window: &mut Window, + cx: &mut Context, + ) -> String { self.markdown.read(cx).selected_text().unwrap_or_default() } diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index ed9c6ff51513b7..8fa8907a16cc48 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -29,9 +29,16 @@ pub(crate) fn migrate_settings( if let Some(profiles) = root_object.get_mut("profiles") { if let Some(profiles_object) = profiles.as_object_mut() { - for (_profile_name, profile_settings) in profiles_object.iter_mut() { - if let Some(profile_map) = profile_settings.as_object_mut() { - migrate_one(profile_map)?; + for profile_value in profiles_object.values_mut() { + if let Some(profile_map) = profile_value.as_object_mut() { + if let Some(inner) = profile_map + .get_mut("settings") + .and_then(|v| v.as_object_mut()) + { + migrate_one(inner)?; + } else { + migrate_one(profile_map)?; + } } } } @@ -93,12 +100,23 @@ pub(crate) fn migrate_language_setting( if let Some(profiles_object) = profiles.as_object_mut() { let profile_names: Vec = profiles_object.keys().cloned().collect(); for profile_name in &profile_names { - if let Some(profile_settings) = profiles_object.get_mut(profile_name.as_str()) { - apply_to_value_and_languages( - profile_settings, - &["profiles", profile_name], - migrate_fn, - )?; + if let Some(profile_value) = profiles_object.get_mut(profile_name.as_str()) { + if let Some(settings_value) = profile_value + .as_object_mut() + .and_then(|m| m.get_mut("settings")) + { + apply_to_value_and_languages( + settings_value, + &["profiles", profile_name], + migrate_fn, + )?; + } else { + apply_to_value_and_languages( + profile_value, + &["profiles", profile_name], + migrate_fn, + )?; + } } } } @@ -334,3 +352,9 @@ pub(crate) mod m_2026_04_10 { pub(crate) use settings::rename_web_search_to_search_web; } + +pub(crate) mod m_2026_04_17 { + mod settings; + + pub(crate) use settings::promote_show_branch_icon_true_to_show_branch_status_icon; +} diff --git a/crates/migrator/src/migrations/m_2026_04_10/settings.rs b/crates/migrator/src/migrations/m_2026_04_10/settings.rs index 54305231494807..f72f5ee03d7c4f 100644 --- a/crates/migrator/src/migrations/m_2026_04_10/settings.rs +++ b/crates/migrator/src/migrations/m_2026_04_10/settings.rs @@ -5,27 +5,13 @@ use crate::migrations::migrate_settings; const AGENT_KEY: &str = "agent"; const PROFILES_KEY: &str = "profiles"; -const SETTINGS_KEY: &str = "settings"; const TOOL_PERMISSIONS_KEY: &str = "tool_permissions"; const TOOLS_KEY: &str = "tools"; const OLD_TOOL_NAME: &str = "web_search"; const NEW_TOOL_NAME: &str = "search_web"; pub fn rename_web_search_to_search_web(value: &mut Value) -> Result<()> { - migrate_settings(value, &mut migrate_one) -} - -fn migrate_one(object: &mut serde_json::Map) -> Result<()> { - migrate_agent_value(object)?; - - // Root-level profiles have a `settings` wrapper after m_2026_04_01, - // but `migrate_settings` calls us with the profile map directly, - // so we need to look inside `settings` too. - if let Some(settings) = object.get_mut(SETTINGS_KEY).and_then(|v| v.as_object_mut()) { - migrate_agent_value(settings)?; - } - - Ok(()) + migrate_settings(value, &mut migrate_agent_value) } fn migrate_agent_value(object: &mut serde_json::Map) -> Result<()> { diff --git a/crates/migrator/src/migrations/m_2026_04_17/settings.rs b/crates/migrator/src/migrations/m_2026_04_17/settings.rs new file mode 100644 index 00000000000000..50a37454cea04e --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_04_17/settings.rs @@ -0,0 +1,47 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::migrations::migrate_settings; + +const SETTINGS_KEY: &str = "settings"; +const TITLE_BAR_KEY: &str = "title_bar"; +const OLD_KEY: &str = "show_branch_icon"; +const NEW_KEY: &str = "show_branch_status_icon"; + +pub fn promote_show_branch_icon_true_to_show_branch_status_icon(value: &mut Value) -> Result<()> { + migrate_settings(value, &mut migrate_one) +} + +fn migrate_one(object: &mut serde_json::Map) -> Result<()> { + migrate_title_bar_value(object); + + if let Some(settings) = object + .get_mut(SETTINGS_KEY) + .and_then(|value| value.as_object_mut()) + { + migrate_title_bar_value(settings); + } + + Ok(()) +} + +fn migrate_title_bar_value(object: &mut serde_json::Map) { + let Some(title_bar) = object + .get_mut(TITLE_BAR_KEY) + .and_then(|value| value.as_object_mut()) + else { + return; + }; + + let Some(old_value) = title_bar.remove(OLD_KEY) else { + return; + }; + + if title_bar.contains_key(NEW_KEY) { + return; + } + + if old_value == Value::Bool(true) { + title_bar.insert(NEW_KEY.to_string(), Value::Bool(true)); + } +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 57815cf5a5b0ae..72cd7723ce69d5 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -250,6 +250,9 @@ pub fn migrate_settings(text: &str) -> Result> { MigrationType::Json(migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum), MigrationType::Json(migrations::m_2026_04_01::restructure_profiles_with_settings_key), MigrationType::Json(migrations::m_2026_04_10::rename_web_search_to_search_web), + MigrationType::Json( + migrations::m_2026_04_17::promote_show_branch_icon_true_to_show_branch_status_icon, + ), ]; run_migrations(text, migrations) } @@ -4930,6 +4933,98 @@ mod tests { ); } + #[test] + fn test_migration_helpers_handle_various_profile_forms() { + let setting = "a_setting"; + let old_value = "old_value"; + let new_value = "new_value"; + + fn language_setting_fn(value: &mut serde_json::Value, _: &[&str]) -> anyhow::Result<()> { + if let Some(obj) = value.as_object_mut() { + if let Some(v) = obj.get_mut("a_setting") { + *v = serde_json::json!("new_value"); + } + } + Ok(()) + } + + let mut settings_fn = |map: &mut serde_json::Map| { + if let Some(v) = map.get_mut(setting) { + *v = serde_json::json!(new_value); + } + Ok(()) + }; + + // Legacy form + let input = serde_json::json!({ + "profiles": { + "work": { + setting: old_value + } + } + }); + let expected = serde_json::json!({ + "profiles": { + "work": { + setting: new_value + } + } + }); + + let mut value = input.clone(); + migrations::migrate_settings(&mut value, &mut settings_fn).unwrap(); + assert_eq!(value, expected); + + let mut value = input; + migrations::migrate_language_setting(&mut value, language_setting_fn).unwrap(); + assert_eq!(value, expected); + + // Form after migration: `m_2026_04_01` + let input = serde_json::json!({ + "profiles": { + "work": { + "settings": { + setting: old_value + } + } + } + }); + let expected = serde_json::json!({ + "profiles": { + "work": { + "settings": { + setting: new_value + } + } + } + }); + + let mut value = input.clone(); + migrations::migrate_settings(&mut value, &mut settings_fn).unwrap(); + assert_eq!(value, expected); + + let mut value = input; + migrations::migrate_language_setting(&mut value, language_setting_fn).unwrap(); + assert_eq!(value, expected); + + // Base-only form after migration: `m_2026_04_01` (no settings to migrate) + let input = serde_json::json!({ + "profiles": { + "work": { + "base": "default" + } + } + }); + + let mut value = input.clone(); + migrations::migrate_settings(&mut value, &mut settings_fn).unwrap(); + assert_eq!(value, input); + + let mut value = input.clone(); + migrations::migrate_language_setting(&mut value, language_setting_fn).unwrap(); + assert_eq!(value, input); + } + #[test] fn test_rename_web_search_to_search_web_root_level_profile() { assert_migrate_with_migrations( @@ -5026,4 +5121,252 @@ mod tests { ), ); } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_at_root() { + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + "# + .unindent(), + Some( + &r#" + { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_drop_show_branch_icon_false_without_setting_status_icon() { + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_icon": false, + "show_branch_name": true + } + } + "# + .unindent(), + Some( + &r#" + { + "title_bar": { + "show_branch_name": true + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_platform_override() { + assert_migrate_settings( + &r#" + { + "macos": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + } + "# + .unindent(), + Some( + &r#" + { + "macos": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_release_override() { + assert_migrate_settings( + &r#" + { + "preview": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + } + "# + .unindent(), + Some( + &r#" + { + "preview": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_profiles() { + assert_migrate_settings( + &r#" + { + "profiles": { + "work": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + } + } + "# + .unindent(), + Some( + &r#" + { + "profiles": { + "work": { + "settings": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_across_all_scopes() { + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + }, + "macos": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + }, + "preview": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + }, + "profiles": { + "work": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + } + } + "# + .unindent(), + Some( + &r#" + { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + }, + "macos": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + }, + "preview": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + }, + "profiles": { + "work": { + "settings": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_no_change_when_already_migrated() + { + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + "# + .unindent(), + None, + ); + + // No title_bar key — should be unchanged + assert_migrate_settings(&r#"{ "theme": "One Dark" }"#.unindent(), None); + + // title_bar without show_branch_icon — should be unchanged + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_name": true + } + } + "# + .unindent(), + None, + ); + } } diff --git a/crates/multi_buffer/src/anchor.rs b/crates/multi_buffer/src/anchor.rs index b6a4dae1a27eb7..6a8e3b86af0075 100644 --- a/crates/multi_buffer/src/anchor.rs +++ b/crates/multi_buffer/src/anchor.rs @@ -103,15 +103,16 @@ impl ExcerptAnchor { } pub(crate) fn cmp(&self, other: &Self, snapshot: &MultiBufferSnapshot) -> Ordering { - let Some(self_path_key) = snapshot.path_keys_by_index.get(&self.path) else { + let Some(self_path_key) = snapshot.path_keys.get_index(self.path.0 as usize) else { panic!("anchor's path was never added to multibuffer") }; - let Some(other_path_key) = snapshot.path_keys_by_index.get(&other.path) else { + let Some(other_path_key) = snapshot.path_keys.get_index(other.path.0 as usize) else { panic!("anchor's path was never added to multibuffer") }; - if self_path_key.cmp(other_path_key) != Ordering::Equal { - return self_path_key.cmp(other_path_key); + match self_path_key.cmp(other_path_key) { + Ordering::Equal => (), + ordering => return ordering, } // in the case that you removed the buffer containing self, @@ -122,16 +123,19 @@ impl ExcerptAnchor { } // two anchors into the same buffer at the same path - // TODO(cole) buffer_for_path is slow let Some(buffer) = snapshot - .buffer_for_path(&self_path_key) - .filter(|buffer| buffer.remote_id() == self.text_anchor.buffer_id) + .buffers + .get(&self.text_anchor.buffer_id) + .filter(|buffer_state| buffer_state.path_key == *self_path_key) else { // buffer no longer exists at the original path (which may have been reused for a different buffer), // so no way to compare the anchors return Ordering::Equal; }; - let text_cmp = self.text_anchor().cmp(&other.text_anchor(), buffer); + // two anchors into the same buffer at the same path that still exists at that path in the multibuffer + let text_cmp = self + .text_anchor() + .cmp(&other.text_anchor(), &buffer.buffer_snapshot); if text_cmp != Ordering::Equal { return text_cmp; } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 724f659e44e815..59272d4b7c582f 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -15,7 +15,7 @@ use buffer_diff::{ DiffHunkStatus, DiffHunkStatusKind, }; use clock::ReplicaId; -use collections::{BTreeMap, Bound, HashMap, HashSet}; +use collections::{BTreeMap, Bound, HashMap, HashSet, IndexSet}; use gpui::{App, Context, Entity, EventEmitter}; use itertools::Itertools; use language::{ @@ -676,7 +676,7 @@ impl DiffState { #[derive(Clone)] struct BufferStateSnapshot { - path_key: PathKey, + pub(crate) path_key: PathKey, path_key_index: PathKeyIndex, buffer_snapshot: BufferSnapshot, } @@ -695,8 +695,7 @@ impl fmt::Debug for BufferStateSnapshot { pub struct MultiBufferSnapshot { excerpts: SumTree, buffers: TreeMap, - path_keys_by_index: TreeMap, - indices_by_path_key: TreeMap, + path_keys: Arc>, diffs: SumTree, diff_transforms: SumTree, non_text_state_update_count: usize, @@ -1802,8 +1801,7 @@ impl MultiBuffer { show_deleted_hunks: _, use_extended_diff_range: _, show_headers: _, - path_keys_by_index: _, - indices_by_path_key: _, + path_keys: _, buffers, } = self.snapshot.get_mut(); let start = ExcerptDimension(MultiBufferOffset::ZERO); @@ -2497,8 +2495,7 @@ impl MultiBuffer { excerpts, diffs: buffer_diff, buffers: buffer_snapshots, - path_keys_by_index: _, - indices_by_path_key: _, + path_keys: _, diff_transforms: _, non_text_state_update_count, edit_count, @@ -3582,7 +3579,7 @@ impl MultiBufferSnapshot { let Some(excerpt) = cursor.item() else { break; }; - if &excerpt.path_key != path { + if excerpt.path_key != *path { break; } let buffer_snapshot = excerpt.buffer_snapshot(self); @@ -3639,6 +3636,7 @@ impl MultiBufferSnapshot { result } + /// Callers should not provide a range where `end < start` pub fn range_to_buffer_ranges( &self, range: Range, @@ -3650,6 +3648,7 @@ impl MultiBufferSnapshot { let mut cursor = self.cursor::(); let start = range.start.to_offset(self); let end = range.end.to_offset(self); + let range_non_empty = end > start; cursor.seek(&start); let mut result: Vec<( @@ -3658,7 +3657,7 @@ impl MultiBufferSnapshot { ExcerptRange, )> = Vec::new(); while let Some(region) = cursor.region() { - if region.range.start >= end { + if region.range.start > end || (region.range.start == end && range_non_empty) { break; } if region.is_main_buffer { @@ -5264,12 +5263,11 @@ impl MultiBufferSnapshot { /// Creates a multibuffer anchor for the given buffer anchor, if it is contained in any excerpt. pub fn anchor_in_excerpt(&self, text_anchor: text::Anchor) -> Option { - for excerpt in { - let this = &self; + let excerpts = { let buffer_id = text_anchor.buffer_id; - if let Some(buffer_state) = this.buffers.get(&buffer_id) { + if let Some(buffer_state) = self.buffers.get(&buffer_id) { let path_key = buffer_state.path_key.clone(); - let mut cursor = this.excerpts.cursor::(()); + let mut cursor = self.excerpts.cursor::(()); cursor.seek_forward(&path_key, Bias::Left); Some(iter::from_fn(move || { let excerpt = cursor.item()?; @@ -5284,7 +5282,8 @@ impl MultiBufferSnapshot { } .into_iter() .flatten() - } { + }; + for excerpt in excerpts { let buffer_snapshot = excerpt.buffer_snapshot(self); if excerpt.range.contains(&text_anchor, &buffer_snapshot) { return Some(Anchor::in_buffer(excerpt.path_key_index, text_anchor)); @@ -6356,13 +6355,6 @@ impl MultiBufferSnapshot { )) } - pub fn buffer_for_path(&self, path: &PathKey) -> Option<&BufferSnapshot> { - let (_, _, excerpt) = self - .excerpts - .find::((), path, Bias::Left); - Some(excerpt?.buffer_snapshot(self)) - } - pub fn path_for_buffer(&self, buffer_id: BufferId) -> Option<&PathKey> { Some(&self.buffers.get(&buffer_id)?.path_key) } @@ -6378,9 +6370,7 @@ impl MultiBufferSnapshot { } fn first_excerpt_for_path(&self, path_key: &PathKey) -> Option<&Excerpt> { - let (_, _, first_excerpt) = - self.excerpts - .find::((), path_key, Bias::Left); + let (_, _, first_excerpt) = self.excerpts.find::((), path_key, Bias::Left); first_excerpt } @@ -6389,7 +6379,7 @@ impl MultiBufferSnapshot { } fn try_path_for_anchor(&self, anchor: ExcerptAnchor) -> Option<&PathKey> { - self.path_keys_by_index.get(&anchor.path) + self.path_keys.get_index(anchor.path.0 as usize) } pub fn path_for_anchor(&self, anchor: ExcerptAnchor) -> &PathKey { @@ -6831,7 +6821,7 @@ impl MultiBufferSnapshot { excerpt.path_key ); assert_eq!( - self.path_keys_by_index.get(&excerpt.path_key_index), + self.path_keys.get_index(excerpt.path_key_index.0 as usize), Some(&excerpt.path_key), "excerpt path key index does not match path key: {:#?}", excerpt.path_key, @@ -7470,6 +7460,23 @@ impl sum_tree::SeekTarget<'_, ExcerptSummary, ExcerptSummary> for AnchorSeekTarg } } +impl sum_tree::ContextLessSummary for PathKey { + fn zero() -> Self { + PathKey::min() + } + + fn add_summary(&mut self, summary: &Self) { + debug_assert!( + summary >= self, + "Path keys must be in ascending order: {:?} > {:?}", + summary, + self + ); + + *self = summary.clone(); + } +} + impl sum_tree::SeekTarget<'_, ExcerptSummary, ExcerptSummary> for PathKey { fn cmp( &self, diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index cebc9073e9d87a..a7f4b18cc42395 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -5864,6 +5864,48 @@ fn test_range_to_buffer_ranges(cx: &mut App) { assert_eq!(ranges_half_open_max[1].1, BufferOffset(0)..BufferOffset(0)); } +#[gpui::test] +fn test_range_to_buffer_ranges_zero_length_at_excerpt_boundary(cx: &mut App) { + let buffer_1 = cx.new(|cx| Buffer::local("aaa\nbbb", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("ccc\nddd", cx)); + + let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..Point::new(1, 3)], + 0, + cx, + ); + multibuffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(1, 3)], + 0, + cx, + ); + }); + + let snapshot = multibuffer.read(cx).snapshot(cx); + assert_eq!(snapshot.text(), "aaa\nbbb\nccc\nddd"); + + // This point is right at the start of the very first excerpt, so if we get + // a buffer range, we should get `0..0` + let excerpt_2_start = Point::new(2, 0); + let expected_ranges = vec![BufferOffset(0)..BufferOffset(0)]; + let ranges = snapshot + .range_to_buffer_ranges(excerpt_2_start..excerpt_2_start) + .into_iter() + .map(|tup| tup.1) + .collect_vec(); + + assert_eq!( + ranges, expected_ranges, + "Zero-length range at excerpt boundary should return the excerpt at that point" + ); +} + #[gpui::test] async fn test_buffer_range_to_excerpt_ranges(cx: &mut TestAppContext) { let base_text = indoc!( diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index 5c2123d0f9c1b0..3af1d5be32cf3a 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -58,12 +58,6 @@ impl PathKey { } impl MultiBuffer { - pub fn buffer_for_path(&self, path: &PathKey, cx: &App) -> Option> { - let snapshot = self.snapshot(cx); - let excerpt = snapshot.excerpts_for_path(path).next()?; - self.buffer(excerpt.context.start.buffer_id) - } - pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option { let snapshot = self.snapshot(cx); let excerpt = snapshot.excerpts_for_path(path).next()?; @@ -253,8 +247,8 @@ impl MultiBuffer { for (path_index, excerpt_anchors) in &buffers { let path = snapshot - .path_keys_by_index - .get(&path_index) + .path_keys + .get_index(path_index.0 as usize) .expect("anchor from wrong multibuffer"); let mut excerpt_anchors = excerpt_anchors.peekable(); @@ -353,18 +347,15 @@ impl MultiBuffer { pub(crate) fn get_or_create_path_key_index(&mut self, path_key: &PathKey) -> PathKeyIndex { let mut snapshot = self.snapshot.borrow_mut(); - if let Some(&existing) = snapshot.indices_by_path_key.get(path_key) { - return existing; + if let Some(existing) = snapshot.path_keys.get_index_of(path_key) { + return PathKeyIndex(existing as u64); } - let index = snapshot - .path_keys_by_index - .last() - .map(|(index, _)| PathKeyIndex(index.0 + 1)) - .unwrap_or(PathKeyIndex(0)); - snapshot.path_keys_by_index.insert(index, path_key.clone()); - snapshot.indices_by_path_key.insert(path_key.clone(), index); - index + PathKeyIndex( + Arc::make_mut(&mut snapshot.path_keys) + .insert_full(path_key.clone()) + .0 as u64, + ) } pub fn update_path_excerpts( diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 4a6a3c821cdb3a..ce9a383a606f4d 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -288,21 +288,20 @@ impl Render for Onboarding { window.focus_prev(cx); cx.notify(); })) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) .child( div() - .max_w(Rems(48.0)) + .id("page-content") .size_full() - .mx_auto() + .overflow_y_scroll() .child( v_flex() - .id("page-content") - .m_auto() - .p_12() - .size_full() - .max_w_full() .min_w_0() + .max_w(rems_from_px(780.)) + .w_full() + .mx_auto() + .p_12() .gap_6() - .overflow_y_scroll() .child( h_flex() .w_full() @@ -342,10 +341,9 @@ impl Render for Onboarding { }), ) .child(Divider::horizontal().color(ui::DividerColor::BorderVariant)) - .child(self.render_page(cx)) - .track_scroll(&self.scroll_handle), + .child(self.render_page(cx)), ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx), + .track_scroll(&self.scroll_handle), ) } } diff --git a/crates/open_ai/Cargo.toml b/crates/open_ai/Cargo.toml index 9a73e73196fa22..5083e97c560147 100644 --- a/crates/open_ai/Cargo.toml +++ b/crates/open_ai/Cargo.toml @@ -28,7 +28,6 @@ serde.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true -tiktoken-rs.workspace = true [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index 81fa79d35ee134..3068f57f582db1 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -18,7 +18,7 @@ use crate::responses::{ StreamEvent as ResponsesStreamEvent, }; use crate::{ - FunctionContent, FunctionDefinition, ImageUrl, MessagePart, Model, ReasoningEffort, + FunctionContent, FunctionDefinition, ImageUrl, MessagePart, ReasoningEffort, ResponseStreamEvent, ToolCall, ToolCallContent, }; @@ -29,13 +29,18 @@ pub fn into_open_ai( supports_prompt_cache_key: bool, max_output_tokens: Option, reasoning_effort: Option, + interleaved_reasoning: bool, ) -> crate::Request { let stream = !model_id.starts_with("o1-"); let mut messages = Vec::new(); + let mut current_reasoning: Option = None; for message in request.messages { for content in message.content { match content { + MessageContent::Thinking { text, .. } if interleaved_reasoning => { + current_reasoning.get_or_insert_default().push_str(&text); + } MessageContent::Text(text) | MessageContent::Thinking { text, .. } => { let should_add = if message.role == Role::User { // Including whitespace-only user messages can cause error with OpenAI compatible APIs @@ -50,6 +55,15 @@ pub fn into_open_ai( message.role, &mut messages, ); + if let Some(reasoning) = current_reasoning.take() { + if let Some(crate::RequestMessage::Assistant { + reasoning_content, + .. + }) = messages.last_mut() + { + *reasoning_content = Some(reasoning); + } + } } } MessageContent::RedactedThinking(_) => {} @@ -85,6 +99,7 @@ pub fn into_open_ai( messages.push(crate::RequestMessage::Assistant { content: None, tool_calls: vec![tool_call], + reasoning_content: current_reasoning.take(), }); } } @@ -362,6 +377,7 @@ fn add_message_content_part( Role::Assistant => crate::RequestMessage::Assistant { content: Some(crate::MessageContent::from(vec![new_part])), tool_calls: Vec::new(), + reasoning_content: None, }, Role::System => crate::RequestMessage::System { content: crate::MessageContent::from(vec![new_part]), @@ -802,68 +818,6 @@ fn token_usage_from_response_usage(usage: &ResponsesUsage) -> TokenUsage { } } -pub fn collect_tiktoken_messages( - request: LanguageModelRequest, -) -> Vec { - request - .messages - .into_iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(message.string_contents()), - name: None, - function_call: None, - }) - .collect::>() -} - -/// Count tokens for an OpenAI model. This is synchronous; callers should spawn -/// it on a background thread if needed. -pub fn count_open_ai_tokens(request: LanguageModelRequest, model: Model) -> Result { - let messages = collect_tiktoken_messages(request); - match model { - Model::Custom { max_tokens, .. } => { - let model = if max_tokens >= 100_000 { - // If the max tokens is 100k or more, it likely uses the o200k_base tokenizer - "gpt-4o" - } else { - // Otherwise fallback to gpt-4, since only cl100k_base and o200k_base are - // supported with this tiktoken method - "gpt-4" - }; - tiktoken_rs::num_tokens_from_messages(model, &messages) - } - // Currently supported by tiktoken_rs - // Sometimes tiktoken-rs is behind on model support. If that is the case, make a new branch - // arm with an override. We enumerate all supported models here so that we can check if new - // models are supported yet or not. - Model::ThreePointFiveTurbo - | Model::Four - | Model::FourTurbo - | Model::FourOmniMini - | Model::FourPointOneNano - | Model::O1 - | Model::O3 - | Model::O3Mini - | Model::Five - | Model::FiveCodex - | Model::FiveMini - | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages), - // GPT-5.1, 5.2, 5.2-codex, 5.3-codex, 5.4, and 5.4-pro don't have dedicated tiktoken support; use gpt-5 tokenizer - Model::FivePointOne - | Model::FivePointTwo - | Model::FivePointTwoCodex - | Model::FivePointThreeCodex - | Model::FivePointFour - | Model::FivePointFourPro => tiktoken_rs::num_tokens_from_messages("gpt-5", &messages), - } - .map(|tokens| tokens as u64) -} - #[cfg(test)] mod tests { use crate::responses::{ @@ -913,34 +867,6 @@ mod tests { }) } - #[test] - fn tiktoken_rs_support() { - let request = LanguageModelRequest { - thread_id: None, - prompt_id: None, - intent: None, - messages: vec![LanguageModelRequestMessage { - role: Role::User, - content: vec![MessageContent::Text("message".into())], - cache: false, - reasoning_details: None, - }], - tools: vec![], - tool_choice: None, - stop: vec![], - temperature: None, - thinking_allowed: true, - thinking_effort: None, - speed: None, - }; - - // Validate that all models are supported by tiktoken-rs - for model in ::iter() { - let count = count_open_ai_tokens(request.clone(), model).unwrap(); - assert!(count > 0); - } - } - #[test] fn responses_stream_maps_text_and_usage() { let events = vec![ @@ -1690,4 +1616,97 @@ mod tests { "OutputItemDone reasoning should not produce Thinking events" ); } + + #[test] + fn into_open_ai_interleaved_reasoning() { + let tool_use_id = LanguageModelToolUseId::from("call-1"); + let tool_input = json!({"query": "foo"}); + let tool_arguments = serde_json::to_string(&tool_input).unwrap(); + let tool_use = LanguageModelToolUse { + id: tool_use_id.clone(), + name: Arc::from("search"), + raw_input: tool_arguments.clone(), + input: tool_input, + is_input_complete: true, + thought_signature: None, + }; + let tool_result = LanguageModelToolResult { + tool_use_id: tool_use_id, + tool_name: Arc::from("search"), + is_error: false, + content: LanguageModelToolResultContent::Text(Arc::from("result")), + output: None, + }; + let request = LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: None, + messages: vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("search for something".into())], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::Thinking { + text: "I should search".into(), + signature: None, + }, + MessageContent::Text("Searching now.".into()), + MessageContent::ToolUse(tool_use), + ], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::ToolResult(tool_result)], + cache: false, + reasoning_details: None, + }, + ], + tools: vec![], + tool_choice: None, + stop: vec![], + temperature: None, + thinking_allowed: true, + thinking_effort: None, + speed: None, + }; + + let result = into_open_ai(request.clone(), "model", false, false, None, None, true); + assert_eq!( + serde_json::to_value(&result).unwrap()["messages"], + json!([ + {"role": "user", "content": "search for something"}, + { + "role": "assistant", + "content": "Searching now.", + "tool_calls": [{"id": "call-1", "type": "function", "function": {"name": "search", "arguments": tool_arguments}}], + "reasoning_content": "I should search" + }, + {"role": "tool", "content": "result", "tool_call_id": "call-1"} + ]) + ); + + let result = into_open_ai(request, "model", false, false, None, None, false); + assert_eq!( + serde_json::to_value(&result).unwrap()["messages"], + json!([ + {"role": "user", "content": "search for something"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "I should search"}, + {"type": "text", "text": "Searching now."} + ], + "tool_calls": [{"id": "call-1", "type": "function", "function": {"name": "search", "arguments": tool_arguments}}] + }, + {"role": "tool", "content": "result", "tool_call_id": "call-1"} + ]) + ); + } } diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 256b78f8a2ec92..0109efbe293ee0 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -366,6 +366,8 @@ pub enum RequestMessage { content: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_content: Option, }, User { content: MessageContent, diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index fa23b805cd4846..4a30f2ff8743c1 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -51,7 +51,8 @@ use theme::SyntaxTheme; use theme_settings::ThemeSettings; use ui::{ ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors, - IndentGuideLayout, ListItem, ScrollAxes, Scrollbars, Tab, Tooltip, WithScrollbar, prelude::*, + IndentGuideLayout, KeyBinding, ListItem, ScrollAxes, Scrollbars, Tab, Tooltip, WithScrollbar, + prelude::*, }; use util::{RangeExt, ResultExt, TryFutureExt, debug_panic, rel_path::RelPath}; use workspace::{ @@ -4561,18 +4562,30 @@ impl OutlinePanel { .child(Label::new(query)), ) }) - .child(h_flex().justify_center().child({ - let keystroke = match self.position(window, cx) { - DockPosition::Left => window.keystroke_text_for(&workspace::ToggleLeftDock), - DockPosition::Bottom => { - window.keystroke_text_for(&workspace::ToggleBottomDock) - } - DockPosition::Right => { - window.keystroke_text_for(&workspace::ToggleRightDock) - } - }; - Label::new(format!("Toggle Panel With {keystroke}")).color(Color::Muted) - })) + .child( + h_flex() + .gap_1() + .justify_center() + .child(Label::new("Toggle Panel With").color(Color::Muted)) + .child({ + let key_binding = match self.position(window, cx) { + DockPosition::Left => { + KeyBinding::for_action(&workspace::ToggleLeftDock, cx) + .into_any_element() + } + DockPosition::Bottom => { + KeyBinding::for_action(&workspace::ToggleBottomDock, cx) + .into_any_element() + } + DockPosition::Right => { + KeyBinding::for_action(&workspace::ToggleRightDock, cx) + .into_any_element() + } + }; + + key_binding + }), + ) } else { let list_contents = { let items_len = self.cached_entries.len(); @@ -4712,7 +4725,7 @@ impl OutlinePanel { deferred( anchored() .position(*position) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) @@ -4722,10 +4735,10 @@ impl OutlinePanel { } fn render_filter_footer(&mut self, pinned: bool, cx: &mut Context) -> Div { - let (icon, icon_tooltip) = if pinned { - (IconName::Unpin, "Unpin Outline") + let (pin_button_id, icon, icon_tooltip) = if pinned { + ("unpin_button", IconName::Unpin, "Unpin Outline") } else { - (IconName::Pin, "Pin Active Outline") + ("pin_button", IconName::Pin, "Pin Active Outline") }; let has_query = self.query(cx).is_some(); @@ -4763,7 +4776,7 @@ impl OutlinePanel { ) }) .child( - IconButton::new("pin_button", icon) + IconButton::new(pin_button_id, icon) .tooltip(Tooltip::text(icon_tooltip)) .shape(IconButtonShape::Square) .on_click(cx.listener(|outline_panel, _, window, cx| { diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index c9b9c756217281..7afab7e81692e2 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -4,6 +4,7 @@ use std::env; use std::path::{Path, PathBuf}; use std::sync::{LazyLock, OnceLock}; +use util::paths::SanitizedPath; pub use util::paths::home_dir; use util::rel_path::RelPath; @@ -71,8 +72,14 @@ pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf { CUSTOM_DATA_DIR.get_or_init(|| { let path = PathBuf::from(dir); std::fs::create_dir_all(&path).expect("failed to create custom data directory"); - path.canonicalize() - .expect("failed to canonicalize custom data directory's path to an absolute path") + let canonicalized = path + .canonicalize() + .expect("failed to canonicalize custom data directory's path to an absolute path"); + // On Windows, `canonicalize` produces extended-length paths prefixed + // with `\\?\`. Strip that prefix so downstream consumers (e.g. + // Node.js language servers) that receive derived paths as arguments + // don't choke on the verbatim syntax. + SanitizedPath::new(&canonicalized).as_path().to_path_buf() }) } diff --git a/crates/picker/src/popover_menu.rs b/crates/picker/src/popover_menu.rs index 42eedb2492149a..b534f8f2ba3818 100644 --- a/crates/picker/src/popover_menu.rs +++ b/crates/picker/src/popover_menu.rs @@ -1,5 +1,5 @@ use gpui::{ - AnyView, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Point, + Anchor, AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Point, Subscription, }; use ui::{ @@ -18,7 +18,7 @@ where trigger: T, tooltip: TT, handle: Option>>, - anchor: Corner, + anchor: Anchor, offset: Option>, _subscriptions: Vec, } @@ -33,7 +33,7 @@ where picker: Entity>, trigger: T, tooltip: TT, - anchor: Corner, + anchor: Anchor, cx: &mut App, ) -> Self { Self { diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index d2f05a119a1883..b5828d60689d6a 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -11,7 +11,8 @@ use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, }; use language::{ - Buffer, BufferEvent, Capability, DiskState, File as _, Language, Operation, + Buffer, BufferEvent, Capability, DiskState, File as _, Language, LineEnding, Operation, + language_settings::{AllLanguageSettings, LineEndingSetting}, proto::{ deserialize_line_ending, deserialize_version, serialize_line_ending, serialize_version, split_operations, @@ -663,7 +664,7 @@ impl LocalBufferStore { Err(error) if is_not_found_error(&error) => cx.new(|cx| { let buffer_id = BufferId::from(cx.entity_id().as_non_zero_u64()); let text_buffer = text::Buffer::new(ReplicaId::LOCAL, buffer_id, ""); - Buffer::build( + let mut buffer = Buffer::build( text_buffer, Some(Arc::new(File { worktree, @@ -674,7 +675,9 @@ impl LocalBufferStore { is_private: false, })), Capability::ReadWrite, - ) + ); + apply_initial_line_ending(&mut buffer, cx); + buffer }), Err(e) => return Err(e), }; @@ -724,8 +727,10 @@ impl LocalBufferStore { ) -> Task>> { cx.spawn(async move |buffer_store, cx| { let buffer = cx.new(|cx| { - Buffer::local("", cx) - .with_language(language.unwrap_or_else(|| language::PLAIN_TEXT.clone()), cx) + let mut buffer = Buffer::local("", cx) + .with_language(language.unwrap_or_else(|| language::PLAIN_TEXT.clone()), cx); + apply_initial_line_ending(&mut buffer, cx); + buffer }); buffer_store.update(cx, |buffer_store, cx| { buffer_store.add_buffer(buffer.clone(), cx).log_err(); @@ -1628,8 +1633,10 @@ impl BufferStore { cx: &mut Context, ) -> Entity { let buffer = cx.new(|cx| { - Buffer::local(text, cx) - .with_language(language.unwrap_or_else(|| language::PLAIN_TEXT.clone()), cx) + let mut buffer = Buffer::local(text, cx) + .with_language(language.unwrap_or_else(|| language::PLAIN_TEXT.clone()), cx); + apply_initial_line_ending(&mut buffer, cx); + buffer }); self.add_buffer(buffer.clone(), cx).log_err(); @@ -1799,3 +1806,24 @@ fn is_not_found_error(error: &anyhow::Error) -> bool { .downcast_ref::() .is_some_and(|err| err.kind() == io::ErrorKind::NotFound) } + +fn apply_initial_line_ending(buffer: &mut Buffer, cx: &mut Context) { + // Only applies for empty rope or a single line with no trailing newline. + if buffer.max_point().row > 0 { + return; + } + let location = buffer.file().map(|file| settings::SettingsLocation { + worktree_id: file.worktree_id(cx), + path: file.path().as_ref(), + }); + let language = buffer.language().map(|l| l.name()); + let settings = AllLanguageSettings::get(location, cx).language(location, language.as_ref(), cx); + let desired = match settings.line_ending { + LineEndingSetting::Detect => return, + LineEndingSetting::PreferLf | LineEndingSetting::EnforceLf => LineEnding::Unix, + LineEndingSetting::PreferCrlf | LineEndingSetting::EnforceCrlf => LineEnding::Windows, + }; + if buffer.line_ending() != desired { + buffer.set_line_ending(desired, cx); + } +} diff --git a/crates/project/src/environment.rs b/crates/project/src/environment.rs index 8156e172b91796..c7b87ea8e35d07 100644 --- a/crates/project/src/environment.rs +++ b/crates/project/src/environment.rs @@ -5,8 +5,7 @@ use remote::RemoteClient; use rpc::proto::{self, REMOTE_SERVER_PROJECT_ID}; use std::{collections::VecDeque, path::Path, sync::Arc}; use task::{Shell, shell_to_proto}; -use terminal::terminal_settings::TerminalSettings; -use util::{ResultExt, command::new_command, rel_path::RelPath}; +use util::{ResultExt, command::new_command}; use worktree::Worktree; use collections::HashMap; @@ -134,19 +133,7 @@ impl ProjectEnvironment { None if self.is_remote_project => { Some(self.local_directory_environment(&Shell::System, abs_path, cx)) } - None => Some({ - let shell = TerminalSettings::get( - Some(settings::SettingsLocation { - worktree_id: worktree.id(), - path: RelPath::empty(), - }), - cx, - ) - .shell - .clone(); - - self.local_directory_environment(&shell, abs_path, cx) - }), + None => Some(self.local_directory_environment(&Shell::System, abs_path, cx)), } .unwrap_or_else(|| Task::ready(None).shared()) } @@ -175,21 +162,7 @@ impl ProjectEnvironment { worktree_store.find_worktree(&abs_path, cx) }) .ok() - .map(|worktree| { - let shell = terminal::terminal_settings::TerminalSettings::get( - worktree - .as_ref() - .map(|(worktree, path)| settings::SettingsLocation { - worktree_id: worktree.read(cx).id(), - path: &path, - }), - cx, - ) - .shell - .clone(); - - self.local_directory_environment(&shell, abs_path, cx) - }), + .map(|_| self.local_directory_environment(&Shell::System, abs_path, cx)), } .unwrap_or_else(|| Task::ready(None).shared()) } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index d35a13e7df3e55..0d4051be7e52de 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -4024,10 +4024,12 @@ impl RepositorySnapshot { } fn repo_path_to_abs_path(&self, repo_path: &RepoPath) -> PathBuf { - self.path_style - .join(&self.work_directory_abs_path, repo_path.as_std_path()) - .unwrap() - .into() + let repo_path = repo_path.display(self.path_style); + PathBuf::from( + self.path_style + .join(&self.work_directory_abs_path, repo_path.as_ref()) + .unwrap(), + ) } #[inline] diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 0ba9787d2e4144..0b6dcfa0078588 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -902,6 +902,7 @@ fn create_gpui_image(content: Vec) -> anyhow::Result> { image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, image::ImageFormat::Ico => gpui::ImageFormat::Ico, + image::ImageFormat::Pnm => gpui::ImageFormat::Pnm, format => anyhow::bail!("Image format {format:?} not supported"), }, content, diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index d4a4f9b0496841..e22f478eb9b95e 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -33,6 +33,7 @@ use lsp::{ OneOf, RenameOptions, ServerCapabilities, }; use serde_json::Value; + use signature_help::{lsp_to_proto_signature, proto_to_lsp_signature}; use std::{ cmp::Reverse, collections::hash_map, mem, ops::Range, path::Path, str::FromStr, sync::Arc, @@ -3851,45 +3852,27 @@ impl LspCommand for GetCodeLens { async fn response_from_lsp( self, message: Option>, - lsp_store: Entity, + _lsp_store: Entity, buffer: Entity, server_id: LanguageServerId, cx: AsyncApp, ) -> anyhow::Result> { let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot()); - let language_server = cx.update(|cx| { - lsp_store - .read(cx) - .language_server_for_id(server_id) - .with_context(|| { - format!("Missing the language server that just returned a response {server_id}") - }) - })?; - let server_capabilities = language_server.capabilities(); - let available_commands = server_capabilities - .execute_command_provider - .as_ref() - .map(|options| options.commands.as_slice()) - .unwrap_or_default(); - Ok(message - .unwrap_or_default() + let code_lenses = message.unwrap_or_default(); + + Ok(code_lenses .into_iter() - .filter(|code_lens| { - code_lens - .command - .as_ref() - .is_none_or(|command| available_commands.contains(&command.command)) - }) .map(|code_lens| { let code_lens_range = range_from_lsp(code_lens.range); let start = snapshot.clip_point_utf16(code_lens_range.start, Bias::Left); let end = snapshot.clip_point_utf16(code_lens_range.end, Bias::Right); let range = snapshot.anchor_before(start)..snapshot.anchor_after(end); + let resolved = code_lens.command.is_some(); CodeAction { server_id, range, lsp_action: LspAction::CodeLens(code_lens), - resolved: false, + resolved, } }) .collect()) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 68e6265a7129f7..f1ae73d5acb54a 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -77,7 +77,8 @@ use language::{ OffsetUtf16, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToOffsetUtf16, ToPointUtf16, Toolchain, Transaction, Unclipped, language_settings::{ - AllLanguageSettings, FormatOnSave, Formatter, LanguageSettings, all_language_settings, + AllLanguageSettings, FormatOnSave, Formatter, LanguageSettings, LineEndingSetting, + all_language_settings, }, modeline, point_to_lsp, proto::{ @@ -1086,6 +1087,7 @@ impl LocalLspStore { let mut cx = cx.clone(); async move { this.update(&mut cx, |this, cx| { + this.invalidate_code_lens(); cx.emit(LspStoreEvent::RefreshCodeLens); this.downstream_client.as_ref().map(|(client, project_id)| { client.send(proto::RefreshCodeLens { @@ -1602,6 +1604,9 @@ impl LocalLspStore { (adapters_and_servers, settings, request_timeout) }) })?; + let had_existing_line_endings = buffer + .handle + .read_with(cx, |buffer, _| buffer.max_point().row > 0); // handle whitespace formatting if settings.remove_trailing_whitespace_on_save { @@ -1622,6 +1627,30 @@ impl LocalLspStore { })?; } + let line_ending_policy = match settings.line_ending { + LineEndingSetting::Detect => None, + LineEndingSetting::PreferLf => Some((LineEnding::Unix, true)), + LineEndingSetting::PreferCrlf => Some((LineEnding::Windows, true)), + LineEndingSetting::EnforceLf => Some((LineEnding::Unix, false)), + LineEndingSetting::EnforceCrlf => Some((LineEnding::Windows, false)), + }; + if let Some((desired_line_ending, preserve_existing)) = line_ending_policy { + buffer.handle.update(cx, |buffer, cx| { + if buffer.line_ending() == desired_line_ending { + return; + } + if preserve_existing && had_existing_line_endings { + zlog::trace!( + logger => "preserving existing line endings ({}) on save", + buffer.line_ending().label() + ); + return; + } + zlog::trace!(logger => "normalizing line endings to {}", desired_line_ending.label()); + buffer.set_line_ending(desired_line_ending, cx); + }); + } + // Formatter for `code_actions_on_format` that runs before // the rest of the formatters let mut code_actions_on_format_formatters = None; @@ -5544,20 +5573,20 @@ impl LspStore { .await .context("resolving a code action")?; if let Some(edit) = action.lsp_action.edit() - && (edit.changes.is_some() || edit.document_changes.is_some()) { - return LocalLspStore::deserialize_workspace_edit( - this.upgrade().context("no app present")?, - edit.clone(), - push_to_history, - - lang_server.clone(), - cx, - ) - .await; - } + && (edit.changes.is_some() || edit.document_changes.is_some()) + { + return LocalLspStore::deserialize_workspace_edit( + this.upgrade().context("no app present")?, + edit.clone(), + push_to_history, + lang_server.clone(), + cx, + ) + .await; + } let Some(command) = action.lsp_action.command() else { - return Ok(ProjectTransaction::default()) + return Ok(ProjectTransaction::default()); }; let server_capabilities = lang_server.capabilities(); @@ -5568,15 +5597,18 @@ impl LspStore { .unwrap_or_default(); if !available_commands.contains(&command.command) { - log::warn!("Cannot execute a command {} not listed in the language server capabilities", command.command); - return Ok(ProjectTransaction::default()) + log::warn!( + "Skipping executeCommand for {}, not listed in language server capabilities", + command.command + ); + return Ok(ProjectTransaction::default()); } - let request_timeout = cx.update(|app| + let request_timeout = cx.update(|app| { ProjectSettings::get_global(app) - .global_lsp_settings - .get_request_timeout() - ); + .global_lsp_settings + .get_request_timeout() + }); this.update(cx, |this, _| { this.as_local_mut() @@ -5586,12 +5618,16 @@ impl LspStore { })?; let _result = lang_server - .request::(lsp::ExecuteCommandParams { - command: command.command.clone(), - arguments: command.arguments.clone().unwrap_or_default(), - ..lsp::ExecuteCommandParams::default() - }, request_timeout) - .await.into_response() + .request::( + lsp::ExecuteCommandParams { + command: command.command.clone(), + arguments: command.arguments.clone().unwrap_or_default(), + ..lsp::ExecuteCommandParams::default() + }, + request_timeout, + ) + .await + .into_response() .context("execute command")?; return this.update(cx, |this, _| { @@ -11918,12 +11954,17 @@ impl LspStore { &self, id: LanguageServerId, ) -> Option> { - self.as_local() - .and_then(|local| local.language_servers.get(&id)) - .and_then(|language_server_state| match language_server_state { - LanguageServerState::Running { adapter, .. } => Some(adapter.clone()), - _ => None, - }) + if let Some(local) = self.as_local() + && let Some(LanguageServerState::Running { adapter, .. }) = + local.language_servers.get(&id) + { + return Some(adapter.clone()); + } + // In remote (SSH/collab) mode there are no local `language_servers`, but + // `language_server_statuses` is kept in sync with the upstream and carries each + // server's registered name, which is enough to look the adapter up in the registry. + let name = &self.language_server_statuses.get(&id)?.name; + self.languages.adapter_for_name(name) } pub(super) fn update_local_worktree_language_servers( diff --git a/crates/project/src/lsp_store/code_lens.rs b/crates/project/src/lsp_store/code_lens.rs index 756c2dec06ea9d..02059bc076ef1d 100644 --- a/crates/project/src/lsp_store/code_lens.rs +++ b/crates/project/src/lsp_store/code_lens.rs @@ -1,3 +1,4 @@ +use std::ops::Range; use std::sync::Arc; use anyhow::{Context as _, Result}; @@ -8,14 +9,15 @@ use futures::{ future::{Shared, join_all}, }; use gpui::{AppContext as _, AsyncApp, Context, Entity, Task}; -use language::Buffer; +use language::{Anchor, Buffer, ToOffset as _}; use lsp::LanguageServerId; use rpc::{TypedEnvelope, proto}; use settings::Settings as _; use std::time::Duration; +use text::OffsetRangeExt as _; use crate::{ - CodeAction, LspStore, LspStoreEvent, + CodeAction, LspAction, LspStore, LspStoreEvent, Project, lsp_command::{GetCodeLens, LspCommand as _}, project_settings::ProjectSettings, }; @@ -36,10 +38,44 @@ impl CodeLensData { } impl LspStore { + pub(super) fn invalidate_code_lens(&mut self) { + for lsp_data in self.lsp_data.values_mut() { + lsp_data.code_lens = None; + } + } + + /// Fetches and returns all code lenses for the buffer. + /// + /// Resolution of individual lenses is the caller's responsibility; see + /// [`LspStore::resolve_visible_code_lenses`]. pub fn code_lens_actions( &mut self, buffer: &Entity, cx: &mut Context, + ) -> Task>>> { + let buffer_id = buffer.read(cx).remote_id(); + let fetch_task = self.fetch_code_lenses(buffer, cx); + + cx.spawn(async move |lsp_store, cx| { + fetch_task + .await + .map_err(|e| anyhow::anyhow!("code lens fetch failed: {e:#}"))?; + + let actions = lsp_store.read_with(cx, |lsp_store, _| { + lsp_store + .lsp_data + .get(&buffer_id) + .and_then(|data| data.code_lens.as_ref()) + .map(|code_lens| code_lens.lens.values().flatten().cloned().collect()) + })?; + Ok(actions) + }) + } + + fn fetch_code_lenses( + &mut self, + buffer: &Entity, + cx: &mut Context, ) -> CodeLensTask { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); @@ -83,7 +119,9 @@ impl LspStore { .timer(Duration::from_millis(30)) .await; let fetched_lens = lsp_store - .update(cx, |lsp_store, cx| lsp_store.fetch_code_lens(&buffer, cx)) + .update(cx, |lsp_store, cx| { + lsp_store.fetch_code_lens_for_buffer(&buffer, cx) + }) .map_err(Arc::new)? .await .context("fetching code lens") @@ -107,7 +145,7 @@ impl LspStore { }; lsp_store - .update(cx, |lsp_store, _| { + .update(cx, |lsp_store, cx| { let lsp_data = lsp_store.current_lsp_data(buffer_id)?; let code_lens = lsp_data.code_lens.as_mut()?; if let Some(fetched_lens) = fetched_lens { @@ -120,6 +158,11 @@ impl LspStore { lsp_data.buffer_version = query_version_queried_for; code_lens.lens = fetched_lens; } + let snapshot = buffer.read(cx).snapshot(); + for actions in code_lens.lens.values_mut() { + actions + .sort_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot)); + } } code_lens.update = None; Some(code_lens.lens.values().flatten().cloned().collect()) @@ -131,7 +174,7 @@ impl LspStore { new_task } - pub(super) fn fetch_code_lens( + fn fetch_code_lens_for_buffer( &mut self, buffer: &Entity, cx: &mut Context, @@ -202,6 +245,112 @@ impl LspStore { } } + pub fn resolve_visible_code_lenses( + &mut self, + buffer: &Entity, + visible_range: Range, + cx: &mut Context, + ) -> Task> { + let buffer_id = buffer.read(cx).remote_id(); + let snapshot = buffer.read(cx).snapshot(); + let visible_start = visible_range.start.to_offset(&snapshot); + let visible_end = visible_range.end.to_offset(&snapshot); + + let Some(code_lens) = self + .lsp_data + .get(&buffer_id) + .and_then(|data| data.code_lens.as_ref()) + else { + return Task::ready(Vec::new()); + }; + + let capable_servers = code_lens + .lens + .keys() + .filter_map(|server_id| { + let server = self.language_server_for_id(*server_id)?; + GetCodeLens::can_resolve_lens(&server.capabilities()) + .then_some((*server_id, server)) + }) + .collect::>(); + if capable_servers.is_empty() { + return Task::ready(Vec::new()); + } + + let to_resolve = code_lens + .lens + .iter() + .flat_map(|(server_id, actions)| { + let start_idx = + actions.partition_point(|a| a.range.start.to_offset(&snapshot) < visible_start); + let end_idx = start_idx + + actions[start_idx..] + .partition_point(|a| a.range.start.to_offset(&snapshot) <= visible_end); + actions[start_idx..end_idx].iter().enumerate().filter_map( + move |(local_idx, action)| { + let LspAction::CodeLens(lens) = &action.lsp_action else { + return None; + }; + if lens.command.is_some() { + return None; + } + Some((*server_id, start_idx + local_idx, lens.clone())) + }, + ) + }) + .collect::>(); + if to_resolve.is_empty() { + return Task::ready(Vec::new()); + } + + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + + cx.spawn(async move |lsp_store, cx| { + let mut resolved = Vec::new(); + for (server_id, index, lens) in to_resolve { + let Some(server) = capable_servers.get(&server_id) else { + continue; + }; + match server + .request::(lens, request_timeout) + .await + .into_response() + { + Ok(resolved_lens) => resolved.push((server_id, index, resolved_lens)), + Err(e) => log::warn!("Failed to resolve code lens: {e:#}"), + } + } + if resolved.is_empty() { + return Vec::new(); + } + + lsp_store + .update(cx, |lsp_store, _| { + let Some(code_lens) = lsp_store + .lsp_data + .get_mut(&buffer_id) + .and_then(|data| data.code_lens.as_mut()) + else { + return Vec::new(); + }; + let mut newly_resolved = Vec::new(); + for (server_id, index, resolved_lens) in resolved { + if let Some(actions) = code_lens.lens.get_mut(&server_id) { + if let Some(action) = actions.get_mut(index) { + action.resolved = true; + action.lsp_action = LspAction::CodeLens(resolved_lens); + newly_resolved.push(action.clone()); + } + } + } + newly_resolved + }) + .unwrap_or_default() + }) + } + #[cfg(any(test, feature = "test-support"))] pub fn forget_code_lens_task(&mut self, buffer_id: text::BufferId) -> Option { Some( @@ -216,13 +365,59 @@ impl LspStore { } pub(super) async fn handle_refresh_code_lens( - this: Entity, + lsp_store: Entity, _: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - this.update(&mut cx, |_, cx| { + lsp_store.update(&mut cx, |lsp_store, cx| { + lsp_store.invalidate_code_lens(); cx.emit(LspStoreEvent::RefreshCodeLens); }); Ok(proto::Ack {}) } } + +impl Project { + pub fn code_lens_actions( + &mut self, + buffer: &Entity, + range: Range, + cx: &mut Context, + ) -> Task>>> { + let snapshot = buffer.read(cx).snapshot(); + let range = range.to_point(&snapshot); + let range_start = snapshot.anchor_before(range.start); + let range_end = if range.start == range.end { + range_start + } else { + snapshot.anchor_after(range.end) + }; + let range = range_start..range_end; + let lsp_store = self.lsp_store(); + let fetch_task = + lsp_store.update(cx, |lsp_store, cx| lsp_store.code_lens_actions(buffer, cx)); + let buffer = buffer.clone(); + cx.spawn(async move |_, cx| { + let mut actions = fetch_task.await?; + if let Some(actions) = &mut actions { + let resolve_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.resolve_visible_code_lenses(&buffer, range.clone(), cx) + }); + let resolved = resolve_task.await; + for resolved_action in resolved { + if let Some(action) = actions.iter_mut().find(|a| { + a.server_id == resolved_action.server_id && a.range == resolved_action.range + }) { + *action = resolved_action; + } + } + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + actions.retain(|action| { + range.start.cmp(&action.range.start, &snapshot).is_ge() + && range.end.cmp(&action.range.end, &snapshot).is_le() + }); + } + Ok(actions) + }) + } +} diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index 9c284a143613c4..55395bd066326f 100644 --- a/crates/project/src/lsp_store/lsp_ext_command.rs +++ b/crates/project/src/lsp_store/lsp_ext_command.rs @@ -584,6 +584,56 @@ pub struct LspRunnables { pub runnables: Vec<(Option, TaskTemplate)>, } +pub fn runnable_to_task_template(label: String, args: RunnableArgs) -> TaskTemplate { + let mut task_template = TaskTemplate::default(); + task_template.label = label; + match args { + RunnableArgs::Cargo(cargo) => { + match cargo.override_cargo { + Some(override_cargo) => { + let mut override_parts = override_cargo.split(" ").map(|s| s.to_string()); + task_template.command = override_parts + .next() + .unwrap_or_else(|| override_cargo.clone()); + task_template.args.extend(override_parts); + } + None => task_template.command = "cargo".to_string(), + }; + task_template.env = cargo.environment; + task_template.cwd = Some( + cargo + .workspace_root + .unwrap_or(cargo.cwd) + .to_string_lossy() + .to_string(), + ); + task_template.args.extend(cargo.cargo_args); + if !cargo.executable_args.is_empty() { + let shell_kind = task_template.shell.shell_kind(cfg!(windows)); + task_template.args.push("--".to_string()); + task_template.args.extend( + cargo + .executable_args + .into_iter() + // rust-analyzer's doctest data may contain things like `X::new` + // which cause shell issues when run as `$SHELL -i -c "cargo test ..."`. + // Escape extra cargo args unconditionally as those are unlikely to contain `~`. + .flat_map(|extra_arg| { + shell_kind.try_quote(&extra_arg).map(|s| s.to_string()) + }), + ); + } + } + RunnableArgs::Shell(shell) => { + task_template.command = shell.program; + task_template.args = shell.args; + task_template.env = shell.environment; + task_template.cwd = Some(shell.cwd.to_string_lossy().into_owned()); + } + } + task_template +} + #[async_trait(?Send)] impl LspCommand for GetLspRunnables { type Response = LspRunnables; @@ -632,70 +682,7 @@ impl LspCommand for GetLspRunnables { ), None => None, }; - let mut task_template = TaskTemplate::default(); - task_template.label = runnable.label; - match runnable.args { - RunnableArgs::Cargo(cargo) => { - match cargo.override_cargo { - Some(override_cargo) => { - let mut override_parts = - override_cargo.split(" ").map(|s| s.to_string()); - task_template.command = override_parts - .next() - .unwrap_or_else(|| override_cargo.clone()); - task_template.args.extend(override_parts); - } - None => task_template.command = "cargo".to_string(), - }; - task_template.env = cargo.environment; - task_template.cwd = Some( - cargo - .workspace_root - .unwrap_or(cargo.cwd) - .to_string_lossy() - .to_string(), - ); - task_template.args.extend(cargo.cargo_args); - if !cargo.executable_args.is_empty() { - let shell_kind = task_template.shell.shell_kind(cfg!(windows)); - task_template.args.push("--".to_string()); - task_template.args.extend( - cargo - .executable_args - .into_iter() - // rust-analyzer's doctest data may be smth. like - // ``` - // command: "cargo", - // args: [ - // "test", - // "--doc", - // "--package", - // "cargo-output-parser", - // "--", - // "X::new", - // "--show-output", - // ], - // ``` - // and `X::new` will cause troubles if not escaped properly, as later - // the task runs as `$SHELL -i -c "cargo test ..."`. - // - // We cannot escape all shell arguments unconditionally, as we use this for ssh commands, which may involve paths starting with `~`. - // That bit is not auto-expanded when using single quotes. - // Escape extra cargo args unconditionally as those are unlikely to contain `~`. - .flat_map(|extra_arg| { - shell_kind.try_quote(&extra_arg).map(|s| s.to_string()) - }), - ); - } - } - RunnableArgs::Shell(shell) => { - task_template.command = shell.program; - task_template.args = shell.args; - task_template.env = shell.environment; - task_template.cwd = Some(shell.cwd.to_string_lossy().into_owned()); - } - } - + let task_template = runnable_to_task_template(runnable.label, runnable.args); runnables.push((location, task_template)); } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index f45a6632f40f94..ab66da494dede5 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -134,7 +134,7 @@ use std::{ use task_store::TaskStore; use terminals::Terminals; -use text::{Anchor, BufferId, OffsetRangeExt, Point, Rope}; +use text::{Anchor, BufferId, Point, Rope}; use toolchain_store::EmptyToolchainStore; use util::{ ResultExt as _, maybe, @@ -761,7 +761,7 @@ impl LspAction { } } - fn edit(&self) -> Option<&lsp::WorkspaceEdit> { + pub fn edit(&self) -> Option<&lsp::WorkspaceEdit> { match self { Self::Action(action) => action.edit.as_ref(), Self::Command(_) => None, @@ -769,7 +769,7 @@ impl LspAction { } } - fn command(&self) -> Option<&lsp::Command> { + pub fn command(&self) -> Option<&lsp::Command> { match self { Self::Action(action) => action.command.as_ref(), Self::Command(command) => Some(command), @@ -4385,45 +4385,6 @@ impl Project { }) } - pub fn code_lens_actions( - &mut self, - buffer: &Entity, - range: Range, - cx: &mut Context, - ) -> Task>>> { - let snapshot = buffer.read(cx).snapshot(); - let range = range.to_point(&snapshot); - let range_start = snapshot.anchor_before(range.start); - let range_end = if range.start == range.end { - range_start - } else { - snapshot.anchor_after(range.end) - }; - let range = range_start..range_end; - let code_lens_actions = self - .lsp_store - .update(cx, |lsp_store, cx| lsp_store.code_lens_actions(buffer, cx)); - - cx.background_spawn(async move { - let mut code_lens_actions = code_lens_actions - .await - .map_err(|e| anyhow!("code lens fetch failed: {e:#}"))?; - if let Some(code_lens_actions) = &mut code_lens_actions { - code_lens_actions.retain(|code_lens_action| { - range - .start - .cmp(&code_lens_action.range.start, &snapshot) - .is_ge() - && range - .end - .cmp(&code_lens_action.range.end, &snapshot) - .is_le() - }); - } - Ok(code_lens_actions) - }) - } - pub fn apply_code_action( &self, buffer_handle: Entity, diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index cd4702d04863c2..5f30ef2aa18c5a 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -105,12 +105,11 @@ impl SearchQuery { // AhoCorasickBuilder doesn't support case-insensitive search with unicode characters // Fallback to regex search as recommended by // https://docs.rs/aho-corasick/1.1/aho_corasick/struct.AhoCorasickBuilder.html#method.ascii_case_insensitive - return Self::regex( - regex::escape(&query), + return Self::escaped_regex( + query, whole_word, case_sensitive, include_ignored, - false, files_to_include, files_to_exclude, false, @@ -145,7 +144,7 @@ impl SearchQuery { pub fn regex( query: impl ToString, whole_word: bool, - mut case_sensitive: bool, + case_sensitive: bool, include_ignored: bool, one_match_per_line: bool, files_to_include: PathMatcher, @@ -153,47 +152,96 @@ impl SearchQuery { match_full_paths: bool, buffers: Option>>, ) -> Result { - let mut query = query.to_string(); - let initial_query = Arc::from(query.as_str()); + let query = query.to_string(); + let inner = SearchInputs { + query: Arc::from(query.as_str()), + files_to_include, + files_to_exclude, + match_full_paths, + buffers, + }; + Self::build_regex( + query, + whole_word, + case_sensitive, + include_ignored, + one_match_per_line, + inner, + ) + } - if let Some((case_sensitive_from_pattern, new_query)) = - Self::case_sensitive_from_pattern(&query) + /// Create a regex query from a literal string, escaping any regex + /// metacharacters so that the resulting query matches the literal text. + /// + /// Unlike `regex`, the query stored on the resulting `SearchQuery` is the + /// original unescaped text, so `as_str` returns what the user typed. + pub fn escaped_regex( + query: impl ToString, + whole_word: bool, + case_sensitive: bool, + include_ignored: bool, + files_to_include: PathMatcher, + files_to_exclude: PathMatcher, + match_full_paths: bool, + buffers: Option>>, + ) -> Result { + let query = query.to_string(); + let inner = SearchInputs { + query: Arc::from(query.as_str()), + files_to_include, + files_to_exclude, + match_full_paths, + buffers, + }; + Self::build_regex( + regex::escape(&query), + whole_word, + case_sensitive, + include_ignored, + false, + inner, + ) + } + + fn build_regex( + mut pattern: String, + whole_word: bool, + mut case_sensitive: bool, + include_ignored: bool, + one_match_per_line: bool, + inner: SearchInputs, + ) -> Result { + if let Some((case_sensitive_from_pattern, new_pattern)) = + Self::case_sensitive_from_pattern(&pattern) { case_sensitive = case_sensitive_from_pattern; - query = new_query + pattern = new_pattern } if whole_word { - let mut word_query = String::new(); - if let Some(first) = query.get(0..1) + let mut word_pattern = String::new(); + if let Some(first) = pattern.get(0..1) && WORD_MATCH_TEST.is_match(first).is_ok_and(|x| !x) { - word_query.push_str("\\b"); + word_pattern.push_str("\\b"); } - word_query.push_str(&query); - if let Some(last) = query.get(query.len() - 1..) + word_pattern.push_str(&pattern); + if let Some(last) = pattern.get(pattern.len() - 1..) && WORD_MATCH_TEST.is_match(last).is_ok_and(|x| !x) { - word_query.push_str("\\b"); + word_pattern.push_str("\\b"); } - query = word_query + pattern = word_pattern } - let multiline = query.contains('\n') || query.contains("\\n"); + let multiline = pattern.contains('\n') || pattern.contains("\\n"); if multiline { - query.insert_str(0, "(?m)"); + pattern.insert_str(0, "(?m)"); } - let regex = RegexBuilder::new(&query) + let regex = RegexBuilder::new(&pattern) .case_insensitive(!case_sensitive) .build()?; - let inner = SearchInputs { - query: initial_query, - files_to_exclude, - files_to_include, - match_full_paths, - buffers, - }; Ok(Self::Regex { regex, replacement: None, diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 6efddcdf772611..e22af5d552fa8e 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -17,7 +17,9 @@ use terminal::{ TaskState, TaskStatus, Terminal, TerminalBuilder, insert_zed_terminal_env, terminal_settings::TerminalSettings, }; -use util::{command::new_std_command, get_default_system_shell, maybe, rel_path::RelPath}; +use util::{ + command::new_std_command, get_default_system_shell, get_system_shell, maybe, rel_path::RelPath, +}; use crate::{Project, ProjectPath}; @@ -103,7 +105,7 @@ impl Project { .read(cx) .shell() .unwrap_or_else(get_default_system_shell), - None => settings.shell.program(), + None => get_system_shell(), }; let path_style = self.path_style(cx); let shell_kind = ShellKind::new(&shell, path_style.is_windows()); @@ -363,12 +365,16 @@ impl Project { .unwrap_or_else(get_default_system_shell), None => settings.shell.program(), }; + let env_shell = match &remote_client { + Some(_) => shell.clone(), + None => get_system_shell(), + }; let path_style = self.path_style(cx); // Prepare a task for resolving the environment let env_task = - self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx); + self.resolve_directory_environment(&env_shell, path.clone(), remote_client.clone(), cx); let lang_registry = self.languages.clone(); cx.spawn(async move |project, cx| { @@ -526,7 +532,7 @@ impl Project { .as_ref() .and_then(|remote_client| remote_client.read(cx).shell()) .map(Shell::Program) - .unwrap_or_else(|| settings.shell.clone()); + .unwrap_or(Shell::System); let is_windows = self.path_style(cx).is_windows(); let builder = ShellBuilder::new(&shell, is_windows).non_interactive(); let (command, args) = builder.build(Some(command), &Vec::new()); diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index bad9fcf58dc939..05e29d00177ec7 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -46,7 +46,9 @@ use language::{ LanguageConfig, LanguageMatcher, LanguageName, LineEnding, ManifestName, ManifestProvider, ManifestQuery, OffsetRangeExt, Point, ToPoint, Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata, - language_settings::{Formatter, FormatterList, LanguageSettings, LanguageSettingsContent}, + language_settings::{ + Formatter, FormatterList, LanguageSettings, LanguageSettingsContent, LineEndingSetting, + }, markdown_lang, rust_lang, tree_sitter_typescript, }; use lsp::{ @@ -318,6 +320,7 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) { assert_eq!(settings_a.hard_tabs, true); assert_eq!(settings_a.ensure_final_newline_on_save, true); assert_eq!(settings_a.remove_trailing_whitespace_on_save, true); + assert_eq!(settings_a.line_ending, LineEndingSetting::EnforceLf); assert_eq!(settings_a.preferred_line_length, 120); // .editorconfig in b/ overrides .editorconfig in root @@ -6420,6 +6423,289 @@ async fn test_buffer_line_endings(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_line_ending_user_settings_on_format(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let cases = [ + ( + "default", + None, + [ + ("crlf_file.rs", LineEnding::Windows), + ("lf_file.rs", LineEnding::Unix), + ("no_newline.rs", LineEnding::default()), + ], + ), + ( + "detect", + Some(LineEndingSetting::Detect), + [ + ("crlf_file.rs", LineEnding::Windows), + ("lf_file.rs", LineEnding::Unix), + ("no_newline.rs", LineEnding::default()), + ], + ), + ( + "prefer_lf", + Some(LineEndingSetting::PreferLf), + [ + ("crlf_file.rs", LineEnding::Windows), + ("lf_file.rs", LineEnding::Unix), + ("no_newline.rs", LineEnding::Unix), + ], + ), + ( + "prefer_crlf", + Some(LineEndingSetting::PreferCrlf), + [ + ("crlf_file.rs", LineEnding::Windows), + ("lf_file.rs", LineEnding::Unix), + ("no_newline.rs", LineEnding::Windows), + ], + ), + ( + "enforce_lf", + Some(LineEndingSetting::EnforceLf), + [ + ("crlf_file.rs", LineEnding::Unix), + ("lf_file.rs", LineEnding::Unix), + ("no_newline.rs", LineEnding::Unix), + ], + ), + ( + "enforce_crlf", + Some(LineEndingSetting::EnforceCrlf), + [ + ("crlf_file.rs", LineEnding::Windows), + ("lf_file.rs", LineEnding::Windows), + ("no_newline.rs", LineEnding::Windows), + ], + ), + ]; + + for (case_name, line_ending_setting, expected_line_endings) in cases { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "crlf_file.rs": "one\r\ntwo\r\nthree\r\n", + "lf_file.rs": "one\ntwo\nthree\n", + "no_newline.rs": "single line", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let worktree_id = project.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.line_ending = line_ending_setting; + }); + }); + }); + cx.executor().run_until_parked(); + + assert_line_endings_after_format( + cx, + &project, + worktree_id, + case_name, + &expected_line_endings, + ) + .await; + } +} + +#[gpui::test] +async fn test_line_ending_editorconfig_on_format_and_save(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let cases = [ + ( + "editorconfig lf", + "lf", + "crlf_file.rs", + LineEnding::Windows, + [ + ("crlf_file.rs", LineEnding::Unix), + ("lf_file.rs", LineEnding::Unix), + ("no_newline.rs", LineEnding::Unix), + ], + "one\ntwo\nthree\n", + ), + ( + "editorconfig crlf", + "crlf", + "lf_file.rs", + LineEnding::Unix, + [ + ("crlf_file.rs", LineEnding::Windows), + ("lf_file.rs", LineEnding::Windows), + ("no_newline.rs", LineEnding::Windows), + ], + "one\r\ntwo\r\nthree\r\n", + ), + ]; + + for ( + case_name, + editorconfig_end_of_line, + buffer_path, + initial_line_ending, + expected_line_endings, + expected_saved_contents, + ) in cases + { + let file_system = FakeFs::new(cx.executor()); + file_system + .insert_tree( + path!("/dir"), + json!({ + ".editorconfig": format!("root = true\n[*.rs]\nend_of_line = {editorconfig_end_of_line}\n"), + "crlf_file.rs": "one\r\ntwo\r\nthree\r\n", + "lf_file.rs": "one\ntwo\nthree\n", + "no_newline.rs": "single line", + }), + ) + .await; + + let project = Project::test(file_system.clone(), [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + cx.executor().run_until_parked(); + let worktree_id = project.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path(buffer_path)), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), initial_line_ending); + }); + + assert_line_endings_after_format( + cx, + &project, + worktree_id, + case_name, + &expected_line_endings, + ) + .await; + + project + .update(cx, |project, cx| project.save_buffer(buffer, cx)) + .await + .unwrap(); + let saved_path = PathBuf::from(path!("/dir")).join(buffer_path); + assert_eq!( + file_system.load(&saved_path).await.unwrap(), + expected_saved_contents, + ); + } +} + +#[gpui::test] +async fn test_line_ending_initialization_for_new_buffers(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let cases = [ + (Some(LineEndingSetting::Detect), LineEnding::default()), + (Some(LineEndingSetting::PreferLf), LineEnding::Unix), + (Some(LineEndingSetting::PreferCrlf), LineEnding::Windows), + (Some(LineEndingSetting::EnforceLf), LineEnding::Unix), + (Some(LineEndingSetting::EnforceCrlf), LineEnding::Windows), + ]; + + for (line_ending_setting, expected_line_ending) in cases { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({})).await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.line_ending = line_ending_setting; + }); + }); + }); + cx.executor().run_until_parked(); + + let created_buffer = project + .update(cx, |project, cx| project.create_buffer(None, false, cx)) + .unwrap() + .await; + created_buffer.update(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), expected_line_ending); + }); + + let local_buffer = project.update(cx, |project, cx| { + project.create_local_buffer("single line", None, false, cx) + }); + local_buffer.update(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), expected_line_ending); + }); + + let opened_missing_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/new_file.rs"), cx) + }) + .await + .unwrap(); + opened_missing_buffer.update(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), expected_line_ending); + }); + } +} + +async fn assert_line_endings_after_format( + cx: &mut gpui::TestAppContext, + project: &Entity, + worktree_id: WorktreeId, + case_name: &str, + expected_line_endings: &[(&str, LineEnding)], +) { + for (path, expected_line_ending) in expected_line_endings { + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path(path)), cx) + }) + .await + .unwrap(); + let mut buffers = HashSet::default(); + buffers.insert(buffer.clone()); + project + .update(cx, |project, cx| { + project.format( + buffers, + project::lsp_store::LspFormatTarget::Buffers, + false, + project::lsp_store::FormatTrigger::Save, + cx, + ) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, _| { + assert_eq!( + buffer.line_ending(), + *expected_line_ending, + "unexpected line ending for {path} in {case_name}" + ); + }); + } +} + #[gpui::test] async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 3a5047c0d7a6d4..990040ac4bff79 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1217,10 +1217,10 @@ impl ProjectPanel { .when(!is_collab && is_root, |menu| { menu.separator() .action( - "Add Project to Workspace…", + "Add Folders to Project…", Box::new(workspace::AddFolderToProject), ) - .action("Remove from Workspace", Box::new(RemoveFromProject)) + .action("Remove from Project", Box::new(RemoveFromProject)) }) .when(is_dir && !is_root, |menu| { menu.separator().action( @@ -2077,13 +2077,18 @@ impl ProjectPanel { let directory_id; let new_entry_id = self.resolve_entry(entry_id); - if let Some((worktree, expanded_dir_ids)) = self - .project - .read(cx) - .worktree_for_id(worktree_id, cx) - .zip(self.state.expanded_dir_ids.get_mut(&worktree_id)) - { + if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) { let worktree = worktree.read(cx); + let expanded_dir_ids = match self.state.expanded_dir_ids.entry(worktree_id) { + hash_map::Entry::Occupied(entry) => entry.into_mut(), + hash_map::Entry::Vacant(entry) => { + let Some(root_entry_id) = worktree.root_entry().map(|entry| entry.id) else { + return; + }; + entry.insert(vec![root_entry_id]) + } + }; + if let Some(mut entry) = worktree.entry_for_id(new_entry_id) { loop { if entry.is_dir() { @@ -7096,7 +7101,7 @@ impl Render for ProjectPanel { deferred( anchored() .position(*position) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .child(menu.clone()), ) .with_priority(3) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 5cb9fe838142c4..4897b57937d6c6 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -8065,6 +8065,104 @@ async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppC ); } +#[gpui::test] +async fn test_context_menu_new_file_in_empty_hidden_root(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({})).await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + cx.run_until_parked(); + + assert!( + visible_entries_as_strings(&panel, 0..20, cx).is_empty(), + "Empty worktree with hide_root=true should render no entries" + ); + + panel.update(cx, |panel, _| { + assert!( + panel.selection.is_none(), + "Project panel should start without a selection" + ); + assert!( + panel.state.last_worktree_root_id.is_some(), + "Project panel should still track the hidden root entry" + ); + }); + + panel.update_in(cx, |panel, window, cx| { + let root_entry_id = panel + .state + .last_worktree_root_id + .expect("hidden root should be available for background context menu actions"); + panel.deploy_context_menu( + gpui::point(gpui::px(1.), gpui::px(1.)), + root_entry_id, + window, + cx, + ); + panel.new_file(&NewFile, window, cx); + }); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + assert!( + panel.filename_editor.read(cx).is_focused(window), + "New File from the background context menu should open the filename editor" + ); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[" [EDITOR: ''] <== selected"], + "New file editor should appear at the hidden root level" + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text("new_file_from_context_menu.txt", window, cx) + }); + panel.confirm_edit(true, window, cx).unwrap() + }); + confirm.await.unwrap(); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[" new_file_from_context_menu.txt <== selected <== marked"], + "Confirmed file should appear at the hidden root level" + ); + + assert!( + fs.is_file(Path::new("/root/new_file_from_context_menu.txt")) + .await, + "File should be created in the empty root directory" + ); +} + #[cfg(windows)] #[gpui::test] async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) { diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index a2aa9f78a2a5ed..fbb7bb31a939c2 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -26,7 +26,7 @@ editor.workspace = true extension_host.workspace = true fs.workspace = true futures.workspace = true -fuzzy.workspace = true +fuzzy_nucleo.workspace = true gpui.workspace = true language.workspace = true log.workspace = true diff --git a/crates/recent_projects/src/dev_container_suggest.rs b/crates/recent_projects/src/dev_container_suggest.rs index 759eef2ba32074..48ef87e4407104 100644 --- a/crates/recent_projects/src/dev_container_suggest.rs +++ b/crates/recent_projects/src/dev_container_suggest.rs @@ -3,6 +3,7 @@ use dev_container::find_configs_in_snapshot; use gpui::{SharedString, Window}; use project::{Project, WorktreeId}; use std::sync::LazyLock; +use ui::Tooltip; use ui::prelude::*; use util::ResultExt; use util::rel_path::RelPath; @@ -91,6 +92,7 @@ pub fn suggest_on_worktree_updated( let abs_path = worktree.abs_path(); let project_path = abs_path.to_string_lossy().to_string(); + let worktree_name = worktree.root_name_str().to_string(); let key_for_dismiss = project_devcontainer_key(&project_path); let already_dismissed = KeyValueStore::global(cx) @@ -112,10 +114,18 @@ pub fn suggest_on_worktree_updated( workspace.show_notification(notification_id, cx, |cx| { cx.new(move |cx| { - MessageNotification::new( - "This project contains a Dev Container configuration file. Would you like to re-open it in a container?", - cx, + let message: SharedString = format!( + "{worktree_name} contains a Dev Container configuration file. Would you like to re-open it in a container?" ) + .into(); + let tooltip_text: SharedString = project_path.clone().into(); + MessageNotification::new_from_builder(cx, move |_window, _cx| { + div() + .id("dev-container-suggest-message") + .child(Label::new(message.clone())) + .tooltip(Tooltip::text(tooltip_text.clone())) + .into_any_element() + }) .primary_message("Yes, Open in Container") .primary_icon(IconName::Check) .primary_icon_color(Color::Success) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 045815800286e5..5cca4767a2f40b 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -22,7 +22,7 @@ pub use remote_connection::{RemoteConnectionModal, connect}; pub use remote_connections::{navigate_to_positions, open_remote_project}; use disconnected_overlay::DisconnectedOverlay; -use fuzzy::{StringMatch, StringMatchCandidate}; +use fuzzy_nucleo::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, Window, actions, px, @@ -41,8 +41,8 @@ use workspace::ProjectGroupKey; use dev_container::{DevContainerContext, find_devcontainer_configs}; use ui::{ - ContextMenu, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, - PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*, + ButtonLike, ContextMenu, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, + ListSubHeader, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; use util::{ResultExt, paths::PathExt}; use workspace::{ @@ -96,7 +96,7 @@ pub async fn get_recent_projects( db: &WorkspaceDb, ) -> Vec { let workspaces = db - .recent_workspaces_on_disk(fs.as_ref()) + .recent_project_workspaces(fs.as_ref()) .await .unwrap_or_default(); @@ -610,7 +610,7 @@ impl RecentProjects { cx.spawn_in(window, async move |this, cx| { let Some(fs) = fs else { return }; let workspaces = db - .recent_workspaces_on_disk(fs.as_ref()) + .recent_project_workspaces(fs.as_ref()) .await .log_err() .unwrap_or_default(); @@ -780,7 +780,7 @@ impl RecentProjects { let paths_to_add = paths.paths().to_vec(); picker .delegate - .add_project_to_workspace(paths_to_add, window, cx); + .add_paths_to_project(paths_to_add, window, cx); } } } @@ -937,7 +937,7 @@ impl PickerDelegate for RecentProjectsDelegate { cx: &mut Context>, ) -> gpui::Task<()> { let query = query.trim_start(); - let smart_case = query.chars().any(|c| c.is_uppercase()); + let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query); let is_empty_query = query.is_empty(); let folder_matches = if self.open_folders.is_empty() { @@ -950,15 +950,13 @@ impl PickerDelegate for RecentProjectsDelegate { .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref())) .collect(); - smol::block_on(fuzzy::match_strings( + match_strings( &candidates, query, - smart_case, - true, + case, + fuzzy_nucleo::LengthPenalty::On, 100, - &Default::default(), - cx.background_executor().clone(), - )) + ) }; let project_group_candidates: Vec<_> = self @@ -976,21 +974,13 @@ impl PickerDelegate for RecentProjectsDelegate { }) .collect(); - let mut project_group_matches = smol::block_on(fuzzy::match_strings( + let project_group_matches = match_strings( &project_group_candidates, query, - smart_case, - true, + case, + fuzzy_nucleo::LengthPenalty::On, 100, - &Default::default(), - cx.background_executor().clone(), - )); - project_group_matches.sort_unstable_by(|a, b| { - b.score - .partial_cmp(&a.score) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.candidate_id.cmp(&b.candidate_id)) - }); + ); // Build candidates for recent projects (not current, not sibling, not open folder) let recent_candidates: Vec<_> = self @@ -1008,21 +998,13 @@ impl PickerDelegate for RecentProjectsDelegate { }) .collect(); - let mut recent_matches = smol::block_on(fuzzy::match_strings( + let recent_matches = match_strings( &recent_candidates, query, - smart_case, - true, + case, + fuzzy_nucleo::LengthPenalty::On, 100, - &Default::default(), - cx.background_executor().clone(), - )); - recent_matches.sort_unstable_by(|a, b| { - b.score - .partial_cmp(&a.score) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.candidate_id.cmp(&b.candidate_id)) - }); + ); let mut entries = Vec::new(); @@ -1058,7 +1040,7 @@ impl PickerDelegate for RecentProjectsDelegate { candidate_id: id, score: 0.0, positions: Vec::new(), - string: String::new(), + string: Default::default(), })); } } else { @@ -1084,7 +1066,7 @@ impl PickerDelegate for RecentProjectsDelegate { candidate_id: id, score: 0.0, positions: Vec::new(), - string: String::new(), + string: Default::default(), })); } } @@ -1527,7 +1509,7 @@ impl PickerDelegate for RecentProjectsDelegate { .icon_size(IconSize::Small) .tooltip(move |_, cx| { Tooltip::with_meta( - "Add Project to this Workspace", + "Add Folders to this Project", None, "As a multi-root folder project", cx, @@ -1538,7 +1520,7 @@ impl PickerDelegate for RecentProjectsDelegate { cx.listener(move |picker, _event, window, cx| { cx.stop_propagation(); window.prevent_default(); - picker.delegate.add_project_to_workspace( + picker.delegate.add_paths_to_project( paths_to_add.clone(), window, cx, @@ -1638,11 +1620,21 @@ impl PickerDelegate for RecentProjectsDelegate { .border_t_1() .border_color(cx.theme().colors().border_variant) .child({ - let open_action = workspace::Open { - create_new_window: self.create_new_window, - }; - Button::new("open_local_folder", "Open Local Folders") - .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx)) + ButtonLike::new("open_local_folder") + .child( + h_flex() + .w_full() + .gap_1() + .justify_between() + .child(Label::new("Open Local Folders")) + .child(KeyBinding::for_action_in( + &workspace::Open { + create_new_window: self.create_new_window, + }, + &focus_handle, + cx, + )), + ) .on_click({ let workspace = self.workspace.clone(); let create_new_window = self.create_new_window; @@ -1657,14 +1649,21 @@ impl PickerDelegate for RecentProjectsDelegate { }) }) .child( - Button::new("open_remote_folder", "Open Remote Folder") - .key_binding(KeyBinding::for_action( - &OpenRemote { - from_existing_connection: false, - create_new_window: false, - }, - cx, - )) + ButtonLike::new("open_remote_folder") + .child( + h_flex() + .w_full() + .gap_1() + .justify_between() + .child(Label::new("Open Remote Folder")) + .child(KeyBinding::for_action( + &OpenRemote { + from_existing_connection: false, + create_new_window: false, + }, + cx, + )), + ) .on_click(|_, window, cx| { window.dispatch_action( OpenRemote { @@ -1784,7 +1783,7 @@ impl PickerDelegate for RecentProjectsDelegate { .child( PopoverMenu::new("actions-menu-popover") .with_handle(self.actions_menu_handle.clone()) - .anchor(gpui::Corner::BottomRight) + .anchor(gpui::Anchor::BottomRight) .offset(gpui::Point { x: px(0.0), y: px(-2.0), @@ -1983,7 +1982,7 @@ fn open_local_project( } impl RecentProjectsDelegate { - fn add_project_to_workspace( + fn add_paths_to_project( &mut self, paths: Vec, window: &mut Window, @@ -2040,7 +2039,7 @@ impl RecentProjectsDelegate { db.delete_workspace_by_id(workspace_id).await.log_err(); let Some(fs) = fs else { return }; let workspaces = db - .recent_workspaces_on_disk(fs.as_ref()) + .recent_project_workspaces(fs.as_ref()) .await .unwrap_or_default(); let workspaces = diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 448115c6988a3e..38c5e8cdb56af9 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -160,7 +160,7 @@ pub async fn open_remote_project( let open_results = existing_window .update(cx, |multi_workspace, window, cx| { window.activate_window(); - multi_workspace.activate(existing_workspace.clone(), window, cx); + multi_workspace.activate(existing_workspace.clone(), None, window, cx); existing_workspace.update(cx, |workspace, cx| { workspace.open_paths( resolved_paths, diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 0e15abf296e491..77553791f87f75 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -45,8 +45,8 @@ use std::{ use ui::{ CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal, - ModalFooter, ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, - prelude::*, + ModalFooter, ModalHeader, Navigable, NavigableEntry, ScrollAxes, Scrollbars, Section, Tooltip, + WithScrollbar, prelude::*, }; use util::{ ResultExt, @@ -505,7 +505,7 @@ impl ProjectPicker { }?; let items = open_remote_project_with_existing_connection( - connection, project, paths, app_state, window, None, cx, + connection, project, paths, app_state, window, None, None, cx, ) .await .log_err(); @@ -2827,7 +2827,12 @@ impl RemoteServerProjects { ) .size_full(), ) - .vertical_scrollbar_for(&state.scroll_handle, window, cx), + .custom_scrollbars( + Scrollbars::always_visible(ScrollAxes::Vertical) + .tracked_scroll_handle(&state.scroll_handle), + window, + cx, + ), ), ) .footer(ModalFooter::new().end_slot({ diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index f197ed3cead41e..f19531c7070526 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; -use fuzzy::{StringMatch, StringMatchCandidate}; +use fuzzy_nucleo::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, Window, @@ -12,7 +12,7 @@ use picker::{ }; use remote::RemoteConnectionOptions; use settings::Settings; -use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use ui::{ButtonLike, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::{ResultExt, paths::PathExt}; use workspace::{ @@ -70,7 +70,7 @@ impl SidebarRecentProjects { cx.spawn_in(window, async move |this, cx| { let Some(fs) = fs else { return }; let workspaces = db - .recent_workspaces_on_disk(fs.as_ref()) + .recent_project_workspaces(fs.as_ref()) .await .log_err() .unwrap_or_default(); @@ -194,7 +194,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { cx: &mut Context>, ) -> Task<()> { let query = query.trim_start(); - let smart_case = query.chars().any(|c| c.is_uppercase()); + let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query); let is_empty_query = query.is_empty(); let current_workspace_id = self @@ -234,22 +234,13 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { }) .collect(); } else { - let mut matches = smol::block_on(fuzzy::match_strings( + self.filtered_workspaces = match_strings( &candidates, query, - smart_case, - true, + case, + fuzzy_nucleo::LengthPenalty::On, 100, - &Default::default(), - cx.background_executor().clone(), - )); - matches.sort_unstable_by(|a, b| { - b.score - .partial_cmp(&a.score) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.candidate_id.cmp(&b.candidate_id)) - }); - self.filtered_workspaces = matches; + ); } self.selected_index = 0; @@ -426,22 +417,36 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { create_new_window: false, }; - Button::new("open_local_folder", "Add Local Folders") - .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx)) + ButtonLike::new("open_local_folder") + .child( + h_flex() + .w_full() + .gap_1() + .justify_between() + .child(Label::new("Add Local Folders")) + .child(KeyBinding::for_action_in(&open_action, &focus_handle, cx)), + ) .on_click(cx.listener(move |_, _, window, cx| { window.dispatch_action(open_action.boxed_clone(), cx); cx.emit(DismissEvent); })) }) .child( - Button::new("open_remote_folder", "Add Remote Folder") - .key_binding(KeyBinding::for_action( - &OpenRemote { - from_existing_connection: false, - create_new_window: false, - }, - cx, - )) + ButtonLike::new("open_remote_folder") + .child( + h_flex() + .w_full() + .gap_1() + .justify_between() + .child(Label::new("Add Remote Folder")) + .child(KeyBinding::for_action( + &OpenRemote { + from_existing_connection: false, + create_new_window: false, + }, + cx, + )), + ) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action( OpenRemote { diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index c53dd7c3fb68bc..e0930fde365c11 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -24,7 +24,7 @@ pub struct WslPickerDismissed; pub(crate) struct WslPickerDelegate { selected_index: usize, distro_list: Option>, - matches: Vec, + matches: Vec, } impl WslPickerDelegate { @@ -39,7 +39,7 @@ impl WslPickerDelegate { pub fn selected_distro(&self) -> Option { self.matches .get(self.selected_index) - .map(|m| m.string.clone()) + .map(|m| m.string.to_string()) } } @@ -101,9 +101,9 @@ impl picker::PickerDelegate for WslPickerDelegate { &mut self, query: String, _window: &mut Window, - cx: &mut Context>, + _cx: &mut Context>, ) -> Task<()> { - use fuzzy::StringMatchCandidate; + use fuzzy_nucleo::StringMatchCandidate; let needs_fetch = self.distro_list.is_none(); if needs_fetch { @@ -121,16 +121,14 @@ impl picker::PickerDelegate for WslPickerDelegate { .collect::>(); let query = query.trim_start(); - let smart_case = query.chars().any(|c| c.is_uppercase()); - self.matches = smol::block_on(fuzzy::match_strings( - candidates.as_slice(), + let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query); + self.matches = fuzzy_nucleo::match_strings( + &candidates, query, - smart_case, - true, + case, + fuzzy_nucleo::LengthPenalty::On, 100, - &Default::default(), - cx.background_executor().clone(), - )); + ); self.matches.sort_unstable_by_key(|m| m.candidate_id); self.selected_index = self @@ -150,7 +148,7 @@ impl picker::PickerDelegate for WslPickerDelegate { if let Some(distro) = self.matches.get(self.selected_index) { cx.emit(WslDistroSelected { secondary, - distro: distro.string.clone(), + distro: distro.string.to_string(), }); } } diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index a101a3bda1cd6d..1a8337dcd7fdcd 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -22,7 +22,10 @@ use smol::fs; use std::{ net::IpAddr, path::{Path, PathBuf}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, time::Instant, }; use tempfile::TempDir; @@ -36,6 +39,9 @@ use util::{ pub(crate) struct SshRemoteConnection { socket: SshSocket, master_process: Mutex>, + /// Whether `kill()` has been called. Separate from `master_process` because + /// reused ControlMaster sessions start with `master_process` as `None`. + killed: AtomicBool, remote_binary_path: Option>, ssh_platform: RemotePlatform, ssh_path_style: PathStyle, @@ -268,7 +274,9 @@ impl AsMut for MasterProcess { #[async_trait(?Send)] impl RemoteConnection for SshRemoteConnection { async fn kill(&self) -> Result<()> { + self.killed.store(true, Ordering::Release); let Some(mut process) = self.master_process.lock().take() else { + log::debug!("no master process to kill (external ControlMaster session)"); return Ok(()); }; process.as_mut().kill().ok(); @@ -277,7 +285,7 @@ impl RemoteConnection for SshRemoteConnection { } fn has_been_killed(&self) -> bool { - self.master_process.lock().is_none() + self.killed.load(Ordering::Acquire) } fn connection_options(&self) -> RemoteConnectionOptions { @@ -507,6 +515,82 @@ impl RemoteConnection for SshRemoteConnection { } } +/// Check if the user already has an active SSH ControlMaster session for the +/// given destination. See: https://github.com/zed-industries/zed/issues/45271 +#[cfg(not(windows))] +async fn find_existing_control_master( + destination: &str, + additional_args: &[String], +) -> Option { + // Use `ssh -G` to resolve the user's effective SSH config for this host. + // This expands ControlPath tokens (%h, %p, %r, %C, etc.) into actual paths. + let output = match util::command::new_command("ssh") + .args(additional_args) + .arg("-G") + .arg(destination) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await + { + Ok(output) => output, + Err(e) => { + log::debug!("failed to run ssh -G: {e}"); + return None; + } + }; + + if !output.status.success() { + log::debug!("ssh -G failed for {destination}, skipping ControlMaster reuse"); + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let control_path = stdout.lines().find_map(|line| { + let path = line.strip_prefix("controlpath ")?.trim(); + if path == "none" || path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } + })?; + + // Verify the master is actually alive by sending a control command. + let check = match util::command::new_command("ssh") + .args(additional_args) + .args(["-O", "check"]) + .arg("-o") + .arg(format!("ControlPath={}", control_path.display())) + .arg(destination) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .await + { + Ok(output) => output, + Err(e) => { + log::debug!("failed to run ssh -O check: {e}"); + return None; + } + }; + + if check.status.success() { + log::info!( + "reusing existing SSH ControlMaster at {}", + control_path.display() + ); + Some(control_path) + } else { + log::debug!( + "ControlMaster socket at {} is not alive, creating new connection", + control_path.display() + ); + None + } +} + impl SshRemoteConnection { pub(crate) async fn new( connection_options: SshConnectionOptions, @@ -520,84 +604,145 @@ impl SshRemoteConnection { let temp_dir = tempfile::Builder::new() .prefix("zed-ssh-session") .tempdir()?; - let askpass_delegate = askpass::AskPassDelegate::new(cx, { - let delegate = delegate.clone(); - move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx) - }); - - let mut askpass = - askpass::AskPassSession::new(cx.background_executor().clone(), askpass_delegate) - .await?; - delegate.set_status(Some("Connecting"), cx); - - // Start the master SSH process, which does not do anything except for establish - // the connection and keep it open, allowing other ssh commands to reuse it - // via a control socket. + // On non-Windows, check if the user already has an active ControlMaster + // session for this host. If so, reuse it instead of prompting for auth. #[cfg(not(windows))] - let socket_path = temp_dir.path().join("ssh.sock"); + let reused_socket = + find_existing_control_master(&destination, &connection_options.additional_args()).await; - #[cfg(windows)] - let mut master_process = MasterProcess::new( - askpass.script_path().as_ref(), - connection_options.additional_args(), - &destination, - )?; #[cfg(not(windows))] - let mut master_process = MasterProcess::new( - askpass.script_path().as_ref(), - connection_options.additional_args(), - &socket_path, - &destination, - )?; + let (socket, master_process_option) = if let Some(reused_path) = reused_socket { + delegate.set_status(Some("Connecting (reusing session)"), cx); + log::info!("reusing existing ControlMaster, skipping authentication"); + let socket = SshSocket::new(connection_options, reused_path).await?; + (socket, None) + } else { + let askpass_delegate = askpass::AskPassDelegate::new(cx, { + let delegate = delegate.clone(); + move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx) + }); + + let mut askpass = + askpass::AskPassSession::new(cx.background_executor().clone(), askpass_delegate) + .await?; + + delegate.set_status(Some("Connecting"), cx); + + // Start the master SSH process, which does not do anything except + // for establish the connection and keep it open, allowing other ssh + // commands to reuse it via a control socket. + let socket_path = temp_dir.path().join("ssh.sock"); + let mut master_process = MasterProcess::new( + askpass.script_path().as_ref(), + connection_options.additional_args(), + &socket_path, + &destination, + )?; - let result = select_biased! { - result = askpass.run().fuse() => { - match result { - AskPassResult::CancelledByUser => { - master_process.as_mut().kill().ok(); - anyhow::bail!("SSH connection canceled") - } - AskPassResult::Timedout => { - anyhow::bail!("connecting to host timed out") + let result = select_biased! { + result = askpass.run().fuse() => { + match result { + AskPassResult::CancelledByUser => { + master_process.as_mut().kill().ok(); + anyhow::bail!("SSH connection canceled") + } + AskPassResult::Timedout => { + anyhow::bail!("connecting to host timed out") + } } } + _ = master_process.wait_connected().fuse() => { + anyhow::Ok(()) + } + }; + + if let Err(e) = result { + return Err(e.context("Failed to connect to host")); } - _ = master_process.wait_connected().fuse() => { - anyhow::Ok(()) + + if master_process.as_mut().try_status()?.is_some() { + let mut output = Vec::new(); + let mut stderr = master_process.as_mut().stderr.take().unwrap(); + stderr.read_to_end(&mut output).await?; + + let error_message = format!( + "failed to connect: {}", + String::from_utf8_lossy(&output).trim() + ); + anyhow::bail!(error_message); } + + let socket = SshSocket::new(connection_options, socket_path).await?; + drop(askpass); + (socket, Some(master_process)) }; - if let Err(e) = result { - return Err(e.context("Failed to connect to host")); - } + #[cfg(windows)] + let (socket, master_process_option) = { + let askpass_delegate = askpass::AskPassDelegate::new(cx, { + let delegate = delegate.clone(); + move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx) + }); + + let mut askpass = + askpass::AskPassSession::new(cx.background_executor().clone(), askpass_delegate) + .await?; + + delegate.set_status(Some("Connecting"), cx); + + let mut master_process = MasterProcess::new( + askpass.script_path().as_ref(), + connection_options.additional_args(), + &destination, + )?; + + let result = select_biased! { + result = askpass.run().fuse() => { + match result { + AskPassResult::CancelledByUser => { + master_process.as_mut().kill().ok(); + anyhow::bail!("SSH connection canceled") + } + AskPassResult::Timedout => { + anyhow::bail!("connecting to host timed out") + } + } + } + _ = master_process.wait_connected().fuse() => { + anyhow::Ok(()) + } + }; - if master_process.as_mut().try_status()?.is_some() { - let mut output = Vec::new(); - output.clear(); - let mut stderr = master_process.as_mut().stderr.take().unwrap(); - stderr.read_to_end(&mut output).await?; + if let Err(e) = result { + return Err(e.context("Failed to connect to host")); + } - let error_message = format!( - "failed to connect: {}", - String::from_utf8_lossy(&output).trim() - ); - anyhow::bail!(error_message); - } + if master_process.as_mut().try_status()?.is_some() { + let mut output = Vec::new(); + let mut stderr = master_process.as_mut().stderr.take().unwrap(); + stderr.read_to_end(&mut output).await?; - #[cfg(not(windows))] - let socket = SshSocket::new(connection_options, socket_path).await?; - #[cfg(windows)] - let socket = SshSocket::new( - connection_options, - askpass - .get_password() - .or_else(|| askpass::EncryptedPassword::try_from("").ok()) - .context("Failed to fetch askpass password")?, - cx.background_executor().clone(), - ) - .await?; - drop(askpass); + let error_message = format!( + "failed to connect: {}", + String::from_utf8_lossy(&output).trim() + ); + anyhow::bail!(error_message); + } + + let socket = SshSocket::new( + connection_options, + askpass + .get_password() + .or_else(|| askpass::EncryptedPassword::try_from("").ok()) + .context("Failed to fetch askpass password")?, + cx.background_executor().clone(), + ) + .await?; + drop(askpass); + + (socket, Some(master_process)) + }; let is_windows = socket.probe_is_windows().await; log::info!("Remote is windows: {}", is_windows); @@ -616,7 +761,8 @@ impl SshRemoteConnection { let mut this = Self { socket, - master_process: Mutex::new(Some(master_process)), + master_process: Mutex::new(master_process_option), + killed: AtomicBool::new(false), _temp_dir: temp_dir, remote_binary_path: None, ssh_path_style, diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 63e9b4b787230e..7b0fc0356a130d 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -1001,6 +1001,15 @@ impl HeadlessProject { "failed to spawn kernel process (command: {})", envelope.payload.command ))? + } else if let Some(venv_python) = working_directory + .as_ref() + .and_then(|wd| find_venv_python(wd)) + { + let path_str = venv_python.to_string_lossy().to_string(); + spawn_kernel(&path_str, &[]).context(format!( + "failed to spawn kernel process (venv: {})", + path_str + ))? } else { spawn_kernel("python3", &[]) .or_else(|_| spawn_kernel("python", &[])) @@ -1325,3 +1334,23 @@ fn prompt_to_proto( ), } } + +fn find_venv_python(working_directory: &str) -> Option { + let wd = std::path::Path::new(working_directory); + for dir_name in &[".venv", "venv", ".env", "env"] { + let venv_dir = wd.join(dir_name); + let has_pyvenv_cfg = venv_dir.join("pyvenv.cfg").is_file(); + let has_activate = venv_dir.join("bin").join("activate").is_file(); + if has_pyvenv_cfg || has_activate { + let python = venv_dir.join("bin").join("python"); + if python.is_file() { + return Some(python); + } + let python3 = venv_dir.join("bin").join("python3"); + if python3.is_file() { + return Some(python3); + } + } + } + None +} diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index c8876ed2328eb3..6f2c2e3f22369b 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2371,6 +2371,148 @@ async fn test_remote_external_agent_server( ); } +#[gpui::test] +async fn test_remote_apply_code_action_skips_unadvertised_command( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; + + fs.insert_tree( + path!("/code/project1/.zed"), + json!({ + "settings.json": r#" + { + "languages": {"Rust":{"language_servers":["rust-analyzer"]}}, + "lsp": { + "rust-analyzer": { + "binary": { + "path": "~/.cargo/bin/rust-analyzer" + } + } + } + }"# + }), + ) + .await; + + cx.update_entity(&project, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..Default::default() + }, + ..Default::default() + }); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + ..Default::default() + }, + ) + }); + + // Register the fake LSP with an empty execute_command_provider and a handler that panics + // if it is ever reached: commands not advertised by the server must be rejected by + // `apply_code_action` before dispatching to the language server. + let mut fake_lsp = server_cx.update(|cx| { + headless.read(cx).languages.register_fake_lsp_server( + LanguageServerName("rust-analyzer".into()), + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: Vec::new(), + ..Default::default() + }), + ..Default::default() + }, + Some(Box::new(|fake| { + fake.set_request_handler::( + |params, _| async move { + panic!( + "Unadvertised command {} must not reach the language server", + params.command + ); + }, + ); + })), + ) + }); + + cx.run_until_parked(); + + let worktree_id = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap() + .0 + .read_with(cx, |worktree, _| worktree.id()); + + cx.run_until_parked(); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let _fake_lsp = fake_lsp.next().await.unwrap(); + + let server_id = server_cx.read(|cx| { + *headless + .read(cx) + .lsp_store + .read(cx) + .as_local() + .unwrap() + .language_servers + .keys() + .next() + .unwrap() + }); + let buffer_id = cx.read(|cx| buffer.read(cx).remote_id()); + + let action = project::CodeAction { + server_id, + range: language::Anchor::min_min_range_for_buffer(buffer_id), + lsp_action: project::LspAction::Command(lsp::Command { + title: "\u{25b6}\u{fe0e} Run Tests".into(), + command: "rust-analyzer.runSingle".into(), + arguments: Some(vec![json!({"label": "test-mod tests"})]), + }), + resolved: true, + }; + + let transaction = project + .update(cx, |project, cx| { + project.apply_code_action(buffer.clone(), action, true, cx) + }) + .await + .expect("Unadvertised command must not be forwarded to executeCommand"); + assert_eq!(transaction.0.len(), 0); +} + pub async fn init_test( server_fs: &Arc, cx: &mut TestAppContext, diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index ce68a4d30285fe..32db5785884eaa 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -484,7 +484,7 @@ where PopoverMenu::new("kernel-switcher") .menu(move |_window, _cx| Some(picker_view.clone())) .trigger_with_tooltip(self.trigger, self.tooltip) - .attach(gpui::Corner::BottomLeft) + .attach(gpui::Anchor::BottomLeft) .when_some(self.handle, |menu, handle| menu.with_handle(handle)) } } diff --git a/crates/repl/src/kernels/mod.rs b/crates/repl/src/kernels/mod.rs index 9f08876cd39f4b..737893b09e44b0 100644 --- a/crates/repl/src/kernels/mod.rs +++ b/crates/repl/src/kernels/mod.rs @@ -31,6 +31,62 @@ use runtimelib::{ use ui::{Icon, IconName, SharedString}; use util::rel_path::RelPath; +pub(crate) const VENV_DIR_NAMES: &[&str] = &[".venv", "venv", ".env", "env"]; + +// Build a POSIX shell script that attempts to find and exec the best Python binary to run with the given arguments. +pub(crate) fn build_python_exec_shell_script( + python_args: &str, + cd_command: &str, + env_command: &str, +) -> String { + let venv_dirs = VENV_DIR_NAMES.join(" "); + format!( + "set -e; \ + {cd_command}\ + {env_command}\ + for venv_dir in {venv_dirs}; do \ + if [ -f \"$venv_dir/pyvenv.cfg\" ] || [ -f \"$venv_dir/bin/activate\" ]; then \ + if [ -x \"$venv_dir/bin/python\" ]; then \ + exec \"$venv_dir/bin/python\" {python_args}; \ + elif [ -x \"$venv_dir/bin/python3\" ]; then \ + exec \"$venv_dir/bin/python3\" {python_args}; \ + fi; \ + fi; \ + done; \ + if command -v python3 >/dev/null 2>&1; then \ + exec python3 {python_args}; \ + elif command -v python >/dev/null 2>&1; then \ + exec python {python_args}; \ + else \ + echo 'Error: Python not found in virtual environment or PATH' >&2; \ + exit 127; \ + fi" + ) +} + +/// Build a POSIX shell script that outputs the best Python binary. +#[cfg(target_os = "windows")] +pub(crate) fn build_python_discovery_shell_script() -> String { + let venv_dirs = VENV_DIR_NAMES.join(" "); + format!( + "for venv_dir in {venv_dirs}; do \ + if [ -f \"$venv_dir/pyvenv.cfg\" ] || [ -f \"$venv_dir/bin/activate\" ]; then \ + if [ -x \"$venv_dir/bin/python\" ]; then \ + echo \"$venv_dir/bin/python\"; exit 0; \ + elif [ -x \"$venv_dir/bin/python3\" ]; then \ + echo \"$venv_dir/bin/python3\"; exit 0; \ + fi; \ + fi; \ + done; \ + if command -v python3 >/dev/null 2>&1; then \ + echo python3; exit 0; \ + elif command -v python >/dev/null 2>&1; then \ + echo python; exit 0; \ + fi; \ + exit 1" + ) +} + pub fn start_kernel_tasks( session: Entity, iopub_socket: ClientIoPubConnection, @@ -542,49 +598,47 @@ pub fn python_env_kernel_specifications( }; if let (Some(distro), Some(internal_path)) = (distro, internal_path) { - let python_path = format!("{}/.venv/bin/python", internal_path); - let check = util::command::new_command("wsl") - .args(&["-d", distro, "test", "-f", &python_path]) + let discovery_script = build_python_discovery_shell_script(); + let script = format!( + "cd {} && {}", + shlex::try_quote(&internal_path) + .unwrap_or(std::borrow::Cow::Borrowed(&internal_path)), + discovery_script + ); + let output = util::command::new_command("wsl") + .arg("-d") + .arg(distro) + .arg("bash") + .arg("-l") + .arg("-c") + .arg(&script) .output() .await; - if check.is_ok() && check.unwrap().status.success() { - let default_kernelspec = JupyterKernelspec { - argv: vec![ - python_path.clone(), - "-m".to_string(), - "ipykernel_launcher".to_string(), - "-f".to_string(), - "{connection_file}".to_string(), - ], - display_name: format!("WSL: {} (.venv)", distro), - language: "python".to_string(), - interrupt_mode: None, - metadata: None, - env: None, - }; - - kernel_specs.push(KernelSpecification::WslRemote(WslKernelSpecification { - name: format!("WSL: {} (.venv)", distro), - kernelspec: default_kernelspec, - distro: distro.to_string(), - })); - } else { - let check_system = util::command::new_command("wsl") - .args(&["-d", distro, "command", "-v", "python3"]) - .output() - .await; + if let Ok(output) = output { + if output.status.success() { + let python_cmd = + String::from_utf8_lossy(&output.stdout).trim().to_string(); + let (python_path, display_suffix) = if python_cmd.contains('/') { + let venv_name = python_cmd.split('/').next().unwrap_or("venv"); + ( + format!("{}/{}", internal_path, python_cmd), + format!("({})", venv_name), + ) + } else { + (python_cmd, "(System)".to_string()) + }; - if check_system.is_ok() && check_system.unwrap().status.success() { + let display_name = format!("WSL: {} {}", distro, display_suffix); let default_kernelspec = JupyterKernelspec { argv: vec![ - "python3".to_string(), + python_path, "-m".to_string(), "ipykernel_launcher".to_string(), "-f".to_string(), "{connection_file}".to_string(), ], - display_name: format!("WSL: {} (System)", distro), + display_name: display_name.clone(), language: "python".to_string(), interrupt_mode: None, metadata: None, @@ -593,7 +647,7 @@ pub fn python_env_kernel_specifications( kernel_specs.push(KernelSpecification::WslRemote( WslKernelSpecification { - name: format!("WSL: {} (System)", distro), + name: display_name, kernelspec: default_kernelspec, distro: distro.to_string(), }, diff --git a/crates/repl/src/kernels/remote_kernels.rs b/crates/repl/src/kernels/remote_kernels.rs index 8315f95833ccb1..69a41e85accd9b 100644 --- a/crates/repl/src/kernels/remote_kernels.rs +++ b/crates/repl/src/kernels/remote_kernels.rs @@ -13,7 +13,7 @@ use super::{KernelSession, RunningKernel}; use anyhow::Result; use jupyter_websocket_client::{ JupyterWebSocket, JupyterWebSocketReader, JupyterWebSocketWriter, KernelLaunchRequest, - KernelSpecsResponse, RemoteServer, + KernelSpecsResponse, ProtocolMode, RemoteServer, }; use std::{fmt::Debug, sync::Arc}; @@ -173,8 +173,10 @@ impl RemoteRunningKernel { let (ws_stream, _response) = response?; - let kernel_socket = JupyterWebSocket { inner: ws_stream }; - + let kernel_socket = JupyterWebSocket { + inner: ws_stream, + protocol_mode: ProtocolMode::Json, + }; let (mut w, mut r): (JupyterWebSocketWriter, JupyterWebSocketReader) = kernel_socket.split(); diff --git a/crates/repl/src/kernels/wsl_kernel.rs b/crates/repl/src/kernels/wsl_kernel.rs index be76d7ddccb7f1..b4f6d356bf2f01 100644 --- a/crates/repl/src/kernels/wsl_kernel.rs +++ b/crates/repl/src/kernels/wsl_kernel.rs @@ -1,5 +1,6 @@ use super::{ - KernelSession, KernelSpecification, RunningKernel, WslKernelSpecification, start_kernel_tasks, + KernelSession, KernelSpecification, RunningKernel, WslKernelSpecification, + build_python_exec_shell_script, start_kernel_tasks, }; use anyhow::{Context as _, Result}; use futures::{ @@ -228,8 +229,6 @@ impl WslRunningKernel { kernel_args.extend(resolved_argv.iter().cloned()); let shell_command = if needs_python_resolution { - // 1. Check for .venv/bin/python or .venv/bin/python3 in working directory - // 2. Fall back to system python3 or python let rest_args: Vec = resolved_argv.iter().skip(1).cloned().collect(); let arg_string = quote_posix_shell_arguments(&rest_args)?; let set_env_command = if env_assignments.is_empty() { @@ -245,34 +244,8 @@ impl WslRunningKernel { } else { String::new() }; - // TODO: find a better way to debug missing python issues in WSL - - format!( - "set -e; \ - {} \ - {} \ - echo \"Working directory: $(pwd)\" >&2; \ - if [ -x .venv/bin/python ]; then \ - echo \"Found .venv/bin/python\" >&2; \ - exec .venv/bin/python {}; \ - elif [ -x .venv/bin/python3 ]; then \ - echo \"Found .venv/bin/python3\" >&2; \ - exec .venv/bin/python3 {}; \ - elif command -v python3 >/dev/null 2>&1; then \ - echo \"Found system python3\" >&2; \ - exec python3 {}; \ - elif command -v python >/dev/null 2>&1; then \ - echo \"Found system python\" >&2; \ - exec python {}; \ - else \ - echo 'Error: Python not found in .venv or PATH' >&2; \ - echo 'Contents of current directory:' >&2; \ - ls -la >&2; \ - echo 'PATH:' \"$PATH\" >&2; \ - exit 127; \ - fi", - cd_command, set_env_command, arg_string, arg_string, arg_string, arg_string - ) + + build_python_exec_shell_script(&arg_string, &cd_command, &set_env_command) } else { let args_string = quote_posix_shell_arguments(&resolved_argv)?; diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index cb8f1d51103fca..c4c651b50b5645 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -98,6 +98,11 @@ pub enum Cell { Raw(Entity), } +pub(crate) enum MovementDirection { + Start, + End, +} + fn convert_outputs( outputs: &Vec, window: &mut Window, @@ -223,6 +228,52 @@ impl Cell { })), } } + + pub(crate) fn move_to(&self, direction: MovementDirection, window: &mut Window, cx: &mut App) { + fn move_in_editor( + editor: &Entity, + direction: MovementDirection, + window: &mut Window, + cx: &mut App, + ) { + editor.update(cx, |editor, cx| { + match direction { + MovementDirection::Start => { + editor.move_to_beginning(&Default::default(), window, cx); + } + MovementDirection::End => { + editor.move_to_end(&Default::default(), window, cx); + } + } + editor.focus_handle(cx).focus(window, cx); + }) + } + + match self { + Cell::Code(cell) => { + cell.update(cx, |cell, cx| { + move_in_editor(&cell.editor, direction, window, cx) + }); + } + Cell::Markdown(cell) => { + cell.update(cx, |cell, cx| { + cell.set_editing(true); + move_in_editor(&cell.editor, direction, window, cx); + + cx.notify(); + }); + } + _ => {} + } + } + + pub(crate) fn editor<'a>(&'a self, cx: &'a App) -> Option<&'a Entity> { + match self { + Cell::Code(cell) => Some(cell.read(cx).editor()), + Cell::Markdown(cell) => Some(cell.read(cx).editor()), + _ => None, + } + } } pub trait RenderableCell: Render { @@ -620,7 +671,7 @@ impl CodeCell { let buffer = cx.new(|cx| Buffer::local(source.clone(), cx)); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); - let editor_view = cx.new(|cx| { + let editor = cx.new(|cx| { let mut editor = Editor::new( EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, @@ -643,6 +694,7 @@ impl CodeCell { }; editor.disable_mouse_wheel_zoom(); + editor.disable_scrollbars_and_minimap(window, cx); editor.set_show_gutter(false, cx); editor.set_text_style_refinement(refinement); editor.set_use_modal_editing(true); @@ -661,7 +713,7 @@ impl CodeCell { metadata, execution_count: None, source, - editor: editor_view, + editor, outputs: Vec::new(), selected: false, cell_position: None, diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index ce0b6f598971ec..1cb876046dd380 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -35,6 +35,7 @@ use crate::kernels::{ Kernel, KernelSession, KernelSpecification, KernelStatus, LocalKernelSpecification, NativeRunningKernel, RemoteRunningKernel, SshRunningKernel, WslRunningKernel, }; +use crate::notebook::MovementDirection; use crate::repl_store::ReplStore; use picker::Picker; @@ -54,6 +55,12 @@ pub(crate) enum NotebookMode { Edit, } +#[derive(PartialEq, Eq)] +enum SelectionMode { + SelectOnly, + SelectAndMove, +} + pub(crate) const MAX_TEXT_BLOCK_WIDTH: f32 = 9999.0; pub(crate) const SMALL_SPACING_SIZE: f32 = 8.0; pub(crate) const MEDIUM_SPACING_SIZE: f32 = 12.0; @@ -84,14 +91,11 @@ pub struct NotebookEditor { languages: Arc, project: Entity, worktree_id: project::WorktreeId, - focus_handle: FocusHandle, notebook_item: Entity, notebook_language: Shared>>>, - remote_id: Option, cell_list: ListState, - notebook_mode: NotebookMode, selected_cell_index: usize, cell_order: Vec, @@ -206,13 +210,14 @@ impl NotebookEditor { cell_order: cell_order.clone(), original_cell_order: cell_order.clone(), cell_map: cell_map.clone(), - kernel: Kernel::Shutdown, // TODO: use recommended kernel after the implementation is done in repl + kernel: Kernel::Shutdown, kernel_specification: None, execution_requests: HashMap::default(), kernel_picker_handle: PopoverMenuHandle::default(), }; editor.launch_kernel(window, cx); editor.refresh_language(cx); + editor.refresh_kernelspecs(cx); cx.subscribe(¬ebook_item, |this, _item, _event, cx| { this.refresh_language(cx); @@ -222,6 +227,18 @@ impl NotebookEditor { editor } + fn refresh_kernelspecs(&mut self, cx: &mut Context) { + let store = ReplStore::global(cx); + let project = self.project.clone(); + let worktree_id = self.worktree_id; + + let refresh_task = store.update(cx, |store, cx| { + store.refresh_python_kernelspecs(worktree_id, &project, cx) + }); + + cx.background_spawn(refresh_task).detach_and_log_err(cx); + } + fn refresh_language(&mut self, cx: &mut Context) { let notebook_language = self.notebook_item.read(cx).notebook_language(); let task = cx.spawn(async move |this, cx| { @@ -313,8 +330,13 @@ impl NotebookEditor { } fn launch_kernel(&mut self, window: &mut Window, cx: &mut Context) { - // use default Python kernel if no specification is set - let spec = self.kernel_specification.clone().unwrap_or_else(|| { + let spec = self.kernel_specification.clone().or_else(|| { + ReplStore::global(cx) + .read(cx) + .active_kernelspec(self.worktree_id, None, cx) + }); + + let spec = spec.unwrap_or_else(|| { KernelSpecification::Jupyter(LocalKernelSpecification { name: "python3".to_string(), path: PathBuf::from("python3"), @@ -523,6 +545,12 @@ impl NotebookEditor { } } + fn get_selected_cell(&self) -> Option<&Cell> { + self.cell_order + .get(self.selected_cell_index) + .and_then(|cell_id| self.cell_map.get(cell_id)) + } + fn has_outputs(&self, window: &mut Window, cx: &mut Context) -> bool { self.cell_map.values().any(|cell| { if let Cell::Code(code_cell) = cell { @@ -847,9 +875,10 @@ impl NotebookEditor { } } - pub fn select_next( + fn select_next( &mut self, _: &menu::SelectNext, + selection_mode: SelectionMode, window: &mut Window, cx: &mut Context, ) { @@ -862,13 +891,21 @@ impl NotebookEditor { index + 1 }; self.set_selected_index(ix, true, window, cx); + + if selection_mode == SelectionMode::SelectAndMove + && let Some(cell) = self.get_selected_cell() + { + cell.move_to(MovementDirection::Start, window, cx); + } + cx.notify(); } } - pub fn select_previous( + fn select_previous( &mut self, _: &menu::SelectPrevious, + selection_mode: SelectionMode, window: &mut Window, cx: &mut Context, ) { @@ -877,6 +914,13 @@ impl NotebookEditor { let index = self.selected_index(); let ix = if index == 0 { 0 } else { index - 1 }; self.set_selected_index(ix, true, window, cx); + + if selection_mode == SelectionMode::SelectAndMove + && let Some(cell) = self.get_selected_cell() + { + cell.move_to(MovementDirection::End, window, cx); + } + cx.notify(); } } @@ -1298,82 +1342,37 @@ impl Render for NotebookEditor { .on_action(cx.listener(|this, action, window, cx| { this.handle_enter_command_mode(action, window, cx) })) - .on_action(cx.listener(|this, action, window, cx| this.select_next(action, window, cx))) - .on_action( - cx.listener(|this, action, window, cx| this.select_previous(action, window, cx)), - ) - .on_action( - cx.listener(|this, action, window, cx| this.select_first(action, window, cx)), - ) - .on_action(cx.listener(|this, action, window, cx| this.select_last(action, window, cx))) - .on_action(cx.listener(|this, _: &MoveUp, window, cx| { - this.select_previous(&menu::SelectPrevious, window, cx); - if let Some(cell_id) = this.cell_order.get(this.selected_cell_index) { - if let Some(cell) = this.cell_map.get(cell_id) { - match cell { - Cell::Code(cell) => { - let editor = cell.read(cx).editor().clone(); - editor.update(cx, |editor, cx| { - editor.move_to_end(&Default::default(), window, cx); - }); - editor.focus_handle(cx).focus(window, cx); - } - Cell::Markdown(cell) => { - cell.update(cx, |cell, cx| { - cell.set_editing(true); - cx.notify(); - }); - let editor = cell.read(cx).editor().clone(); - editor.update(cx, |editor, cx| { - editor.move_to_end(&Default::default(), window, cx); - }); - editor.focus_handle(cx).focus(window, cx); - } - _ => {} - } - } - } + .on_action(cx.listener(|this, action, window, cx| { + this.select_next(action, SelectionMode::SelectOnly, window, cx) })) + .on_action(cx.listener(|this, action, window, cx| { + this.select_previous(action, SelectionMode::SelectOnly, window, cx) + })) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(|this, _: &MoveDown, window, cx| { - this.select_next(&menu::SelectNext, window, cx); - if let Some(cell_id) = this.cell_order.get(this.selected_cell_index) { - if let Some(cell) = this.cell_map.get(cell_id) { - match cell { - Cell::Code(cell) => { - let editor = cell.read(cx).editor().clone(); - editor.update(cx, |editor, cx| { - editor.move_to_beginning(&Default::default(), window, cx); - }); - editor.focus_handle(cx).focus(window, cx); - } - Cell::Markdown(cell) => { - cell.update(cx, |cell, cx| { - cell.set_editing(true); - cx.notify(); - }); - let editor = cell.read(cx).editor().clone(); - editor.update(cx, |editor, cx| { - editor.move_to_beginning(&Default::default(), window, cx); - }); - editor.focus_handle(cx).focus(window, cx); - } - _ => {} - } - } - } + this.select_next( + &Default::default(), + SelectionMode::SelectAndMove, + window, + cx, + ); + })) + .on_action(cx.listener(|this, _: &MoveUp, window, cx| { + this.select_previous( + &Default::default(), + SelectionMode::SelectAndMove, + window, + cx, + ); })) .on_action(cx.listener(|this, _: &NotebookMoveDown, window, cx| { - let Some(cell_id) = this.cell_order.get(this.selected_cell_index) else { - return; - }; - let Some(cell) = this.cell_map.get(cell_id) else { + let Some(cell) = this.get_selected_cell() else { return; }; - let editor = match cell { - Cell::Code(cell) => cell.read(cx).editor().clone(), - Cell::Markdown(cell) => cell.read(cx).editor().clone(), - _ => return, + let Some(editor) = cell.editor(cx).cloned() else { + return; }; let is_at_last_line = editor.update(cx, |editor, cx| { @@ -1391,32 +1390,12 @@ impl Render for NotebookEditor { }); if is_at_last_line { - this.select_next(&menu::SelectNext, window, cx); - if let Some(cell_id) = this.cell_order.get(this.selected_cell_index) { - if let Some(cell) = this.cell_map.get(cell_id) { - match cell { - Cell::Code(cell) => { - let editor = cell.read(cx).editor().clone(); - editor.update(cx, |editor, cx| { - editor.move_to_beginning(&Default::default(), window, cx); - }); - editor.focus_handle(cx).focus(window, cx); - } - Cell::Markdown(cell) => { - cell.update(cx, |cell, cx| { - cell.set_editing(true); - cx.notify(); - }); - let editor = cell.read(cx).editor().clone(); - editor.update(cx, |editor, cx| { - editor.move_to_beginning(&Default::default(), window, cx); - }); - editor.focus_handle(cx).focus(window, cx); - } - _ => {} - } - } - } + this.select_next( + &Default::default(), + SelectionMode::SelectAndMove, + window, + cx, + ); } else { editor.update(cx, |editor, cx| { editor.move_down(&Default::default(), window, cx); @@ -1424,17 +1403,12 @@ impl Render for NotebookEditor { } })) .on_action(cx.listener(|this, _: &NotebookMoveUp, window, cx| { - let Some(cell_id) = this.cell_order.get(this.selected_cell_index) else { - return; - }; - let Some(cell) = this.cell_map.get(cell_id) else { + let Some(cell) = this.get_selected_cell() else { return; }; - let editor = match cell { - Cell::Code(cell) => cell.read(cx).editor().clone(), - Cell::Markdown(cell) => cell.read(cx).editor().clone(), - _ => return, + let Some(editor) = cell.editor(cx).cloned() else { + return; }; let is_at_first_line = editor.update(cx, |editor, cx| { @@ -1451,32 +1425,12 @@ impl Render for NotebookEditor { }); if is_at_first_line { - this.select_previous(&menu::SelectPrevious, window, cx); - if let Some(cell_id) = this.cell_order.get(this.selected_cell_index) { - if let Some(cell) = this.cell_map.get(cell_id) { - match cell { - Cell::Code(cell) => { - let editor = cell.read(cx).editor().clone(); - editor.update(cx, |editor, cx| { - editor.move_to_end(&Default::default(), window, cx); - }); - editor.focus_handle(cx).focus(window, cx); - } - Cell::Markdown(cell) => { - cell.update(cx, |cell, cx| { - cell.set_editing(true); - cx.notify(); - }); - let editor = cell.read(cx).editor().clone(); - editor.update(cx, |editor, cx| { - editor.move_to_end(&Default::default(), window, cx); - }); - editor.focus_handle(cx).focus(window, cx); - } - _ => {} - } - } - } + this.select_previous( + &Default::default(), + SelectionMode::SelectAndMove, + window, + cx, + ); } else { editor.update(cx, |editor, cx| { editor.move_up(&Default::default(), window, cx); diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 425f7d2aa3d9e9..e5105081ca7af7 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -8,9 +8,7 @@ use gpui::{ WindowOptions, actions, point, size, transparent_black, }; use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; -use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role, -}; +use language_model::{ConfiguredModel, LanguageModelRegistry}; use picker::{Picker, PickerDelegate}; use platform_title_bar::PlatformTitleBar; use release_channel::ReleaseChannel; @@ -165,8 +163,6 @@ pub struct RulesLibrary { struct RuleEditor { title_editor: Entity, body_editor: Entity, - token_count: Option, - pending_token_count: Task>, next_title_and_body_to_save: Option<(String, Rope)>, pending_save: Option>>, _subscriptions: Vec, @@ -785,13 +781,10 @@ impl RulesLibrary { body_editor, next_title_and_body_to_save: None, pending_save: None, - token_count: None, - pending_token_count: Task::ready(None), _subscriptions, }, ); this.set_active_rule(Some(prompt_id), window, cx); - this.count_tokens(prompt_id, window, cx); } Err(error) => { // TODO: we should show the error in the UI. @@ -1019,7 +1012,6 @@ impl RulesLibrary { match event { EditorEvent::BufferEdited => { self.save_rule(prompt_id, window, cx); - self.count_tokens(prompt_id, window, cx); } EditorEvent::Blurred => { title_editor.update(cx, |title_editor, cx| { @@ -1049,7 +1041,6 @@ impl RulesLibrary { match event { EditorEvent::BufferEdited => { self.save_rule(prompt_id, window, cx); - self.count_tokens(prompt_id, window, cx); } EditorEvent::Blurred => { body_editor.update(cx, |body_editor, cx| { @@ -1068,59 +1059,6 @@ impl RulesLibrary { } } - fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context) { - let Some(ConfiguredModel { model, .. }) = - LanguageModelRegistry::read_global(cx).default_model() - else { - return; - }; - if let Some(rule) = self.rule_editors.get_mut(&prompt_id) { - let editor = &rule.body_editor.read(cx); - let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx); - let body = buffer.as_rope().clone(); - rule.pending_token_count = cx.spawn_in(window, async move |this, cx| { - async move { - const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1); - - cx.background_executor().timer(DEBOUNCE_TIMEOUT).await; - let token_count = cx - .update(|_, cx| { - model.count_tokens( - LanguageModelRequest { - thread_id: None, - prompt_id: None, - intent: None, - messages: vec![LanguageModelRequestMessage { - role: Role::System, - content: vec![body.to_string().into()], - cache: false, - reasoning_details: None, - }], - tools: Vec::new(), - tool_choice: None, - stop: Vec::new(), - temperature: None, - thinking_allowed: true, - thinking_effort: None, - speed: None, - }, - cx, - ) - })? - .await?; - - this.update(cx, |this, cx| { - let rule_editor = this.rule_editors.get_mut(&prompt_id).unwrap(); - rule_editor.token_count = Some(token_count); - cx.notify(); - }) - } - .log_err() - .await - }); - } - } - fn render_rule_list(&mut self, cx: &mut Context) -> impl IntoElement { v_flex() .id("rule-list") @@ -1293,8 +1231,6 @@ impl RulesLibrary { let rule_metadata = self.store.read(cx).metadata(prompt_id)?; let rule_editor = &self.rule_editors[&prompt_id]; let focus_handle = rule_editor.body_editor.focus_handle(cx); - let registry = LanguageModelRegistry::read_global(cx); - let model = registry.default_model().map(|default| default.model); let built_in = prompt_id.is_built_in(); Some( @@ -1318,52 +1254,17 @@ impl RulesLibrary { built_in, cx, )) - .child( - h_flex() - .h_full() - .flex_shrink_0() - .children(rule_editor.token_count.map(|token_count| { - let token_count: SharedString = - token_count.to_string().into(); - let label_token_count: SharedString = - token_count.to_string().into(); - - div() - .id("token_count") - .mr_1() - .flex_shrink_0() - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Token Estimation", - None, - format!( - "Model: {}", - model - .as_ref() - .map(|model| model.name().0) - .unwrap_or_default() - ), - cx, - ) - }) - .child( - Label::new(format!( - "{} tokens", - label_token_count - )) - .color(Color::Muted), - ) - })) - .map(|this| { - if built_in { - this.child(self.render_built_in_rule_controls()) - } else { - this.child(self.render_regular_rule_controls( - rule_metadata.default, - )) - } - }), - ), + .child(h_flex().h_full().flex_shrink_0().map(|this| { + if built_in { + this.child(self.render_built_in_rule_controls()) + } else { + this.child( + self.render_regular_rule_controls( + rule_metadata.default, + ), + ) + } + })), ) .child( div() diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index ca5acb53c084ca..9dcef1b60e1771 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -56,7 +56,9 @@ use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar}; const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50; -pub use zed_actions::buffer_search::{Deploy, DeployReplace, Dismiss, FocusEditor}; +pub use zed_actions::buffer_search::{ + Deploy, DeployReplace, Dismiss, FocusEditor, UseSelectionForFind, +}; pub enum Event { UpdateLocation, @@ -839,6 +841,16 @@ impl BufferSearchBar { this.deploy(&Deploy::replace(), window, cx); } })); + registrar.register_handler(ForDeployed( + |this, action: &UseSelectionForFind, window, cx| { + this.use_selection_for_find(action, window, cx); + }, + )); + registrar.register_handler(ForDismissed( + |this, action: &UseSelectionForFind, window, cx| { + this.use_selection_for_find(action, window, cx); + }, + )); } pub fn new( @@ -991,7 +1003,7 @@ impl BufferSearchBar { let mut handle = self.query_editor.focus_handle(cx); let mut select_query = true; - let has_seed_text = self.query_suggestion(window, cx).is_some(); + let has_seed_text = self.query_suggestion(false, window, cx).is_some(); if deploy.replace_enabled && has_seed_text { handle = self.replacement_editor.focus_handle(cx); select_query = false; @@ -1100,7 +1112,7 @@ impl BufferSearchBar { } pub fn search_suggested(&mut self, window: &mut Window, cx: &mut Context) { - let search = self.query_suggestion(window, cx).map(|suggestion| { + let search = self.query_suggestion(false, window, cx).map(|suggestion| { self.search(&suggestion, Some(self.default_options), true, window, cx) }); @@ -1154,12 +1166,13 @@ impl BufferSearchBar { pub fn query_suggestion( &mut self, + ignore_settings: bool, window: &mut Window, cx: &mut Context, ) -> Option { self.active_searchable_item .as_ref() - .map(|searchable_item| searchable_item.query_suggestion(window, cx)) + .map(|searchable_item| searchable_item.query_suggestion(ignore_settings, window, cx)) .filter(|suggestion| !suggestion.is_empty()) } @@ -1224,6 +1237,26 @@ impl BufferSearchBar { )); } + pub fn use_selection_for_find( + &mut self, + _: &UseSelectionForFind, + window: &mut Window, + cx: &mut Context, + ) { + let Some(search_text) = self.query_suggestion(true, window, cx) else { + return; + }; + self.query_editor.update(cx, |query_editor, cx| { + query_editor.buffer().update(cx, |query_buffer, cx| { + let len = query_buffer.len(cx); + query_buffer.edit([(MultiBufferOffset(0)..len, search_text)], None, cx); + }); + }); + #[cfg(target_os = "macos")] + self.update_find_pasteboard(cx); + cx.notify(); + } + pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context) { if let Some(active_editor) = self.active_searchable_item.as_ref() { let handle = active_editor.item_focus_handle(cx); diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 441c1cf47d1195..a4ea449ffc87df 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1181,7 +1181,7 @@ impl ProjectSearchView { } let editor = item.act_as::(cx)?; - let query = editor.query_suggestion(window, cx); + let query = editor.query_suggestion(false, window, cx); if query.is_empty() { None } else { Some(query) } }); @@ -5339,6 +5339,83 @@ pub mod tests { }); } + #[gpui::test] + async fn test_replace_all_with_shared_heading_prefix_does_not_loop(cx: &mut TestAppContext) { + init_test(cx); + + let search_text = "## この日に作成したノート"; + let replacement_text = "## この日に関連するノート"; + + let file_a_before = format!("{search_text}\n- a\n\n{search_text}\n- b\n"); + let file_b_before = format!("# Daily\n\n{search_text}\n- c\n"); + let file_a_after = format!("{replacement_text}\n- a\n\n{replacement_text}\n- b\n"); + let file_b_after = format!("# Daily\n\n{replacement_text}\n- c\n"); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.md": file_a_before, + "b.md": file_b_before, + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let worktree_id = project.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + 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 search = cx.new(|cx| ProjectSearch::new(project.clone(), cx)); + let search_view = cx.add_window(|window, cx| { + ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) + }); + + perform_search(search_view, search_text, cx); + + search_view + .update(cx, |search_view, _window, cx| { + assert_eq!(search_view.entity.read(cx).match_ranges.len(), 3); + }) + .unwrap(); + + search_view + .update(cx, |search_view, window, cx| { + search_view.replacement_editor.update(cx, |editor, cx| { + editor.set_text(replacement_text, window, cx); + }); + search_view.replace_all(&ReplaceAll, window, cx); + }) + .unwrap(); + + cx.run_until_parked(); + + let buffer_a = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("a.md")), cx) + }) + .await + .unwrap(); + let buffer_b = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("b.md")), cx) + }) + .await + .unwrap(); + + assert_eq!( + buffer_a.read_with(cx, |buffer, _| buffer.text()), + file_a_after + ); + assert_eq!( + buffer_b.read_with(cx, |buffer, _| buffer.text()), + file_b_after + ); + } + #[gpui::test] async fn test_smartcase_overrides_explicit_case_sensitive(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index f4529e305a4428..82c6f6528b685e 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -19,6 +19,7 @@ use util::{ }; use crate::SettingsAssets; +use settings_content::{ActionName, ActionWithArguments}; use settings_json::{ append_top_level_array_value_in_json_text, parse_json_with_comments, replace_top_level_array_value_in_json_text, @@ -698,10 +699,17 @@ impl KeymapFile { "minItems": 2, "maxItems": 2 }); - let mut keymap_action_alternatives = vec![ - empty_action_name.clone(), - empty_action_name_with_input.clone(), - ]; + + let mut keymap_deprecations = deprecations.clone(); + keymap_deprecations.insert(NoAction.name(), "null"); + let action_name_schema = ActionName::build_schema( + action_schemas.iter().map(|(name, _)| *name), + action_documentation, + &keymap_deprecations, + deprecation_messages, + ); + + let mut action_with_arguments_alternatives = vec![empty_action_name_with_input.clone()]; let mut unbind_target_action_alternatives = vec![empty_action_name, empty_action_name_with_input]; @@ -731,7 +739,6 @@ impl KeymapFile { if let Some(description) = &description { add_description(&mut plain_action, description); } - keymap_action_alternatives.push(plain_action.clone()); if include_in_unbind_target_schema { unbind_target_action_alternatives.push(plain_action); } @@ -760,7 +767,7 @@ impl KeymapFile { "minItems": 2, "maxItems": 2 }); - keymap_action_alternatives.push(action_with_input.clone()); + action_with_arguments_alternatives.push(action_with_input.clone()); if include_in_unbind_target_schema { unbind_target_action_alternatives.push(action_with_input); } @@ -789,7 +796,7 @@ impl KeymapFile { "This action does not take input - just the action name string should be used." .to_string(), ); - keymap_action_alternatives.push(actions_with_empty_input); + action_with_arguments_alternatives.push(actions_with_empty_input); } if !empty_schema_unbind_target_action_names.is_empty() { @@ -812,17 +819,22 @@ impl KeymapFile { unbind_target_action_alternatives.push(actions_with_empty_input); } - // Placing null first causes json-language-server to default assuming actions should be - // null, so place it last. - keymap_action_alternatives.push(json_schema!({ - "type": "null" - })); + generator.definitions_mut().insert( + ActionName::schema_name().to_string(), + action_name_schema.to_value(), + ); + generator.definitions_mut().insert( + ActionWithArguments::schema_name().to_string(), + json!({ "anyOf": action_with_arguments_alternatives }), + ); generator.definitions_mut().insert( KeymapAction::schema_name().to_string(), - json!({ - "anyOf": keymap_action_alternatives - }), + json!({ "anyOf": [ + { "$ref": format!("#/$defs/{}", ActionName::schema_name().to_string()) }, + { "$ref": format!("#/$defs/{}", ActionWithArguments::schema_name().to_string()) }, + { "type": "null" } + ] }), ); generator.definitions_mut().insert( UnbindTargetAction::schema_name().to_string(), diff --git a/crates/settings/src/settings_file.rs b/crates/settings/src/settings_file.rs index efc5e45130e244..8b4187da0093c7 100644 --- a/crates/settings/src/settings_file.rs +++ b/crates/settings/src/settings_file.rs @@ -58,6 +58,41 @@ mod tests { fs.unpause_events_and_flush(); assert_eq!(rx.next().await.as_deref(), Some("A")); } + + #[gpui::test] + async fn test_watch_config_file_reloads_when_parent_dir_is_symlink(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let fs = FakeFs::new(cx.background_executor.clone()); + let config_settings_path = PathBuf::from("/root/.config/zed/settings.json"); + let target_settings_path = PathBuf::from("/root/dotfiles/zed/settings.json"); + + fs.insert_tree( + Path::new("/root"), + json!({ + ".config": {}, + "dotfiles": { + "zed": { + "settings.json": "A" + } + } + }), + ) + .await; + + fs.create_symlink( + Path::new("/root/.config/zed"), + PathBuf::from("/root/dotfiles/zed"), + ) + .await + .unwrap(); + + let (mut rx, _task) = + watch_config_file(&cx.background_executor, fs.clone(), config_settings_path); + assert_eq!(rx.next().await.as_deref(), Some("A")); + + fs.insert_file(&target_settings_path, b"B".to_vec()).await; + assert_eq!(rx.next().await.as_deref(), Some("B")); + } } pub const EMPTY_THEME_NAME: &str = "empty-theme"; @@ -134,6 +169,7 @@ pub fn watch_config_file( ) -> (mpsc::UnboundedReceiver, gpui::Task<()>) { let (tx, rx) = mpsc::unbounded(); let task = executor.spawn(async move { + let path = fs.canonicalize(&path).await.unwrap_or_else(|_| path); let (events, _) = fs.watch(&path, Duration::from_millis(100)).await; futures::pin_mut!(events); @@ -196,8 +232,9 @@ pub fn watch_config_dir( } Some(PathEventKind::Rescan) => { for file_path in &config_paths { - let contents = fs.load(file_path).await.unwrap_or_default(); - if tx.unbounded_send(contents).is_err() { + if let Ok(contents) = fs.load(file_path).await + && tx.unbounded_send(contents).is_err() + { return; } } @@ -208,8 +245,9 @@ pub fn watch_config_dir( && event.path == dir_path { for file_path in &config_paths { - let contents = fs.load(file_path).await.unwrap_or_default(); - if tx.unbounded_send(contents).is_err() { + if let Ok(contents) = fs.load(file_path).await + && tx.unbounded_send(contents).is_err() + { return; } } diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index a98a60c5e16625..bbd1ad0bd970cd 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -13,7 +13,7 @@ use gpui::{ use paths::{local_settings_file_relative_path, task_file_name}; use schemars::{JsonSchema, json_schema}; use serde_json::Value; -use settings_content::ParseStatus; +use settings_content::{ActionName, ParseStatus}; use std::{ any::{Any, TypeId, type_name}, fmt::Debug, @@ -272,13 +272,16 @@ pub trait AnySettingValue: 'static + Send + Sync { } /// Parameters that are used when generating some JSON schemas at runtime. -#[derive(Default)] pub struct SettingsJsonSchemaParams<'a> { pub language_names: &'a [String], pub font_names: &'a [String], pub theme_names: &'a [SharedString], pub icon_theme_names: &'a [SharedString], pub lsp_adapter_names: &'a [String], + pub action_names: &'a [&'a str], + pub action_documentation: &'a HashMap<&'a str, &'a str>, + pub deprecations: &'a HashMap<&'a str, &'a str>, + pub deprecation_messages: &'a HashMap<&'a str, &'a str>, } impl SettingsStore { @@ -1263,6 +1266,17 @@ impl SettingsStore { }); } + if !params.action_names.is_empty() { + replace_subschema::(&mut generator, || { + ActionName::build_schema( + params.action_names.iter().copied(), + params.action_documentation, + params.deprecations, + params.deprecation_messages, + ) + }); + } + generator .root_schema_for::() .to_value() @@ -2738,6 +2752,10 @@ mod tests { "rust-analyzer".to_string(), "typescript-language-server".to_string(), ], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), }); let properties = schema @@ -2789,6 +2807,10 @@ mod tests { "rust-analyzer".to_string(), "typescript-language-server".to_string(), ], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), }); let properties = schema @@ -2837,6 +2859,10 @@ mod tests { theme_names: &["One Dark".into()], icon_theme_names: &["Zed Icons".into()], lsp_adapter_names: &["rust-analyzer".to_string()], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), }; let user_schema = SettingsStore::json_schema(¶ms); diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index d2e4681db7584f..11b1de456dc797 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -271,6 +271,7 @@ impl VsCodeSettings { hover_popover_sticky: self.read_bool("editor.hover.sticky"), hover_popover_hiding_delay: self.read_u64("editor.hover.hidingDelay").map(Into::into), inline_code_actions: None, + code_lens: None, jupyter: None, lsp_document_colors: None, lsp_highlight_debounce: None, @@ -540,6 +541,12 @@ impl VsCodeSettings { edit_predictions_disabled_in: None, enable_language_server: None, ensure_final_newline_on_save: self.read_bool("files.insertFinalNewline"), + line_ending: self.read_enum("files.eol", |s| match s { + "\n" => Some(LineEndingSetting::PreferLf), + "\r\n" => Some(LineEndingSetting::PreferCrlf), + "auto" => Some(LineEndingSetting::Detect), + _ => None, + }), extend_comment_on_newline: None, extend_list_on_newline: None, indent_list_on_tab: None, @@ -1036,7 +1043,6 @@ impl VsCodeSettings { fn worktree_settings_content(&self) -> WorktreeSettingsContent { WorktreeSettingsContent { - project_name: None, prevent_sharing_in_public_channels: false, file_scan_exclusions: self .read_value("files.watcherExclude") diff --git a/crates/settings_content/src/action.rs b/crates/settings_content/src/action.rs new file mode 100644 index 00000000000000..b70a15cb4b75c8 --- /dev/null +++ b/crates/settings_content/src/action.rs @@ -0,0 +1,202 @@ +use std::borrow::Cow; +use std::fmt::{Display, Formatter, Result}; + +use collections::HashMap; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use settings_macros::MergeFrom; + +/// The name of a registered GPUI action, serialized as a plain JSON string, for +/// example, "editor::Cancel"` or `"workspace::CloseActiveItem"`. +/// +/// This newtype exists so that settings fields like `command_aliases`, or the +/// keymap file bindings, can request JSON-schema auto completion over the set +/// of actions known at runtime. +#[derive(Serialize, Deserialize, Default, MergeFrom, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct ActionName(String); + +/// Small helper function to populate the schema's `deprecationMessage` field with the +/// provided deprecation message. +fn add_deprecation(schema: &mut Schema, message: String) { + schema.insert("deprecationMessage".into(), Value::String(message)); +} + +/// Small helper function to populate the schema's `description` field with the +/// provided description. +fn add_description(schema: &mut Schema, description: &str) { + schema.insert("description".into(), Value::String(description.to_string())); +} + +impl ActionName { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + /// Build the JSON schema to be used for `$defs/ActionName`, basically an + /// `anyOf` of all of the available actions with per-action documentation + /// and deprecation metadata attached. + pub fn build_schema<'a>( + action_names: impl IntoIterator, + action_documentation: &HashMap<&str, &str>, + deprecations: &HashMap<&str, &str>, + deprecation_messages: &HashMap<&str, &str>, + ) -> Schema { + let mut alternatives = Vec::new(); + + for action_name in action_names { + let mut entry = json_schema!({ + "type": "string", + "const": action_name + }); + + if let Some(message) = deprecation_messages.get(action_name) { + add_deprecation(&mut entry, message.to_string()); + } else if let Some(new_name) = deprecations.get(action_name) { + add_deprecation(&mut entry, format!("Deprecated, use {new_name}")); + } + + if let Some(description) = action_documentation.get(action_name) { + add_description(&mut entry, description); + } + + alternatives.push(entry); + } + + json_schema!({ "anyOf": alternatives }) + } +} + +impl Display for ActionName { + fn fmt(&self, formatter: &mut Formatter<'_>) -> Result { + write!(formatter, "{}", self.0) + } +} + +impl AsRef for ActionName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl JsonSchema for ActionName { + /// The name under which this type should be stored in a generator's `$defs` + /// map when schemars encounters it during schema generation. + /// Keeping it stable as `"ActionName"` lets consumers reference it by + /// `#/$defs/ActionName` and lets [`util::schemars::replace_subschema`] look + /// it up at runtime to swap in the real schema. + fn schema_name() -> Cow<'static, str> { + "ActionName".into() + } + + /// Returns `true` as a placeholder. + /// + /// The real schema, an `anyOf` of every registered action name with action + /// documentation and deprecation metadata, cannot be produced here because + /// `JsonSchema::json_schema` receives no runtime context. It is instead + /// built by call sites that do have access to the GPUI action registry + /// using [`ActionName::build_schema`]. + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!(true) + } +} + +/// A GPUI action together with its input data, serialized as a two-element JSON +/// array of the form `["namespace::Name", { ... }]`, for example, +/// `["pane::ActivateItem", { "index": 0 }]`. +#[derive(Deserialize, Default)] +#[serde(transparent)] +pub struct ActionWithArguments(pub Value); + +impl JsonSchema for ActionWithArguments { + /// The name under which this type should be stored in a generator's `$defs` + /// map when schemars encounters it during schema generation. + /// Keeping it stable as `"ActionWithArguments"` lets consumers reference it + /// by `#/$defs/ActionWithArguments` and lets + /// [`util::schemars::replace_subschema`] look it up at runtime to swap in + /// the real schema. + fn schema_name() -> Cow<'static, str> { + "ActionWithArguments".into() + } + + /// Returns `true` as a placeholder. + /// + /// The real schema, an `anyOf` of every registered action name that + /// supports arguments, with action documentation and deprecation metadata, + /// cannot be produced here because `JsonSchema::json_schema` receives no + /// runtime context. At the time of writing, it is instead built by + /// [`KeymapFile::generate_json_schema`], where all of the runtime + /// information is available. + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_schema_produces_anyof_of_consts_per_name() { + let mut action_documentation = HashMap::default(); + let mut deprecations = HashMap::default(); + let mut deprecation_messages = HashMap::default(); + action_documentation.insert("editor::Cancel", "Cancel the current operation."); + deprecations.insert("workspace::CloseCurrentItem", "workspace::CloseActiveItem"); + deprecation_messages.insert("editor::Explode", "DO NOT USE!"); + + let schema = ActionName::build_schema( + [ + "editor::Cancel", + "editor::Explode", + "workspace::CloseCurrentItem", + "workspace::CloseActiveItem", + ], + &action_documentation, + &deprecations, + &deprecation_messages, + ); + + let value = schema.to_value(); + let values = value + .pointer("/anyOf") + .and_then(|v| v.as_array()) + .expect("anyOf should be present"); + assert_eq!(values.len(), 4); + + let (name, schema_type, description) = ( + values[0].get("const").and_then(Value::as_str), + values[0].get("type").and_then(Value::as_str), + values[0].get("description").and_then(Value::as_str), + ); + assert_eq!(name, Some("editor::Cancel")); + assert_eq!(schema_type, Some("string")); + assert_eq!(description, Some("Cancel the current operation.")); + + let (name, schema_type, message) = ( + values[1].get("const").and_then(Value::as_str), + values[1].get("type").and_then(Value::as_str), + values[1].get("deprecationMessage").and_then(Value::as_str), + ); + assert_eq!(name, Some("editor::Explode")); + assert_eq!(schema_type, Some("string")); + assert_eq!(message, Some("DO NOT USE!")); + + let (name, schema_type, message) = ( + values[2].get("const").and_then(Value::as_str), + values[2].get("type").and_then(Value::as_str), + values[2].get("deprecationMessage").and_then(Value::as_str), + ); + assert_eq!(name, Some("workspace::CloseCurrentItem")); + assert_eq!(schema_type, Some("string")); + assert_eq!(message, Some("Deprecated, use workspace::CloseActiveItem")); + + let (name, schema_type) = ( + values[3].get("const").and_then(Value::as_str), + values[3].get("type").and_then(Value::as_str), + ); + assert_eq!(name, Some("workspace::CloseActiveItem")); + assert_eq!(schema_type, Some("string")); + } +} diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 76891185c42ee3..12756c9bad5d9b 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -128,6 +128,12 @@ pub struct AgentSettingsContent { /// Default: 320 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub default_height: Option, + /// Whether to limit the content width in the agent panel. When enabled, + /// content will be constrained to `max_content_width` and centered when + /// the panel is wider than that value, for optimal readability. + /// + /// Default: true + pub limit_content_width: Option, /// Maximum content width in pixels for the agent panel. Content will be /// centered when the panel is wider than this value. /// @@ -269,13 +275,34 @@ impl AgentSettingsContent { } pub fn add_favorite_model(&mut self, model: LanguageModelSelection) { - if !self.favorite_models.contains(&model) { + // Note: this is intentional to not compare using `PartialEq`here. + // Full equality would treat entries that differ just in thinking/effort/speed + // as distinct and silently produce duplicates. + if !self + .favorite_models + .iter() + .any(|m| m.provider == model.provider && m.model == model.model) + { self.favorite_models.push(model); } } pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) { - self.favorite_models.retain(|m| m != model); + self.favorite_models + .retain(|m| !(m.provider == model.provider && m.model == model.model)); + } + + pub fn update_favorite_model(&mut self, provider: &str, model: &str, f: F) + where + F: FnOnce(&mut LanguageModelSelection), + { + if let Some(entry) = self + .favorite_models + .iter_mut() + .find(|m| m.provider.0 == provider && m.model == model) + { + f(entry); + } } pub fn set_tool_default_permission(&mut self, tool_id: &str, mode: ToolPermissionMode) { diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index d6cdf751fdfd41..3ba21e830828b4 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -215,6 +215,11 @@ pub struct EditorSettingsContent { /// Drag and drop related settings pub drag_and_drop_selection: Option, + /// Whether and how to display code lenses from language servers. + /// + /// Default: "off" + pub code_lens: Option, + /// How to render LSP `textDocument/documentColor` colors in the editor. /// /// Default: [`DocumentColorsRenderMode::Inlay`] @@ -461,7 +466,7 @@ pub struct GutterContent { pub folds: Option, } -/// How to render LSP `textDocument/documentColor` colors in the editor. +/// Whether to display code lenses from language servers above code elements. #[derive( Copy, Clone, @@ -477,6 +482,46 @@ pub struct GutterContent { strum::VariantNames, )] #[serde(rename_all = "snake_case")] +pub enum CodeLens { + /// Do not query and display code lenses. + #[default] + Off, + /// Display code lenses from language servers above code elements. + On, + /// Display code lenses in the code action menu. + Menu, +} + +impl CodeLens { + pub fn enabled(&self) -> bool { + self != &Self::Off + } + + pub fn inline(&self) -> bool { + *self == Self::On + } + + pub fn show_in_menu(&self) -> bool { + *self == Self::Menu + } +} + +/// How to render LSP `textDocument/documentColor` colors in the editor. +#[derive( + Debug, + Clone, + Copy, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] pub enum DocumentColorsRenderMode { /// Do not query and render document colors. None, diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index c4a674822a81ed..56dbb141316f3d 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -449,6 +449,23 @@ pub struct LanguageSettingsContent { /// /// Default: true pub ensure_final_newline_on_save: Option, + /// How line endings should be handled for new files and during format and + /// save operations. + /// + /// - `detect`: Detect existing line endings and otherwise use the platform + /// default (`lf` on Unix, `crlf` on Windows). + /// - `prefer_lf`: Prefer LF for new files and files with no existing line + /// ending. + /// - `prefer_crlf`: Prefer CRLF for new files and files with no existing + /// line ending. + /// - `enforce_lf`: Enforce LF during format and save. + /// - `enforce_crlf`: Enforce CRLF during format and save. + /// + /// The EditorConfig `end_of_line` property overrides this setting and + /// behaves like `enforce_lf` or `enforce_crlf`. + /// + /// Default: detect + pub line_ending: Option, /// How to perform a buffer format. /// /// Default: auto @@ -899,6 +916,42 @@ pub enum FormatOnSave { Off, } +/// Controls how line endings are normalized when a buffer is saved. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum LineEndingSetting { + /// Preserve the existing line endings of the file. New files use the + /// platform default line ending. + #[strum(serialize = "Detect")] + Detect, + /// Use LF for new files and files with no existing line-ending + /// convention, while preserving existing LF or CRLF files. + #[strum(serialize = "Prefer LF")] + PreferLf, + /// Use CRLF for new files and files with no existing line-ending + /// convention, while preserving existing LF or CRLF files. + #[strum(serialize = "Prefer CRLF")] + PreferCrlf, + /// Normalize line endings to LF (`\n`) during format and save. + #[strum(serialize = "Enforce LF")] + EnforceLf, + /// Normalize line endings to CRLF (`\r\n`) during format and save. + #[strum(serialize = "Enforce CRLF")] + EnforceCrlf, +} + /// Controls which formatters should be used when formatting code. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom)] #[serde(untagged)] diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 17beef9df25f76..635b58f988d6ad 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -289,6 +289,8 @@ pub struct OpenAiCompatibleModelCapabilities { pub prompt_cache_key: bool, #[serde(default = "default_true")] pub chat_completions: bool, + #[serde(default)] + pub interleaved_reasoning: bool, } impl Default for OpenAiCompatibleModelCapabilities { @@ -299,6 +301,7 @@ impl Default for OpenAiCompatibleModelCapabilities { parallel_tool_calls: false, prompt_cache_key: false, chat_completions: default_true(), + interleaved_reasoning: false, } } } diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 6e8b296ef21efa..2a33373a8534c6 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -90,12 +90,6 @@ pub struct ProjectSettingsContent { #[with_fallible_options] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct WorktreeSettingsContent { - /// The displayed name of this project. If not set or null, the root directory name - /// will be displayed. - /// - /// Default: null - pub project_name: Option, - /// Whether to prevent this project from being shared in public channels. /// /// Default: false diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index d949b81e2329da..f8daefcfdb010e 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1,3 +1,4 @@ +mod action; mod agent; mod editor; mod extension; @@ -12,6 +13,7 @@ mod theme; mod title_bar; mod workspace; +pub use action::{ActionName, ActionWithArguments}; pub use agent::*; pub use editor::*; pub use extension::*; diff --git a/crates/settings_content/src/title_bar.rs b/crates/settings_content/src/title_bar.rs index af5e30f361c760..34b3e82aff1680 100644 --- a/crates/settings_content/src/title_bar.rs +++ b/crates/settings_content/src/title_bar.rs @@ -81,10 +81,12 @@ impl From for WindowButtonLayoutContent { #[with_fallible_options] #[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)] pub struct TitleBarSettingsContent { - /// Whether to show the branch icon beside branch switcher in the title bar. + /// Whether to show git status indicators on the branch icon in the title bar. + /// When enabled, the branch icon changes to reflect the current repository + /// status (e.g. modified, added, deleted, or conflict). /// /// Default: false - pub show_branch_icon: Option, + pub show_branch_status_icon: Option, /// Whether to show onboarding banners in the title bar. /// /// Default: true diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 3e4f3b03de5c61..19e08e19f34dd1 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; use settings_macros::{MergeFrom, with_fallible_options}; use crate::{ - CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, ShowIndentGuides, - ShowScrollbar, serialize_optional_f32_with_two_decimal_places, + ActionName, CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, + ShowIndentGuides, ShowScrollbar, serialize_optional_f32_with_two_decimal_places, }; #[with_fallible_options] @@ -88,9 +88,9 @@ pub struct WorkspaceSettingsContent { /// Aliases for the command palette. When you type a key in this map, /// it will be assumed to equal the value. /// - /// Default: true + /// Default: {} #[serde(default)] - pub command_aliases: HashMap, + pub command_aliases: HashMap, /// Maximum open tabs in a pane. Will not close an unsaved /// tab. Set to `None` for unlimited tabs. /// diff --git a/crates/settings_ui/src/components/ollama_model_picker.rs b/crates/settings_ui/src/components/ollama_model_picker.rs index 268bf196bce3d0..bc6ae06cb43490 100644 --- a/crates/settings_ui/src/components/ollama_model_picker.rs +++ b/crates/settings_ui/src/components/ollama_model_picker.rs @@ -203,7 +203,7 @@ pub fn render_ollama_model_picker( .max_height(Some(rems(18.).into())) })) }) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .offset(gpui::Point { x: px(0.0), y: px(2.0), diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 4b69026fa3adf7..8b41e7d4117d87 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1,6 +1,5 @@ use gpui::{Action as _, App}; use itertools::Itertools as _; -use release_channel::ReleaseChannel; use settings::{ AudioInputDeviceName, AudioOutputDeviceName, LanguageSettingsContent, SemanticTokens, SettingsContent, @@ -106,33 +105,9 @@ fn developer_page() -> SettingsPage { } fn general_page(cx: &App) -> SettingsPage { - fn general_settings_section(cx: &App) -> Vec { - let mut items = vec![ + fn general_settings_section(_cx: &App) -> Vec { + vec![ SettingsPageItem::SectionHeader("General Settings"), - SettingsPageItem::SettingItem(SettingItem { - files: PROJECT, - title: "Project Name", - description: "The displayed name of this project. If left empty, the root directory name will be displayed.", - field: Box::new(SettingField { - json_path: Some("project_name"), - pick: |settings_content| { - settings_content - .project - .worktree - .project_name - .as_ref() - .or(DEFAULT_EMPTY_STRING) - }, - write: |settings_content, value| { - settings_content.project.worktree.project_name = - value.filter(|name| !name.is_empty()); - }, - }), - metadata: Some(Box::new(SettingsFieldMetadata { - placeholder: Some("Project Name"), - ..Default::default() - })), - }), SettingsPageItem::SettingItem(SettingItem { title: "When Closing With No Tabs", description: "What to do when using the 'close active item' action with no tabs.", @@ -225,11 +200,7 @@ fn general_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), - ]; - - use feature_flags::FeatureFlagAppExt; - if cx.has_flag::() { - items.push(SettingsPageItem::SettingItem(SettingItem { + SettingsPageItem::SettingItem(SettingItem { title: "CLI Default Open Behavior", description: "How `zed ` opens directories when no flag is specified.", field: Box::new(SettingField { @@ -249,10 +220,8 @@ fn general_page(cx: &App) -> SettingsPage { ..Default::default() })), files: USER, - })); - } - - items + }), + ] } fn security_section() -> [SettingsPageItem; 2] { [ @@ -3654,22 +3623,22 @@ fn window_and_layout_page() -> SettingsPage { [ SettingsPageItem::SectionHeader("Title Bar"), SettingsPageItem::SettingItem(SettingItem { - title: "Show Branch Icon", - description: "Show the branch icon beside branch switcher in the titlebar.", + title: "Show Branch Status Icon", + description: "Show git status indicators on the branch icon in the titlebar.", field: Box::new(SettingField { - json_path: Some("title_bar.show_branch_icon"), + json_path: Some("title_bar.show_branch_status_icon"), pick: |settings_content| { settings_content .title_bar .as_ref()? - .show_branch_icon + .show_branch_status_icon .as_ref() }, write: |settings_content, value| { settings_content .title_bar .get_or_insert_default() - .show_branch_icon = value; + .show_branch_status_icon = value; }, }), metadata: None, @@ -5859,23 +5828,58 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), - SettingsPageItem::SettingItem(SettingItem { - title: "Agent Panel Max Content Width", - description: "Maximum content width in pixels. Content will be centered when the panel is wider than this value.", - field: Box::new(SettingField { - json_path: Some("agent.max_content_width"), - pick: |settings_content| { - settings_content.agent.as_ref()?.max_content_width.as_ref() - }, - write: |settings_content, value| { - settings_content - .agent - .get_or_insert_default() - .max_content_width = value; - }, - }), - metadata: None, - files: USER, + SettingsPageItem::DynamicItem(DynamicItem { + discriminant: SettingItem { + files: USER, + title: "Limit Content Width", + description: "Whether to constrain the agent panel content to a maximum width, centering it when the panel is wider, for optimal readability.", + field: Box::new(SettingField:: { + json_path: Some("agent.limit_content_width"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .limit_content_width + .as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .limit_content_width = value; + }, + }), + metadata: None, + }, + pick_discriminant: |settings_content| { + let enabled = settings_content + .agent + .as_ref()? + .limit_content_width + .unwrap_or(true); + Some(if enabled { 1 } else { 0 }) + }, + fields: vec![ + vec![], + vec![SettingItem { + files: USER, + title: "Max Content Width", + description: "Maximum content width in pixels. Content will be centered when the panel is wider than this value.", + field: Box::new(SettingField { + json_path: Some("agent.max_content_width"), + pick: |settings_content| { + settings_content.agent.as_ref()?.max_content_width.as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .max_content_width = value; + }, + }), + metadata: None, + }], + ], }), ] } @@ -7338,7 +7342,7 @@ fn ai_page(cx: &App) -> SettingsPage { ] } - fn agent_configuration_section(cx: &App) -> Box<[SettingsPageItem]> { + fn agent_configuration_section(_cx: &App) -> Box<[SettingsPageItem]> { let mut items = vec![ SettingsPageItem::SectionHeader("Agent Configuration"), SettingsPageItem::SubPageLink(SubPageLink { @@ -7352,30 +7356,28 @@ fn ai_page(cx: &App) -> SettingsPage { }), ]; - if !matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable)) { - items.push(SettingsPageItem::SettingItem(SettingItem { - title: "New Thread Location", - description: "Whether to start a new thread in the current local project or in a new Git worktree.", - field: Box::new(SettingField { - json_path: Some("agent.new_thread_location"), - pick: |settings_content| { - settings_content - .agent - .as_ref()? - .new_thread_location - .as_ref() - }, - write: |settings_content, value| { - settings_content - .agent - .get_or_insert_default() - .new_thread_location = value; - }, - }), - metadata: None, - files: USER, - })); - } + items.push(SettingsPageItem::SettingItem(SettingItem { + title: "New Thread Location", + description: "Whether to start a new thread in the current local project or in a new Git worktree.", + field: Box::new(SettingField { + json_path: Some("agent.new_thread_location"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .new_thread_location + .as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .new_thread_location = value; + }, + }), + metadata: None, + files: USER, + })); items.extend([ SettingsPageItem::SettingItem(SettingItem { @@ -8081,7 +8083,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { ] } - fn formatting_section() -> [SettingsPageItem; 7] { + fn formatting_section() -> [SettingsPageItem; 8] { [ SettingsPageItem::SectionHeader("Formatting"), SettingsPageItem::SettingItem(SettingItem { @@ -8148,6 +8150,28 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { metadata: None, files: USER | PROJECT, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Line Ending", + description: "How line endings should be handled for new files and during format and save operations.", + field: Box::new(SettingField { + json_path: Some("languages.$(language).line_ending"), + pick: |settings_content| { + language_settings_field(settings_content, |language| { + language.line_ending.as_ref() + }) + }, + write: |settings_content, value| { + language_settings_field_mut(settings_content, value, |language, value| { + language.line_ending = value; + }) + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + should_do_titlecase: Some(false), + ..Default::default() + })), + files: USER | PROJECT, + }), SettingsPageItem::SettingItem(SettingItem { title: "Formatter", description: "How to perform a buffer format.", @@ -8951,6 +8975,20 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { let is_global = active_language().is_none(); + let code_lens_item = [SettingsPageItem::SettingItem(SettingItem { + title: "Code Lens", + description: "Whether and how to display code lenses from language servers.", + field: Box::new(SettingField { + json_path: Some("code_lens"), + pick: |settings_content| settings_content.editor.code_lens.as_ref(), + write: |settings_content, value| { + settings_content.editor.code_lens = value; + }, + }), + metadata: None, + files: USER, + })]; + let lsp_document_colors_item = [SettingsPageItem::SettingItem(SettingItem { title: "LSP Document Colors", description: "How to render LSP color previews in the editor.", @@ -8975,6 +9013,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { whitespace_section(), completions_section(), inlay_hints_section(), + code_lens_item, lsp_document_colors_item, tasks_section(), miscellaneous_section(), @@ -8990,6 +9029,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { whitespace_section(), completions_section(), inlay_hints_section(), + code_lens_item, tasks_section(), miscellaneous_section(), ) diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index bbfcd1849dd561..e6b49dd6c8ab27 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -1112,7 +1112,7 @@ fn render_global_default_mode_section(current_mode: ToolPermissionMode) -> AnyEl }) })) }) - .anchor(gpui::Corner::TopRight), + .anchor(gpui::Anchor::TopRight), ) .into_any_element() } @@ -1171,7 +1171,7 @@ fn render_default_mode_section( }) })) }) - .anchor(gpui::Corner::TopRight), + .anchor(gpui::Anchor::TopRight), ) .into_any_element() } diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index bd503dcb280617..7301e03b68dcf4 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -491,6 +491,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) @@ -535,6 +536,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) @@ -2469,7 +2471,7 @@ impl SettingsWindow { .style(DropdownStyle::Subtle) .trigger_tooltip(Tooltip::text("View Other Projects")) .trigger_icon(IconName::ChevronDown) - .attach(gpui::Corner::BottomLeft) + .attach(gpui::Anchor::BottomLeft) .offset(gpui::Point { x: px(0.0), y: px(2.0), @@ -4260,7 +4262,7 @@ fn render_font_picker( ) })) }) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .offset(gpui::Point { x: px(0.0), y: px(2.0), @@ -4313,7 +4315,7 @@ fn render_theme_picker( ) })) }) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .offset(gpui::Point { x: px(0.0), y: px(2.0), @@ -4366,7 +4368,7 @@ fn render_icon_theme_picker( ) })) }) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .offset(gpui::Point { x: px(0.0), y: px(2.0), diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index c0c0b26e1dfe2d..97e09439800067 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -44,7 +44,6 @@ theme.workspace = true theme_settings.workspace = true ui.workspace = true util.workspace = true -vim_mode_setting.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 64e235e9045d3b..4e2be26ded552a 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -2,7 +2,7 @@ mod thread_switcher; use acp_thread::ThreadStatus; use action_log::DiffStats; -use agent_client_protocol::{self as acp}; +use agent_client_protocol::schema as acp; use agent_settings::AgentSettings; use agent_ui::thread_metadata_store::{ ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths, @@ -12,8 +12,8 @@ use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; use agent_ui::{ - AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, CrossChannelImportOnboarding, - DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, ThreadId, ThreadImportModal, + AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, ArchiveSelectedThread, + CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, ThreadId, ThreadImportModal, channels_with_threads, import_threads_from_other_channels, }; use chrono::{DateTime, Utc}; @@ -45,8 +45,8 @@ use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel, - KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, - Tooltip, WithScrollbar, prelude::*, render_modifiers, + KeyBinding, PopoverMenu, PopoverMenuHandle, ScrollAxes, Scrollbars, Tab, ThreadItem, + ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*, render_modifiers, }; use util::ResultExt as _; use util::path_list::PathList; @@ -335,6 +335,26 @@ struct WorkspaceMenuWorktreeLabel { secondary_name: Option, } +impl WorkspaceMenuWorktreeLabel { + fn render(&self, color: Color) -> impl IntoElement { + h_flex() + .min_w_0() + .gap_0p5() + .when_some(self.icon, |this, icon| { + this.child(Icon::new(icon).size(IconSize::XSmall).color(color)) + }) + .child( + Label::new(self.primary_name.clone()) + .color(color) + .truncate(), + ) + .when_some(self.secondary_name.clone(), |this, secondary_name| { + this.child(Label::new(":").color(color).alpha(0.5)) + .child(Label::new(secondary_name).color(color).truncate()) + }) + } +} + fn workspace_menu_worktree_labels( workspace: &Entity, cx: &App, @@ -485,7 +505,6 @@ impl Sidebar { let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_use_modal_editing(true); editor.set_placeholder_text("Search…", window, cx); editor }); @@ -494,7 +513,7 @@ impl Sidebar { &multi_workspace, window, |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { - MultiWorkspaceEvent::ActiveWorkspaceChanged => { + MultiWorkspaceEvent::ActiveWorkspaceChanged { .. } => { this.sync_active_entry_from_active_workspace(cx); this.replace_archived_panel_thread(window, cx); this.update_entries(cx); @@ -736,8 +755,8 @@ impl Sidebar { this.sync_active_entry_from_panel(_agent_panel, cx); this.update_entries(cx); } - AgentPanelEvent::MessageSentOrQueued { thread_id } => { - this.record_thread_message_sent_or_queued(thread_id, cx); + AgentPanelEvent::ThreadInteracted { thread_id } => { + this.record_thread_interacted(thread_id, cx); this.update_entries(cx); } }, @@ -1152,6 +1171,37 @@ impl Sidebar { threads.push(make_thread_entry(row, workspace)); } + // Also surface any thread whose `folder_paths` equals + // one of this group's open workspaces' root paths. + // The three lookups above can all miss when the + // thread's stored `main_worktree_paths` disagree with + // the group key (for example, a stale row whose main + // paths equal its folder paths for a linked-worktree + // workspace). The thread will be rewritten into the + // correct shape the next time `handle_conversation_event` + // fires, but until then the sidebar should still show + // it under the group whose workspace it actually + // belongs to. + for ws in group_workspaces { + let ws_paths = workspace_path_list(ws, cx); + if ws_paths.paths().is_empty() { + continue; + } + for row in thread_store + .read(cx) + .entries_for_path(&ws_paths, group_host.as_ref()) + .cloned() + { + if !seen_thread_ids.insert(row.thread_id) { + continue; + } + threads.push(make_thread_entry( + row, + ThreadEntryWorkspace::Open(ws.clone()), + )); + } + } + // Load any legacy threads for any single linked wortree of this project group. let mut linked_worktree_paths = HashSet::new(); for workspace in group_workspaces { @@ -1669,6 +1719,17 @@ impl Sidebar { cx, )), ) + .on_mouse_down(gpui::MouseButton::Right, { + let menu_handle = self + .project_header_menu_handles + .get(&ix) + .cloned() + .unwrap_or_default(); + move |_, window, cx| { + cx.stop_propagation(); + menu_handle.toggle(window, cx); + } + }) .on_click( cx.listener(move |this, event: &gpui::ClickEvent, window, cx| { if event.modifiers().secondary() { @@ -1898,79 +1959,68 @@ impl Sidebar { let label_color = if is_active_workspace { Color::Accent } else { - Color::Muted + Color::Default }; let row_group_name = SharedString::from(format!( "workspace-menu-row-{workspace_index}" )); h_flex() - .w_full() .group(&row_group_name) - .justify_between() + .w_full() .gap_2() - .child(h_flex().min_w_0().gap_3().children( - workspace_label.iter().map(|label| { - h_flex() - .min_w_0() - .gap_0p5() - .when_some(label.icon, |this, icon| { - this.child( - Icon::new(icon) - .size(IconSize::XSmall) - .color(label_color), - ) - }) - .child( - Label::new(label.primary_name.clone()) - .color(label_color) - .truncate(), - ) - .when_some( - label.secondary_name.clone(), - |this, secondary_name| { + .justify_between() + .child(h_flex().min_w_0().gap_1().children( + workspace_label.iter().enumerate().map( + |(label_ix, label)| { + h_flex() + .gap_1() + .when(label_ix > 0, |this| { this.child( - Label::new(":") - .color(label_color), - ) - .child( - Label::new(secondary_name) - .color(label_color) - .truncate(), + Label::new("•").alpha(0.25), ) - }, - ) - .into_any_element() - }), + }) + .child(label.render(label_color)) + .into_any_element() + }, + ), )) - .child( - IconButton::new( - ("close-workspace", workspace_index), - IconName::Close, + .when(!is_active_workspace, |this| { + let close_multi_workspace = + close_multi_workspace.clone(); + let close_weak_menu = close_weak_menu.clone(); + let close_workspace = close_workspace.clone(); + + this.child( + IconButton::new( + ("close-workspace", workspace_index), + IconName::Close, + ) + .icon_size(IconSize::Small) + .visible_on_hover(&row_group_name) + .tooltip(Tooltip::text("Close Workspace")) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + close_multi_workspace + .update(cx, |multi_workspace, cx| { + multi_workspace + .close_workspace( + &close_workspace, + window, + cx, + ) + .detach_and_log_err(cx); + }) + .ok(); + close_weak_menu + .update(cx, |_, cx| { + cx.emit(DismissEvent) + }) + .ok(); + }), ) - .shape(ui::IconButtonShape::Square) - .style(ButtonStyle::Subtle) - .visible_on_hover(&row_group_name) - .tooltip(Tooltip::text("Close Workspace")) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - window.prevent_default(); - close_multi_workspace - .update(cx, |multi_workspace, cx| { - multi_workspace - .close_workspace( - &close_workspace, - window, - cx, - ) - .detach_and_log_err(cx); - }) - .ok(); - close_weak_menu - .update(cx, |_, cx| cx.emit(DismissEvent)) - .ok(); - }), - ) + }) .into_any_element() }, move |window, cx| { @@ -1978,6 +2028,7 @@ impl Sidebar { .update(cx, |multi_workspace, cx| { multi_workspace.activate( activate_workspace.clone(), + None, window, cx, ); @@ -2022,7 +2073,7 @@ impl Sidebar { Some(menu) }) - .anchor(gpui::Corner::TopRight) + .anchor(gpui::Anchor::TopRight) .offset(gpui::Point { x: px(0.), y: px(1.), @@ -2170,6 +2221,23 @@ impl Sidebar { } fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + if self.filter_editor.read(cx).is_focused(window) { + if self.reset_filter_editor_text(window, cx) { + self.selection = None; + self.update_entries(cx); + return; + } + + if self.selection.is_none() { + self.select_first_entry(); + } + if self.selection.is_some() { + self.focus_handle.focus(window, cx); + cx.notify(); + } + return; + } + if self.reset_filter_editor_text(window, cx) { self.update_entries(cx); } else { @@ -2195,15 +2263,6 @@ impl Sidebar { self.filter_editor.focus_handle(cx).focus(window, cx); } - // When vim mode is active, the editor defaults to normal mode which - // blocks text input. Switch to insert mode so the user can type - // immediately. - if vim_mode_setting::VimModeSetting::get_global(cx).0 { - if let Ok(action) = cx.build_action("vim::SwitchToInsertMode", None) { - window.dispatch_action(action, cx); - } - } - cx.notify(); } @@ -2464,7 +2523,7 @@ impl Sidebar { } multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); if retain { multi_workspace.retain_active_workspace(cx); } @@ -2504,7 +2563,7 @@ impl Sidebar { let activated = target_window .update(cx, |multi_workspace, window, cx| { window.activate_window(); - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx); }) .log_err() @@ -3526,14 +3585,14 @@ impl Sidebar { ) { if let Some(multi_workspace) = self.multi_workspace.upgrade() { multi_workspace.update(cx, |mw, cx| { - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); }); } } - fn remove_selected_thread( + fn archive_selected_thread( &mut self, - _: &RemoveSelectedThread, + _: &ArchiveSelectedThread, window: &mut Window, cx: &mut Context, ) { @@ -3560,11 +3619,7 @@ impl Sidebar { self.thread_last_accessed.insert(*id, Utc::now()); } - fn record_thread_message_sent_or_queued( - &mut self, - thread_id: &agent_ui::ThreadId, - cx: &mut App, - ) { + fn record_thread_interacted(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut App) { let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { store.update_interacted_at(thread_id, Utc::now(), cx); @@ -3722,7 +3777,7 @@ impl Sidebar { } => { if let Some(mw) = weak_multi_workspace.upgrade() { mw.update(cx, |mw, cx| { - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); }); } this.active_entry = Some(ActiveEntry { @@ -3741,7 +3796,7 @@ impl Sidebar { } => { if let Some(mw) = weak_multi_workspace.upgrade() { mw.update(cx, |mw, cx| { - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); mw.retain_active_workspace(cx); }); } @@ -3759,7 +3814,7 @@ impl Sidebar { if let Some(mw) = weak_multi_workspace.upgrade() { if let Some(original_ws) = &original_workspace { mw.update(cx, |mw, cx| { - mw.activate(original_ws.clone(), window, cx); + mw.activate(original_ws.clone(), None, window, cx); }); } } @@ -3816,7 +3871,7 @@ impl Sidebar { if let Some((metadata, workspace)) = initial_preview { if let Some(mw) = self.multi_workspace.upgrade() { mw.update(cx, |mw, cx| { - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); }); } self.active_entry = Some(ActiveEntry { @@ -3926,7 +3981,7 @@ impl Sidebar { move |_window, cx| { Tooltip::for_action_in( "Archive Thread", - &RemoveSelectedThread, + &ArchiveSelectedThread, &focus_handle, cx, ) @@ -4030,7 +4085,7 @@ impl Sidebar { x: px(-2.0), y: px(-2.0), }) - .anchor(gpui::Corner::BottomRight) + .anchor(gpui::Anchor::BottomRight) } fn new_thread_in_group( @@ -4063,13 +4118,13 @@ impl Sidebar { }; multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); }); let draft_id = workspace.update(cx, |workspace, cx| { let panel = workspace.panel::(cx)?; let draft_id = panel.update(cx, |panel, cx| { - panel.activate_draft(true, window, cx); + panel.activate_draft(true, "sidebar", window, cx); panel.active_thread_id(cx) }); workspace.focus_panel::(window, cx); @@ -4190,7 +4245,7 @@ impl Sidebar { .workspace_for_paths(key.path_list(), key.host().as_ref(), cx) }) { multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace, window, cx); + multi_workspace.activate(workspace, None, window, cx); multi_workspace.retain_active_workspace(cx); }); } else { @@ -4447,14 +4502,14 @@ impl Sidebar { sidebar_side_context_menu("sidebar-toggle-menu", _cx) .anchor(if on_right { - gpui::Corner::BottomRight + gpui::Anchor::BottomRight } else { - gpui::Corner::BottomLeft + gpui::Anchor::BottomLeft }) .attach(if on_right { - gpui::Corner::TopRight + gpui::Anchor::TopRight } else { - gpui::Corner::TopLeft + gpui::Anchor::TopLeft }) .trigger(move |_is_active, _window, _cx| { let icon = if on_right { @@ -4910,7 +4965,7 @@ impl Render for Sidebar { .on_action(cx.listener(Self::fold_all)) .on_action(cx.listener(Self::unfold_all)) .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(Self::archive_selected_thread)) .on_action(cx.listener(Self::new_thread_in_group)) .on_action(cx.listener(Self::toggle_archive)) .on_action(cx.listener(Self::focus_sidebar_filter)) @@ -4953,7 +5008,13 @@ impl Render for Sidebar { this.child(self.render_no_results(cx)) }) .when_some(sticky_header, |this, header| this.child(header)) - .vertical_scrollbar_for(&self.list_state, window, cx), + .custom_scrollbars( + Scrollbars::new(ScrollAxes::Vertical) + .tracked_scroll_handle(&self.list_state) + .width_sm(), + window, + cx, + ), ) } }), diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index bd266fb88c3657..2de895114d1ca3 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -1679,9 +1679,9 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { +async fn test_escape_from_search_focuses_first_thread(cx: &mut TestAppContext) { // Scenario: A user searches, finds what they need, then presses Escape - // to dismiss the filter and see the full list again. + // in the search field to hand keyboard control back to the thread list. let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -1723,8 +1723,8 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex ] ); - // User presses Escape — filter clears, full list is restored. - // The selection index (1) now points at the first thread entry. + // First Escape clears the search text, restoring the full list. + // Focus stays on the filter editor. cx.dispatch_action(Cancel); cx.run_until_parked(); assert_eq!( @@ -1732,10 +1732,23 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex vec![ // "v [my-project]", - " Alpha thread <== selected", + " Alpha thread", " Beta thread", ] ); + sidebar.update_in(cx, |sidebar, window, cx| { + assert!(sidebar.filter_editor.read(cx).is_focused(window)); + assert!(!sidebar.focus_handle.is_focused(window)); + }); + + // Second Escape moves focus from the empty search field to the thread list. + cx.dispatch_action(Cancel); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + assert_eq!(sidebar.selection, Some(1)); + assert!(sidebar.focus_handle.is_focused(window)); + assert!(!sidebar.filter_editor.read(cx).is_focused(window)); + }); } #[gpui::test] @@ -2098,7 +2111,7 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC // Switch to workspace 1 so we can verify the confirm switches back. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().nth(1).unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -2597,7 +2610,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); @@ -2653,7 +2666,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned(); if let Some(workspace) = workspace { - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); } }); cx.run_until_parked(); @@ -2917,7 +2930,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp // Switch to the worktree workspace. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().nth(1).unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); // Create a non-empty thread in the worktree workspace. @@ -3521,7 +3534,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp // Switch back to the main workspace before setting up the sidebar. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); // Start a thread in the worktree workspace's panel and keep it @@ -3614,7 +3627,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); let connection = StubAgentConnection::new(); @@ -3937,7 +3950,7 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( // Activate the main workspace before setting up the sidebar. let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); workspace }); @@ -3983,6 +3996,190 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( ); } +// Reproduces the core of the user-reported bug: a thread belonging to +// a multi-root workspace that mixes a standalone project and a linked +// git worktree can become invisible in the sidebar when its stored +// `main_worktree_paths` don't match the workspace's project group +// key. The metadata still exists and Thread History still shows it, +// but the sidebar rebuild's lookups all miss. +// +// Real-world setup: a single multi-root workspace whose roots are +// `[/cloud, /worktrees/zed/wt_a/zed]`, where: +// - `/cloud` is a standalone git repo (main == folder). +// - `/worktrees/zed/wt_a/zed` is a linked worktree of `/zed`. +// +// Once git scans complete the project group key is +// `[/cloud, /zed]` — the main paths of the two roots. A thread +// created in this workspace is written with +// `main=[/cloud, /zed], folder=[/cloud, /worktrees/zed/wt_a/zed]` +// and the sidebar finds it via `entries_for_main_worktree_path`. +// +// If some other code path (stale data on reload, a path-less archive +// restored via the project picker, a legacy write …) persists the +// thread with `main == folder` instead, the stored +// `main_worktree_paths` is +// `[/cloud, /worktrees/zed/wt_a/zed]` ≠ `[/cloud, /zed]`. The three +// lookups in `rebuild_contents` all miss: +// +// 1. `entries_for_main_worktree_path([/cloud, /zed])` — the +// thread's stored main doesn't equal the group key. +// 2. `entries_for_path([/cloud, /zed])` — the thread's folder paths +// don't equal the group key either. +// 3. The linked-worktree fallback iterates the group's workspaces' +// `linked_worktrees()` snapshots. Those yield *sibling* linked +// worktrees of the repo, not the workspace's own roots, so the +// thread's folder `/worktrees/zed/wt_a/zed` doesn't match. +// +// The row falls out of the sidebar entirely — matching the user's +// symptom of a thread visible in the agent panel but missing from +// the sidebar. It only reappears once something re-writes the +// thread's metadata in the good shape (e.g. `handle_conversation_event` +// firing after the user sends a message). +// +// We directly persist the bad shape via `store.save(...)` rather +// than trying to reproduce the original writer. The bug is +// ultimately about the sidebar's tolerance for any stale row whose +// folder paths correspond to an open workspace's roots, regardless +// of how that row came to be in the store. +#[gpui::test] +async fn test_sidebar_keeps_multi_root_thread_with_stale_main_paths(cx: &mut TestAppContext) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.set_global(agent_ui::MaxIdleRetainedThreads(1)); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Standalone repo — one of the workspace's two roots, main + // worktree of its own .git. + fs.insert_tree( + "/cloud", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + + // Separate /zed repo whose linked worktree will form the second + // workspace root. /zed itself is NOT opened as a workspace root. + fs.insert_tree( + "/zed", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/worktrees/zed/wt_a/zed", + serde_json::json!({ + ".git": "gitdir: /zed/.git/worktrees/wt_a", + "src": {}, + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new("/zed/.git"), + false, + git::repository::Worktree { + path: std::path::PathBuf::from("/worktrees/zed/wt_a/zed"), + ref_name: Some("refs/heads/wt_a".into()), + sha: "aaa".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Single multi-root project with both /cloud and the linked + // worktree of /zed. + let project = project::Project::test( + fs.clone(), + ["/cloud".as_ref(), "/worktrees/zed/wt_a/zed".as_ref()], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone()); + let _panel = add_agent_panel(&workspace, cx); + cx.run_until_parked(); + + // Sanity-check the shapes the rest of the test depends on. + let group_key = workspace.read_with(cx, |ws, cx| ws.project_group_key(cx)); + let expected_main_paths = PathList::new(&[PathBuf::from("/cloud"), PathBuf::from("/zed")]); + assert_eq!( + group_key.path_list(), + &expected_main_paths, + "expected the multi-root workspace's project group key to normalize to \ + [/cloud, /zed] (main of the standalone repo + main of the linked worktree)" + ); + + let folder_paths = PathList::new(&[ + PathBuf::from("/cloud"), + PathBuf::from("/worktrees/zed/wt_a/zed"), + ]); + let workspace_root_paths = workspace.read_with(cx, |ws, cx| PathList::new(&ws.root_paths(cx))); + assert_eq!( + workspace_root_paths, folder_paths, + "expected the workspace's root paths to equal [/cloud, /worktrees/zed/wt_a/zed]" + ); + + let session_id = acp::SessionId::new(Arc::from("multi-root-stale-paths")); + let thread_id = ThreadId::new(); + + // Persist the thread in the "bad" shape that the bug manifests as: + // main == folder for every root. Any stale row where + // `main_worktree_paths` no longer equals the group key produces + // the same user-visible symptom; this is the concrete shape + // produced by `WorktreePaths::from_folder_paths` on the workspace + // roots. + cx.update(|_, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + thread_id, + session_id: Some(session_id.clone()), + agent_id: agent::ZED_AGENT_ID.clone(), + title: Some("Stale Multi-Root Thread".into()), + updated_at: Utc::now(), + created_at: None, + interacted_at: None, + worktree_paths: WorktreePaths::from_folder_paths(&folder_paths), + archived: false, + remote_connection: None, + }, + cx, + ) + }); + }); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + let visible = sidebar.read_with(cx, |sidebar, _cx| has_thread_entry(sidebar, &session_id)); + + // If this assert fails, we've reproduced the bug: the sidebar's + // rebuild queries can't locate the thread under the current + // project group, even though the metadata is intact and the + // thread's folder paths exactly equal the open workspace's roots. + assert!( + visible, + "thread disappeared from the sidebar when its main_worktree_paths \ + ({folder_paths:?}) diverged from the project group key ({expected_main_paths:?}); \ + sidebar entries: {entries:?}" + ); +} + #[gpui::test] async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace( cx: &mut TestAppContext, @@ -4018,7 +4215,7 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works // Ensure workspace A is active. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -4088,7 +4285,7 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( // Start with workspace A active. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -4155,7 +4352,7 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( // Activate workspace B (index 1) to make it the active one. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().nth(1).unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -4557,7 +4754,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon // Activate main workspace so the sidebar tracks the main panel. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); let main_workspace = @@ -6461,7 +6658,7 @@ async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_d cx.run_until_parked(); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); @@ -6853,7 +7050,7 @@ async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry( let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone()); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); @@ -7014,7 +7211,7 @@ async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_ // Activate the linked worktree workspace so the sidebar tracks it. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(worktree_workspace.clone(), window, cx); + mw.activate(worktree_workspace.clone(), None, window, cx); }); // Open a thread in the linked worktree panel and send a message @@ -7185,7 +7382,7 @@ async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_gro // Activate the linked worktree workspace. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(worktree_workspace.clone(), window, cx); + mw.activate(worktree_workspace.clone(), None, window, cx); }); // Open a thread on the linked worktree — this is the ONLY thread. @@ -7486,7 +7683,7 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut // Activate the linked worktree workspace. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(worktree_workspace.clone(), window, cx); + mw.activate(worktree_workspace.clone(), None, window, cx); }); // Open a thread on the linked worktree. @@ -7641,7 +7838,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA // Switch back to the main workspace. multi_workspace.update_in(cx, |mw, window, cx| { let main_ws = mw.workspaces().next().unwrap().clone(); - mw.activate(main_ws, window, cx); + mw.activate(main_ws, None, window, cx); }); cx.run_until_parked(); @@ -7850,7 +8047,9 @@ async fn test_transient_workspace_retained(cx: &mut TestAppContext) { ); // Switch to A — B survives. (Switching from one internal workspace, to another) - multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_a, None, window, cx) + }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), @@ -8318,7 +8517,7 @@ async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) .clone() }); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); @@ -8406,11 +8605,11 @@ async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut Te // Switch away from project-b, then back. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_b.clone(), window, cx); + mw.activate(workspace_b.clone(), None, window, cx); }); cx.run_until_parked(); @@ -9146,7 +9345,7 @@ mod property_test { .unwrap_or_else(|| mw.workspace().clone()) }); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); } Operation::AddLinkedWorktree { @@ -10921,7 +11120,7 @@ async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_wo // workspaces — what matters for this test is the explicit sequence of // activations below.) multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(worktree_workspace_a.clone(), window, cx); + mw.activate(worktree_workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -10934,7 +11133,7 @@ async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_wo // workspace remains the linked-worktree one (group B getting activated // records *its own* last-active workspace, not group A's). multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_b.clone(), window, cx); + mw.activate(workspace_b.clone(), None, window, cx); }); cx.run_until_parked(); assert_eq!( diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index 97c291e0dc928d..c74cdedc9fc9b9 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/crates/sidebar/src/thread_switcher.rs @@ -1,11 +1,11 @@ use action_log::DiffStats; -use agent_client_protocol as acp; +use agent_client_protocol::schema as acp; use agent_ui::thread_metadata_store::ThreadMetadata; use gpui::{ Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers, - ModifiersChangedEvent, Render, SharedString, prelude::*, + ModifiersChangedEvent, Render, ScrollHandle, SharedString, prelude::*, }; -use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, prelude::*}; +use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, WithScrollbar, prelude::*}; use workspace::{ModalView, Workspace}; use zed_actions::agents_sidebar::ToggleThreadSwitcher; @@ -42,6 +42,7 @@ pub(crate) struct ThreadSwitcher { entries: Vec, selected_index: usize, init_modifiers: Option, + scroll_handle: ScrollHandle, } impl ThreadSwitcher { @@ -74,11 +75,15 @@ impl ThreadSwitcher { }) .detach(); + let scroll_handle = ScrollHandle::new(); + scroll_handle.scroll_to_item(selected_index); + Self { focus_handle, entries, selected_index, init_modifiers, + scroll_handle, } } @@ -117,6 +122,7 @@ impl ThreadSwitcher { } fn emit_preview(&mut self, cx: &mut Context) { + self.scroll_handle.scroll_to_item(self.selected_index); if let Some(entry) = self.entries.get(self.selected_index) { cx.emit(ThreadSwitcherEvent::Preview { metadata: entry.metadata.clone(), @@ -151,6 +157,7 @@ impl ThreadSwitcher { return; } self.selected_index = index; + self.scroll_handle.scroll_to_item(index); self.emit_preview(cx); cx.notify(); } @@ -205,57 +212,66 @@ impl Focusable for ThreadSwitcher { } impl Render for ThreadSwitcher { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { let selected_index = self.selected_index; v_flex() .key_context("ThreadSwitcher") .track_focus(&self.focus_handle) - .w(rems_from_px(440.)) .p_1p5() - .gap_0p5() + .w(rems_from_px(440.)) .elevation_3(cx) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::toggle)) - .children(self.entries.iter().enumerate().map(|(ix, entry)| { - let id = SharedString::from(format!("thread-switcher-{}", entry.session_id)); + .child( + v_flex() + .id("thread-switcher-list") + .gap_0p5() + .max_h_128() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .children(self.entries.iter().enumerate().map(|(ix, entry)| { + let id = + SharedString::from(format!("thread-switcher-{}", entry.session_id)); - ThreadItem::new(id, entry.title.clone()) - .rounded(true) - .icon(entry.icon) - .status(entry.status) - .when_some(entry.icon_from_external_svg.clone(), |this, svg| { - this.custom_icon_from_external_svg(svg) - }) - .when_some(entry.project_name.clone(), |this, name| { - this.project_name(name) - }) - .worktrees(entry.worktrees.clone()) - .timestamp(entry.timestamp.clone()) - .title_generating(entry.is_title_generating) - .notified(entry.notified) - .when(entry.diff_stats.lines_added > 0, |this| { - this.added(entry.diff_stats.lines_added as usize) - }) - .when(entry.diff_stats.lines_removed > 0, |this| { - this.removed(entry.diff_stats.lines_removed as usize) - }) - .selected(ix == selected_index) - .base_bg(cx.theme().colors().elevated_surface_background) - .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| { - if *hovered { - this.select_index(ix, cx); - } - })) - // TODO: This is not properly propagating to the tread item. - .on_click( - cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| { - this.select_and_confirm(ix, cx); - }), - ) - .into_any_element() - })) + ThreadItem::new(id, entry.title.clone()) + .rounded(true) + .icon(entry.icon) + .status(entry.status) + .when_some(entry.icon_from_external_svg.clone(), |this, svg| { + this.custom_icon_from_external_svg(svg) + }) + .when_some(entry.project_name.clone(), |this, name| { + this.project_name(name) + }) + .worktrees(entry.worktrees.clone()) + .timestamp(entry.timestamp.clone()) + .title_generating(entry.is_title_generating) + .notified(entry.notified) + .when(entry.diff_stats.lines_added > 0, |this| { + this.added(entry.diff_stats.lines_added as usize) + }) + .when(entry.diff_stats.lines_removed > 0, |this| { + this.removed(entry.diff_stats.lines_removed as usize) + }) + .selected(ix == selected_index) + .base_bg(cx.theme().colors().elevated_surface_background) + .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| { + if *hovered { + this.select_index(ix, cx); + } + })) + // TODO: This is not properly propagating to the tread item. + .on_click(cx.listener( + move |this, _event: &gpui::ClickEvent, _window, cx| { + this.select_and_confirm(ix, cx); + }, + )) + .into_any_element() + })), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) } } diff --git a/crates/tab_switcher/Cargo.toml b/crates/tab_switcher/Cargo.toml index 8855c8869ab522..5f0238ebc239c5 100644 --- a/crates/tab_switcher/Cargo.toml +++ b/crates/tab_switcher/Cargo.toml @@ -15,7 +15,7 @@ doctest = false [dependencies] collections.workspace = true editor.workspace = true -fuzzy.workspace = true +fuzzy_nucleo.workspace = true gpui.workspace = true menu.workspace = true picker.workspace = true @@ -23,7 +23,6 @@ project.workspace = true schemars.workspace = true serde.workspace = true settings.workspace = true -smol.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index d1e19ea4faee8d..ac4087bb96b2ff 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -5,7 +5,7 @@ use collections::{HashMap, HashSet}; use editor::items::{ entry_diagnostic_aware_icon_decoration_and_color, entry_git_aware_label_color, }; -use fuzzy::StringMatchCandidate; +use fuzzy_nucleo::StringMatchCandidate; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Point, @@ -441,15 +441,13 @@ impl TabSwitcherDelegate { )) }) .collect::>(); - smol::block_on(fuzzy::match_strings( + fuzzy_nucleo::match_strings( &candidates, &query, - true, - true, + fuzzy_nucleo::Case::Smart, + fuzzy_nucleo::LengthPenalty::On, 10000, - &Default::default(), - cx.background_executor().clone(), - )) + ) .into_iter() .map(|m| all_items[m.candidate_id].clone()) .collect() diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 7b6676760ca61c..4e16d69e40553c 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -157,6 +157,17 @@ impl PtyProcessInfo { self.get_child().is_some_and(|process| process.kill()) } + #[cfg(unix)] + pub(crate) fn terminate_child_process(&self) -> bool { + let pid = self.pid_getter.fallback_pid(); + unsafe { libc::killpg(pid.as_u32() as i32, libc::SIGTERM) == 0 } + } + + #[cfg(not(unix))] + pub(crate) fn terminate_child_process(&self) -> bool { + false + } + fn load(&self) -> Option { let process = self.refresh()?; let cwd = process.cwd().map_or(PathBuf::new(), |p| p.to_owned()); diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index b620f5f03c2deb..74118d372d91cf 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -2424,6 +2424,7 @@ impl Drop for Terminal { std::mem::replace(&mut self.terminal_type, TerminalType::DisplayOnly) { pty_tx.0.send(Msg::Shutdown).ok(); + info.terminate_child_process(); let timer = self.background_executor.timer(Duration::from_millis(100)); self.background_executor diff --git a/crates/terminal/src/terminal_hyperlinks.rs b/crates/terminal/src/terminal_hyperlinks.rs index 0ca6cb2edd9160..649c3bdee35080 100644 --- a/crates/terminal/src/terminal_hyperlinks.rs +++ b/crates/terminal/src/terminal_hyperlinks.rs @@ -11,7 +11,6 @@ use alacritty_terminal::{ use log::{info, warn}; use regex::Regex; use std::{ - iter::{once, once_with}, ops::{Index, Range}, time::{Duration, Instant}, }; @@ -206,6 +205,33 @@ fn sanitize_url_punctuation( } } +/// Returns the byte offset just past the first unbalanced `(` in `s`, or `None` +/// if all parentheses are balanced. Used to strip prefixes like `Update(` from +/// path matches while preserving balanced parens in filenames like `file(copy).txt`. +fn first_unbalanced_open_paren(s: &str) -> Option { + let mut balance: i32 = 0; + let mut first_unmatched = None; + for (i, c) in s.char_indices() { + match c { + '(' => { + if balance == 0 { + first_unmatched = Some(i + c.len_utf8()); + } + balance += 1; + } + ')' => { + balance -= 1; + if balance <= 0 { + balance = 0; + first_unmatched = None; + } + } + _ => {} + } + } + first_unmatched.filter(|_| balance > 0) +} + fn path_match( term: &Term, line_start: AlacPoint, @@ -237,16 +263,10 @@ fn path_match( let first_cell = &term.grid()[line_start]; let mut prev_len = 0; line.push(first_cell.c); - let mut prev_char_is_space = first_cell.c == ' '; let mut hovered_point_byte_offset = None; - let mut hovered_word_start_offset = None; - let mut hovered_word_end_offset = None; if line_start == hovered { hovered_point_byte_offset = Some(0); - if first_cell.c != ' ' { - hovered_word_start_offset = Some(0); - } } for cell in term.grid().iter_from(line_start) { @@ -257,22 +277,8 @@ fn path_match( if !cell.flags.intersects(WIDE_CHAR_SPACERS) { prev_len = line.len(); match cell.c { - ' ' | '\t' => { - if hovered_point_byte_offset.is_some() && !prev_char_is_space { - if hovered_word_end_offset.is_none() { - hovered_word_end_offset = Some(line.len()); - } - } - line.push(' '); - prev_char_is_space = true; - } - c @ _ => { - if hovered_point_byte_offset.is_none() && prev_char_is_space { - hovered_word_start_offset = Some(line.len()); - } - line.push(c); - prev_char_is_space = false; - } + ' ' | '\t' => line.push(' '), + c => line.push(c), } } @@ -283,11 +289,6 @@ fn path_match( } let line = line.trim_ascii_end(); let hovered_point_byte_offset = hovered_point_byte_offset?; - let hovered_word_range = { - let word_start_offset = hovered_word_start_offset.unwrap_or(0); - (word_start_offset != 0) - .then_some(word_start_offset..hovered_word_end_offset.unwrap_or(line.len())) - }; if line.len() <= hovered_point_byte_offset { return None; } @@ -336,23 +337,9 @@ fn path_match( for regex in path_hyperlink_regexes { let mut path_found = false; - for (line_start_offset, captures) in once( - regex - .captures_iter(&line) - .next() - .map(|captures| (0, captures)), - ) - .chain(once_with(|| { - if let Some(hovered_word_range) = &hovered_word_range { - regex - .captures_iter(&line[hovered_word_range.clone()]) - .next() - .map(|captures| (hovered_word_range.start, captures)) - } else { - None - } - })) - .flatten() + for (line_start_offset, captures) in regex + .captures_iter(&line) + .map(|captures| (0usize, captures)) { path_found = true; let match_range = captures.get(0).unwrap().range(); @@ -379,6 +366,16 @@ fn path_match( link_range.start += line_start_offset; link_range.end += line_start_offset; + // Strip prefix up to the first unbalanced `(` in the matched path. + // This handles delimiter parens like `Update(.claude/SKILL.md)` while + // preserving balanced parens in filenames like `file(copy).txt`. + // Analogous to `sanitize_url_punctuation` which strips unbalanced + // trailing `)` from URLs. + if let Some(trim) = first_unbalanced_open_paren(&line[path_range.clone()]) { + path_range.start += trim; + link_range.start = link_range.start.max(path_range.start); + } + if !link_range.contains(&hovered_point_byte_offset) { // No match, just skip. continue; @@ -650,6 +647,12 @@ mod tests { test_path!(" Compiling Cool (‹«/👉test/Cool»›)"); test_path!(" Compiling Cool (/test/Cool👉)"); + // Tool output with path inside parens (e.g. Claude Code) + test_path!("Update👉(src/cool.rs)"); + test_path!("Update(‹«src/👉cool.rs»›)"); + test_path!("Update(src/cool.rs👉)"); + test_path!("Write(‹«/👉test/Cool»›)"); + // Python test_path!("‹«awe👉some.py»›"); test_path!("‹«👉a»› "); @@ -1003,6 +1006,13 @@ mod tests { test_path!("‹«/te:st/👉co:ol.r:s:4:2::::::»›"); test_path!("/test/cool.rs:::👉:"); } + + #[test] + // Filenames with balanced parentheses are preserved as a single path. + // Unbalanced leading `(` (e.g. `Update(.claude/SKILL.md)`) is stripped. + fn parens_in_filename() { + test_path!("‹«docker-compose.prod(👉copy).yml»›"); + } } mod windows { diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index f74d8b83883a11..9da4c5ec6e1cf5 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -51,6 +51,10 @@ zed_actions.workspace = true editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } +remote = { workspace = true, features = ["test-support"] } +release_channel.workspace = true +rpc = { workspace = true, features = ["test-support"] } +semver.workspace = true terminal = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index d1c6b324498ce5..1e07e1c49d43ac 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1162,7 +1162,9 @@ impl Element for TerminalElement { let (shape, text) = match cursor.shape { AlacCursorShape::Block if !focused => (CursorShape::Hollow, None), AlacCursorShape::Block => (CursorShape::Block, Some(cursor_text)), + AlacCursorShape::Underline if !focused => (CursorShape::Hollow, None), AlacCursorShape::Underline => (CursorShape::Underline, None), + AlacCursorShape::Beam if !focused => (CursorShape::Hollow, None), AlacCursorShape::Beam => (CursorShape::Bar, None), AlacCursorShape::HollowBlock => (CursorShape::Hollow, None), AlacCursorShape::Hidden => unreachable!(), diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index a813a1adc55fe5..642243ae147539 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -11,7 +11,7 @@ use collections::HashMap; use db::kvp::KeyValueStore; use futures::{channel::oneshot, future::join_all}; use gpui::{ - Action, AnyView, App, AsyncApp, AsyncWindowContext, Context, Corner, Entity, EventEmitter, + Action, Anchor, AnyView, App, AsyncApp, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, WeakEntity, Window, actions, }; @@ -160,7 +160,7 @@ impl TerminalPanel { IconButton::new("plus", IconName::Plus).icon_size(IconSize::Small), Tooltip::text("New…"), ) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .with_handle(pane.new_item_context_menu_handle.clone()) .menu(move |window, cx| { let focus_handle = focus_handle.clone(); @@ -190,7 +190,7 @@ impl TerminalPanel { .icon_size(IconSize::Small), Tooltip::text("Split Pane"), ) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .with_handle(pane.split_item_context_menu_handle.clone()) .menu({ move |window, cx| { @@ -1314,7 +1314,7 @@ impl Render for FailedToSpawnTerminal { ) })) }) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .offset(gpui::Point { x: px(0.0), y: px(2.0), diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 636ce30e6e243f..8b38ccdb50f766 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1290,7 +1290,7 @@ impl Render for TerminalView { deferred( anchored() .position(*position) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) @@ -1852,7 +1852,12 @@ impl SearchableItem for TerminalView { } /// Returns the selection content to pre-load into this search - fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context) -> String { + fn query_suggestion( + &mut self, + _ignore_settings: bool, + _window: &mut Window, + cx: &mut Context, + ) -> String { self.terminal() .read(cx) .last_content @@ -1971,8 +1976,9 @@ impl SearchableItem for TerminalView { } /// Gets the working directory for the given workspace, respecting the user's settings. -/// Falls back to home directory when no project directory is available. +/// Falls back to the local home directory only for local workspaces. pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option { + let should_fallback_to_local_home = workspace.project().read(cx).is_local(); let directory = match &TerminalSettings::get_global(cx).working_directory { WorkingDirectory::CurrentFileDirectory => workspace .project() @@ -1987,7 +1993,12 @@ pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Opti .map(|dir| Path::new(&dir.to_string()).to_path_buf()) .filter(|dir| dir.is_dir()), }; - directory.or_else(dirs::home_dir) + + if should_fallback_to_local_home { + directory.or_else(dirs::home_dir) + } else { + directory + } } fn current_project_directory(workspace: &Workspace, cx: &App) -> Option { @@ -2017,6 +2028,7 @@ mod tests { use super::*; use gpui::TestAppContext; use project::{Entry, Project, ProjectPath, Worktree}; + use remote::RemoteClient; use std::path::{Path, PathBuf}; use util::paths::PathStyle; use util::rel_path::RelPath; @@ -2079,6 +2091,22 @@ mod tests { }); } + #[gpui::test] + async fn remote_no_worktree_uses_remote_shell_default_cwd( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, + ) { + let (_project, workspace) = init_remote_test(cx, server_cx).await; + + cx.read(|cx| { + let workspace = workspace.read(cx); + + assert!(workspace.project().read(cx).is_remote()); + assert!(workspace.worktrees(cx).next().is_none()); + assert_eq!(default_working_directory(workspace, cx), None); + }); + } + // No active entry, but a worktree, worktree is a file -> parent directory #[gpui::test] async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) { @@ -2237,6 +2265,64 @@ mod tests { (project, workspace, window_handle) } + async fn init_remote_test( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, + ) -> (Entity, Entity) { + cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + + let params = cx.update(AppState::test); + let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx); + let ping_handler = server_cx.new(|_| ()); + server_session.add_request_handler::( + ping_handler.downgrade(), + |_entity, _envelope, _cx| async { Ok(rpc::proto::Ack {}) }, + ); + drop(connect_guard); + + let remote_client = RemoteClient::connect_mock(opts, cx).await; + let project = cx.update(|cx| { + Project::remote( + remote_client, + params.client.clone(), + params.node_runtime.clone(), + params.user_store.clone(), + params.languages.clone(), + params.fs.clone(), + false, + cx, + ) + }); + + let window_handle = cx.add_window({ + let params = params.clone(); + let project_for_workspace = project.clone(); + move |window, cx| { + window.activate_window(); + let workspace = cx.new(|cx| { + Workspace::new( + None, + project_for_workspace.clone(), + params.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, window, cx) + } + }); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + (project, workspace) + } + /// Creates a file in the given worktree and returns its entry. async fn create_file_in_worktree( worktree: Entity, diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index 31497821819489..a1d65dddc4632b 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -192,7 +192,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ("metal", &["metal"]), ("nim", &["nim", "nims", "nimble"]), ("nix", &["nix"]), - ("ocaml", &["ml", "mli"]), + ("ocaml", &["ml", "mli", "mlx"]), ("odin", &["odin"]), ("php", &["php"]), ( diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index eed94c839c0d04..ed7b64c3c18f3c 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -59,6 +59,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true +arrayvec = "0.7.6" [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index e427d5aa7bba2f..edc21f91f6ff5b 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -7,6 +7,8 @@ mod update_version; use crate::application_menu::{ApplicationMenu, show_menus}; use crate::plan_chip::PlanChip; +use arrayvec::ArrayVec; +use git_ui::worktree_picker::WorktreePicker; pub use platform_title_bar::{ self, DraggedWindowTab, MergeAllWindows, MoveTabToNewWindow, PlatformTitleBar, ShowNextWindowTab, ShowPreviousWindowTab, @@ -24,7 +26,7 @@ use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; use gpui::{ - Action, Animation, AnimationExt, AnyElement, App, Context, Corner, Element, Entity, Focusable, + Action, Anchor, Animation, AnimationExt, AnyElement, App, Context, Element, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, pulsating_between, @@ -176,7 +178,7 @@ impl Render for TitleBar { let show_menus = show_menus(cx); - let mut children = Vec::new(); + let mut children = >::new(); let mut project_name = None; let mut repository = None; @@ -188,14 +190,20 @@ impl Render for TitleBar { .root_name() .file_name() .map(|name| SharedString::from(name.to_string())); - linked_worktree_name = repository.as_ref().and_then(|repo| { + if let Some(repo) = &repository { let repo = repo.read(cx); - linked_worktree_short_name( + linked_worktree_name = linked_worktree_short_name( repo.original_repo_abs_path.as_ref(), repo.work_directory_abs_path.as_ref(), - ) - .filter(|name| Some(name) != project_name.as_ref()) - }); + ); + if let Some(name) = repo + .original_repo_abs_path + .file_name() + .and_then(|name| name.to_str()) + { + project_name = Some(SharedString::from(name.to_string())); + } + } } children.push( @@ -389,6 +397,16 @@ impl TitleBar { }), ); subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify())); + if let Some(workspace_entity) = workspace.weak_handle().upgrade() { + subscriptions.push(cx.subscribe( + &workspace_entity, + |_, _, event: &workspace::Event, cx| { + if matches!(event, workspace::Event::WorktreeCreationChanged) { + cx.notify(); + } + }, + )); + } subscriptions.push(cx.observe_button_layout_changed(window, |_, _, cx| cx.notify())); if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| { @@ -568,7 +586,7 @@ impl TitleBar { ) }, ) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .into_any_element(), ) } @@ -751,7 +769,7 @@ impl TitleBar { ) }, ) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) .into_any_element() } @@ -808,7 +826,7 @@ impl TitleBar { ) }, ) - .anchor(gpui::Corner::TopLeft) + .anchor(gpui::Anchor::TopLeft) } fn render_project_branch( @@ -816,12 +834,14 @@ impl TitleBar { repository: Entity, linked_worktree_name: Option, cx: &mut Context, - ) -> Option { + ) -> Option { let workspace = self.workspace.upgrade()?; - let (branch_name, icon_info) = { + let (branch_name, icon_info, is_detached_head) = { let repo = repository.read(cx); + let is_detached_head = repo.branch.is_none(); + let branch_name = repo .branch .as_ref() @@ -851,67 +871,131 @@ impl TitleBar { (IconName::GitBranch, Color::Muted) }; - (branch_name, icon_info) + (branch_name, icon_info, is_detached_head) }; let branch_name = branch_name?; let settings = TitleBarSettings::get_global(cx); let effective_repository = Some(repository); - Some( - PopoverMenu::new("branch-menu") + let worktree_label: SharedString = linked_worktree_name.unwrap_or_else(|| "main".into()); + + let (creation_in_progress, is_switch) = self + .workspace + .upgrade() + .map(|ws| { + let creation = ws.read(cx).active_worktree_creation(); + (creation.label.clone(), creation.is_switch) + }) + .unwrap_or((None, false)); + let is_creating = creation_in_progress.is_some(); + + let display_label: SharedString = if let Some(ref name) = creation_in_progress { + if is_switch { + format!("Loading {}…", name).into() + } else { + format!("Creating {}…", name).into() + } + } else { + worktree_label.clone() + }; + + let worktree_button = { + let project = self.project.clone(); + let workspace_handle = workspace.downgrade(); + PopoverMenu::new("worktree-picker-menu") .menu(move |window, cx| { - Some(git_ui::git_picker::popover( - workspace.downgrade(), - effective_repository.clone(), - git_ui::git_picker::GitPickerTab::Branches, - gpui::rems(34.), - window, - cx, - )) + // When opened from the title bar, focus is on the trigger + // button (not a dock), so `focused_dock` is `None`. That's + // fine — there's no prior dock focus to restore. + Some(cx.new(|cx| { + WorktreePicker::new(project.clone(), workspace_handle.clone(), window, cx) + })) }) .trigger_with_tooltip( - ButtonLike::new("project_branch_trigger") + Button::new("worktree_picker_trigger", display_label) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child( - h_flex() - .gap_0p5() - .when(settings.show_branch_icon, |this| { - let (icon, icon_color) = icon_info; - this.child( - Icon::new(icon).size(IconSize::XSmall).color(icon_color), - ) - }) - .when_some(linked_worktree_name.as_ref(), |this, worktree_name| { - this.child( - Label::new(worktree_name) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("/").size(LabelSize::Small).color( - Color::Custom( - cx.theme().colors().text_muted.opacity(0.4), - ), - ), - ) - }) - .child( - Label::new(branch_name) - .size(LabelSize::Small) - .color(Color::Muted), - ), + .label_size(LabelSize::Small) + .color(Color::Muted) + .loading(is_creating) + .start_icon( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), ), move |_window, cx| { Tooltip::with_meta( - "Git Switcher", - Some(&zed_actions::git::Branch), - "Worktrees, Branches, and Stashes", + "Worktree", + Some(&zed_actions::git::Worktree), + format!("Currently In Use: {}", worktree_label), cx, ) }, ) - .anchor(gpui::Corner::TopLeft), + .anchor(gpui::Anchor::TopLeft) + }; + + let branch_tooltip_label = branch_name.clone(); + let (branch_icon, branch_icon_color) = if settings.show_branch_status_icon { + icon_info + } else { + (IconName::GitBranch, Color::Muted) + }; + + let trigger = if is_detached_head { + Button::new("project_branch_trigger", "Create Branch") + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .label_size(LabelSize::Small) + .start_icon( + Icon::new(IconName::GitBranchPlus) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + } else { + Button::new("project_branch_trigger", branch_name) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .label_size(LabelSize::Small) + .color(Color::Muted) + .start_icon( + Icon::new(branch_icon) + .size(IconSize::XSmall) + .color(branch_icon_color), + ) + }; + + let git_picker_button = PopoverMenu::new("branch-menu") + .menu(move |window, cx| { + Some(git_ui::git_picker::popover( + workspace.downgrade(), + effective_repository.clone(), + git_ui::git_picker::GitPickerTab::Branches, + gpui::rems(34.), + window, + cx, + )) + }) + .trigger_with_tooltip(trigger, move |_window, cx| { + let meta = if is_detached_head { + format!("Detached HEAD: {}", branch_tooltip_label) + } else { + format!("Currently Checked Out: {}", branch_tooltip_label) + }; + Tooltip::with_meta("Branch & Stash", Some(&zed_actions::git::Branch), meta, cx) + }) + .anchor(gpui::Anchor::TopLeft); + + Some( + h_flex() + .gap_px() + .child(worktree_button) + .child( + Label::new("/") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.25), + ) + .child(git_picker_button) + .into_any_element(), ) } @@ -1220,6 +1304,6 @@ impl TitleBar { }) .into() }) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) } } diff --git a/crates/title_bar/src/title_bar_settings.rs b/crates/title_bar/src/title_bar_settings.rs index 61f951ca305d1a..c81c271b3554fe 100644 --- a/crates/title_bar/src/title_bar_settings.rs +++ b/crates/title_bar/src/title_bar_settings.rs @@ -3,7 +3,7 @@ use settings::{RegisterSetting, Settings, SettingsContent}; #[derive(Copy, Clone, Debug, RegisterSetting)] pub struct TitleBarSettings { - pub show_branch_icon: bool, + pub show_branch_status_icon: bool, pub show_onboarding_banner: bool, pub show_user_picture: bool, pub show_branch_name: bool, @@ -18,7 +18,7 @@ impl Settings for TitleBarSettings { fn from_settings(s: &SettingsContent) -> Self { let content = s.title_bar.clone().unwrap(); TitleBarSettings { - show_branch_icon: content.show_branch_icon.unwrap(), + show_branch_status_icon: content.show_branch_status_icon.unwrap(), show_onboarding_banner: content.show_onboarding_banner.unwrap(), show_user_picture: content.show_user_picture.unwrap(), show_branch_name: content.show_branch_name.unwrap(), diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index af538069638e67..c962aa4f9f24e5 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -387,6 +387,7 @@ impl RenderOnce for ThreadItem { .cursor_pointer() .group("thread-item") .relative() + .flex_shrink_0() .overflow_hidden() .w_full() .py_1() @@ -417,14 +418,15 @@ impl RenderOnce for ThreadItem { .when(self.hovered, |this| { this.when_some(self.action_slot, |this, slot| { let overlay = GradientFade::new(base_bg, hover_bg, hover_bg) - .width(px(64.0)) - .right(px(6.)) - .gradient_stop(0.75) + .width(px(80.0)) + .right(px(8.)) + .gradient_stop(0.80) .group_name("thread-item"); this.child( h_flex() .relative() + .pr_1p5() .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation() }) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 006892effc8676..c82b05a98a3493 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -3,7 +3,7 @@ use crate::{ ListSubHeader, Tooltip, prelude::*, utils::WithRemSize, }; use gpui::{ - Action, AnyElement, App, Bounds, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, + Action, Anchor, AnyElement, App, Bounds, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Size, Subscription, anchored, canvas, prelude::*, px, }; @@ -1694,7 +1694,7 @@ impl ContextMenu { })) .child( anchored() - .anchor(Corner::TopLeft) + .anchor(Anchor::TopLeft) .snap_to_window_with_margin(px(8.0)) .child( div() diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index 961608461c0497..c3cb3bcf0d5335 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -1,4 +1,4 @@ -use gpui::{AnyView, Corner, Entity, Pixels, Point}; +use gpui::{Anchor, AnyView, Entity, Pixels, Point}; use crate::{ButtonLike, ContextMenu, PopoverMenu, prelude::*}; @@ -30,7 +30,7 @@ pub struct DropdownMenu { full_width: bool, disabled: bool, handle: Option>, - attach: Option, + attach: Option, offset: Option>, tab_index: Option, chevron: bool, @@ -117,7 +117,7 @@ impl DropdownMenu { } /// Defines which corner of the handle to attach the menu's anchor to. - pub fn attach(mut self, attach: Corner) -> Self { + pub fn attach(mut self, attach: Anchor) -> Self { self.attach = Some(attach); self } @@ -215,7 +215,7 @@ impl RenderOnce for DropdownMenu { popover .attach(match self.attach { Some(attach) => attach, - None => Corner::BottomRight, + None => Anchor::BottomRight, }) .when_some(self.offset, |this, offset| this.offset(offset)) .when_some(self.handle, |this, handle| this.with_handle(handle)) diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index cd79e50ce01b1f..9522d844d1a82f 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -1,7 +1,7 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - AnyElement, AnyView, App, Bounds, Corner, DismissEvent, DispatchPhase, Element, ElementId, + Anchor, AnyElement, AnyView, App, Bounds, DismissEvent, DispatchPhase, Element, ElementId, Entity, Focusable as _, GlobalElementId, HitboxBehavior, HitboxId, InteractiveElement, IntoElement, LayoutId, Length, ManagedView, MouseDownEvent, ParentElement, Pixels, Point, Style, Window, anchored, deferred, div, point, prelude::FluentBuilder, px, size, @@ -137,8 +137,8 @@ pub struct PopoverMenu { >, >, menu_builder: Option Option> + 'static>>, - anchor: Corner, - attach: Option, + anchor: Anchor, + attach: Option, offset: Option>, trigger_handle: Option>, on_open: Option>, @@ -152,7 +152,7 @@ impl PopoverMenu { id: id.into(), child_builder: None, menu_builder: None, - anchor: Corner::TopLeft, + anchor: Anchor::TopLeft, attach: None, offset: None, trigger_handle: None, @@ -219,13 +219,13 @@ impl PopoverMenu { /// Defines which corner of the menu to anchor to the attachment point. /// By default, it uses the cursor position. Also see the `attach` method. - pub fn anchor(mut self, anchor: Corner) -> Self { + pub fn anchor(mut self, anchor: Anchor) -> Self { self.anchor = anchor; self } /// Defines which corner of the handle to attach the menu's anchor to. - pub fn attach(mut self, attach: Corner) -> Self { + pub fn attach(mut self, attach: Anchor) -> Self { self.attach = Some(attach); self } @@ -242,13 +242,18 @@ impl PopoverMenu { self } - fn resolved_attach(&self) -> Corner { - self.attach.unwrap_or(match self.anchor { - Corner::TopLeft => Corner::BottomLeft, - Corner::TopRight => Corner::BottomRight, - Corner::BottomLeft => Corner::TopLeft, - Corner::BottomRight => Corner::TopRight, - }) + fn resolved_attach(&self) -> Anchor { + self.attach + .unwrap_or(self.attach.unwrap_or(match self.anchor { + Anchor::TopLeft => Anchor::BottomLeft, + Anchor::TopCenter => Anchor::BottomCenter, + Anchor::TopRight => Anchor::BottomRight, + Anchor::BottomLeft => Anchor::TopLeft, + Anchor::BottomCenter => Anchor::TopCenter, + Anchor::BottomRight => Anchor::TopRight, + Anchor::LeftCenter => Anchor::LeftCenter, + Anchor::RightCenter => Anchor::RightCenter, + })) } fn resolved_offset(&self, window: &mut Window) -> Point { @@ -256,8 +261,11 @@ impl PopoverMenu { // Default offset = 4px padding + 1px border let offset = rems_from_px(5.) * window.rem_size(); match self.anchor { - Corner::TopRight | Corner::BottomRight => point(offset, px(0.)), - Corner::TopLeft | Corner::BottomLeft => point(-offset, px(0.)), + Anchor::TopRight | Anchor::BottomRight | Anchor::RightCenter => { + point(offset, px(0.)) + } + Anchor::TopLeft | Anchor::BottomLeft | Anchor::LeftCenter => point(-offset, px(0.)), + Anchor::TopCenter | Anchor::BottomCenter => point(px(0.), px(0.)), } }) } diff --git a/crates/ui/src/components/right_click_menu.rs b/crates/ui/src/components/right_click_menu.rs index faf2cb3429b610..a6b9515cacad30 100644 --- a/crates/ui/src/components/right_click_menu.rs +++ b/crates/ui/src/components/right_click_menu.rs @@ -1,7 +1,7 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - AnyElement, App, Bounds, Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, + Anchor, AnyElement, App, Bounds, DismissEvent, DispatchPhase, Element, ElementId, Entity, Focusable as _, GlobalElementId, Hitbox, HitboxBehavior, InteractiveElement, IntoElement, LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Window, anchored, deferred, div, px, @@ -11,8 +11,8 @@ pub struct RightClickMenu { id: ElementId, child_builder: Option AnyElement + 'static>>, menu_builder: Option Entity + 'static>>, - anchor: Option, - attach: Option, + anchor: Option, + attach: Option, } impl RightClickMenu { @@ -34,13 +34,13 @@ impl RightClickMenu { /// anchor defines which corner of the menu to anchor to the attachment point /// (by default the cursor position, but see attach) - pub fn anchor(mut self, anchor: Corner) -> Self { + pub fn anchor(mut self, anchor: Anchor) -> Self { self.anchor = Some(anchor); self } /// attach defines which corner of the handle to attach the menu's anchor to - pub fn attach(mut self, attach: Corner) -> Self { + pub fn attach(mut self, attach: Anchor) -> Self { self.attach = Some(attach); self } diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 86f5e3b4ccbe80..77ceae9a34684a 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -6,8 +6,8 @@ use std::{ }; use gpui::{ - Along, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, Context, - Corner, Corners, CursorStyle, DispatchPhase, Div, Edges, Element, ElementId, Entity, EntityId, + Along, Anchor, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, + Context, Corners, CursorStyle, DispatchPhase, Div, Edges, Element, ElementId, Entity, EntityId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, Size, Stateful, @@ -1122,10 +1122,10 @@ impl Element for ScrollbarElement { .into_iter() .map(|(axis, thumb_range, reserved_space)| { let track_anchor = match axis { - ScrollbarAxis::Horizontal => Corner::BottomLeft, - ScrollbarAxis::Vertical => Corner::TopRight, + ScrollbarAxis::Horizontal => Anchor::BottomLeft, + ScrollbarAxis::Vertical => Anchor::TopRight, }; - let Bounds { origin, size } = Bounds::from_corner_and_size( + let Bounds { origin, size } = Bounds::from_anchor_and_size( track_anchor, bounds .corner(track_anchor) diff --git a/crates/util/src/rel_path.rs b/crates/util/src/rel_path.rs index 5e20aacad5fe17..8916236925e9be 100644 --- a/crates/util/src/rel_path.rs +++ b/crates/util/src/rel_path.rs @@ -27,7 +27,7 @@ pub struct RelPath(str); /// relative and normalized. /// /// This type is to [`RelPath`] as [`std::path::PathBuf`] is to [`std::path::Path`] -#[derive(PartialEq, Eq, Clone, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Clone, Ord, PartialOrd, Serialize)] pub struct RelPathBuf(String); impl RelPath { @@ -333,12 +333,36 @@ impl RelPathBuf { } } +impl<'de> Deserialize<'de> for RelPathBuf { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let path = String::deserialize(deserializer)?; + let rel_path = + RelPath::new(Path::new(&path), PathStyle::local()).map_err(serde::de::Error::custom)?; + Ok(rel_path.into_owned()) + } +} + impl Into> for RelPathBuf { fn into(self) -> Arc { Arc::from(self.as_rel_path()) } } +impl AsRef for RelPathBuf { + fn as_ref(&self) -> &Path { + self.as_std_path() + } +} + +impl AsRef for RelPath { + fn as_ref(&self) -> &Path { + self.as_std_path() + } +} + impl AsRef for RelPathBuf { fn as_ref(&self) -> &RelPath { self.as_rel_path() @@ -378,12 +402,28 @@ pub fn rel_path(path: &str) -> &RelPath { RelPath::unix(path).unwrap() } +#[cfg(any(test, feature = "test-support"))] +#[track_caller] +pub fn rel_path_buf(path: &str) -> RelPathBuf { + RelPath::unix(path).unwrap().to_rel_path_buf() +} + impl PartialEq for RelPath { fn eq(&self, other: &str) -> bool { self.0 == *other } } +pub trait PathExt { + fn to_rel_path_buf(&self) -> Result; +} + +impl + ?Sized> PathExt for T { + fn to_rel_path_buf(&self) -> Result { + Ok(RelPath::new(self.as_ref(), PathStyle::local())?.into_owned()) + } +} + #[derive(Default)] pub struct RelPathComponents<'a>(&'a str); diff --git a/crates/util/src/shell_builder.rs b/crates/util/src/shell_builder.rs index 86a588d44afa3b..58857ab2f619bb 100644 --- a/crates/util/src/shell_builder.rs +++ b/crates/util/src/shell_builder.rs @@ -111,7 +111,7 @@ impl ShellBuilder { | ShellKind::Xonsh | ShellKind::Elvish => { combined_command.insert(0, '('); - combined_command.push_str(") { combined_command.insert_str(0, "$null | & {"); @@ -157,7 +157,7 @@ impl ShellBuilder { | ShellKind::Xonsh | ShellKind::Elvish => { combined_command.insert(0, '('); - combined_command.push_str(") { combined_command.insert_str(0, "$null | & {"); @@ -273,7 +273,7 @@ mod test { .build(Some("echo".into()), &["nothing".to_string()]); assert_eq!(program, "nu"); - assert_eq!(args, vec!["-i", "-c", "(echo nothing) { + let (point, goal) = motion + .move_point( + map, + current_head, + selection.goal, + times, + &text_layout_details, + ) + .unwrap_or((current_head, selection.goal)); + (movement::saturating_left(map, point), goal) + } // Going to next word start is special cased // since Vim differs from Helix in that motion // Vim: `w` goes to the first character of a word @@ -1991,6 +2008,23 @@ mod test { cx.assert_state("h«ellˇ»o", Mode::HelixSelect); } + #[gpui::test] + async fn test_helix_select_end_of_line(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // v g l d should delete to end of line without consuming the newline + cx.set_state("ˇThe quick brown\nfox jumps over", Mode::HelixNormal); + cx.simulate_keystrokes("v g l d"); + cx.assert_state("ˇ\nfox jumps over", Mode::HelixNormal); + + // same from the middle of a line — cursor lands on the last + // remaining character (the space) after delete + cx.set_state("The ˇquick brown\nfox jumps over", Mode::HelixNormal); + cx.simulate_keystrokes("v g l d"); + cx.assert_state("Theˇ \nfox jumps over", Mode::HelixNormal); + } + #[gpui::test] async fn test_helix_select_mode_motion_multiple_cursors(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 8bdd4e7ef50fea..1d0d0812e82899 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -29,7 +29,7 @@ use editor::{Anchor, SelectionEffects}; use editor::{Bias, ToPoint}; use editor::{display_map::ToDisplayPoint, movement}; use gpui::{Context, Window, actions}; -use language::{Point, SelectionGoal}; +use language::{AutoIndentMode, Point, SelectionGoal}; use log::error; use multi_buffer::MultiBufferRow; @@ -729,19 +729,35 @@ impl Vim { .into_iter() .map(|selection| selection.start.row) .collect(); - let edits = selection_start_rows - .into_iter() - .map(|row| { - let indent = snapshot - .indent_and_comment_for_line(MultiBufferRow(row), cx) - .chars() - .collect::(); - let start_of_line = Point::new(row, 0); - (start_of_line..start_of_line, indent + "\n") - }) - .collect::>(); - editor.edit_with_autoindent(edits, cx); + let mut auto_indent_edits = Vec::new(); + let mut plain_edits = Vec::new(); + + for row in selection_start_rows { + let auto_indent_mode = snapshot + .language_settings_at(Point::new(row, 0), cx) + .auto_indent; + let indent = if auto_indent_mode == AutoIndentMode::None { + String::new() + } else { + snapshot.indent_and_comment_for_line(MultiBufferRow(row), cx) + }; + let start_of_line = Point::new(row, 0); + let edit = (start_of_line..start_of_line, indent + "\n"); + if auto_indent_mode == AutoIndentMode::None { + plain_edits.push(edit); + } else { + auto_indent_edits.push(edit); + } + } + + if !plain_edits.is_empty() { + editor.edit(plain_edits, cx); + } + if !auto_indent_edits.is_empty() { + editor.edit_with_autoindent(auto_indent_edits, cx); + } + editor.change_selections(Default::default(), window, cx, |s| { s.move_with(&mut |map, selection| { let previous_line = map.start_of_relative_buffer_row(selection.start, -1); @@ -776,18 +792,28 @@ impl Vim { } }) .collect(); - let edits = selection_end_rows - .into_iter() - .map(|row| { - let indent = snapshot - .indent_and_comment_for_line(MultiBufferRow(row), cx) - .chars() - .collect::(); - let end_of_line = Point::new(row, snapshot.line_len(MultiBufferRow(row))); - (end_of_line..end_of_line, "\n".to_string() + &indent) - }) - .collect::>(); + let mut auto_indent_edits = Vec::new(); + let mut plain_edits = Vec::new(); + + for row in selection_end_rows { + let auto_indent_mode = snapshot + .language_settings_at(Point::new(row, 0), cx) + .auto_indent; + let indent = if auto_indent_mode == AutoIndentMode::None { + String::new() + } else { + snapshot.indent_and_comment_for_line(MultiBufferRow(row), cx) + }; + let end_of_line = Point::new(row, snapshot.line_len(MultiBufferRow(row))); + let edit = (end_of_line..end_of_line, "\n".to_string() + &indent); + if auto_indent_mode == AutoIndentMode::None { + plain_edits.push(edit); + } else { + auto_indent_edits.push(edit); + } + } + editor.change_selections(Default::default(), window, cx, |s| { s.move_with(&mut |map, selection| { let current_line = if !selection.is_empty() && selection.end.column() == 0 { @@ -802,7 +828,13 @@ impl Vim { selection.collapse_to(insert_point, SelectionGoal::None) }); }); - editor.edit_with_autoindent(edits, cx); + + if !plain_edits.is_empty() { + editor.edit(plain_edits, cx); + } + if !auto_indent_edits.is_empty() { + editor.edit_with_autoindent(auto_indent_edits, cx); + } }); }); } @@ -1140,6 +1172,7 @@ mod test { state::Mode::{self}, test::{NeovimBackedTestContext, VimTestContext}, }; + use language; #[gpui::test] async fn test_h(cx: &mut gpui::TestAppContext) { @@ -2099,6 +2132,62 @@ mod test { cx.shared_state().await.assert_eq("// hello\n// ˇ\n// x\n"); } + #[gpui::test] + async fn test_o_auto_indent_none(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |s| { + s.project.all_languages.defaults.auto_indent = Some(language::AutoIndentMode::None); + }); + }); + + // o: new line below starts at column 0 regardless of current indentation + cx.set_state(" let xˇ = 1;", Mode::Normal); + cx.simulate_keystrokes("o"); + cx.assert_state(" let x = 1;\nˇ", Mode::Insert); + + // O: new line above starts at column 0 regardless of current indentation + cx.set_state(" let xˇ = 1;", Mode::Normal); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\n let x = 1;", Mode::Insert); + + // o on the first line: no crash and column 0 + cx.set_state("ˇfoo", Mode::Normal); + cx.simulate_keystrokes("o"); + cx.assert_state("foo\nˇ", Mode::Insert); + + // O on the first line: no crash and column 0 + cx.set_state("ˇfoo", Mode::Normal); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\nfoo", Mode::Insert); + + // o on an already-empty line: stays at column 0 + cx.set_state("fooˇ\n\nbar", Mode::Normal); + cx.simulate_keystrokes("j o"); + cx.assert_state("foo\n\nˇ\nbar", Mode::Insert); + } + + #[gpui::test] + async fn test_o_preserve_indent(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |s| { + s.project.all_languages.defaults.auto_indent = + Some(language::AutoIndentMode::PreserveIndent); + }); + }); + + // o: new line below copies current line's indentation + cx.set_state(" let xˇ = 1;", Mode::Normal); + cx.simulate_keystrokes("o"); + cx.assert_state(" let x = 1;\n ˇ", Mode::Insert); + + // O: new line above copies current line's indentation + cx.set_state(" let xˇ = 1;", Mode::Normal); + cx.simulate_keystrokes("shift-o"); + cx.assert_state(" ˇ\n let x = 1;", Mode::Insert); + } + #[gpui::test] async fn test_yank_line_with_trailing_newline(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index fab9b353e3e9bb..f3df2ce1c07d94 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -25,7 +25,7 @@ pub struct Paste { #[serde(default)] before: bool, #[serde(default)] - preserve_clipboard: bool, + pub(crate) preserve_clipboard: bool, } impl Vim { @@ -835,6 +835,41 @@ mod test { ); } + #[gpui::test] + async fn test_editor_paste_visual_preserves_system_clipboard(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + The quick brown + fox ˇjumps over + the lazy dog"}, + Mode::Normal, + ); + + // Put known content on the system clipboard + cx.write_to_clipboard(ClipboardItem::new_string("from clipboard".to_string())); + + // Select "jumps" in visual mode, then editor::Paste (Cmd-V / Ctrl-V) + cx.simulate_keystrokes("v i w"); + cx.dispatch_action(editor::actions::Paste); + + // The selected text should be replaced with clipboard content + cx.assert_state( + indoc! {" + The quick brown + fox from clipboarˇd over + the lazy dog"}, + Mode::Normal, + ); + + // System clipboard must still hold the original value, not "jumps" + assert_eq!( + cx.read_from_clipboard().map(|item| item.text().unwrap()), + Some("from clipboard".into()), + ); + } + #[gpui::test] async fn test_numbered_registers(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 22c453c877ec89..549e5666834616 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -434,7 +434,6 @@ impl Vim { let count = Vim::take_count(cx).unwrap_or(1); Vim::take_forced_motion(cx); let prior_selections = self.editor_selections(window, cx); - let cursor_word = self.editor_cursor_word(window, cx); let vim = cx.entity(); let searched = pane.update(cx, |pane, cx| { @@ -456,10 +455,7 @@ impl Vim { if !search_bar.show(window, cx) { return None; } - let Some(query) = search_bar - .query_suggestion(window, cx) - .or_else(|| cursor_word) - else { + let Some(query) = search_bar.query_suggestion(true, window, cx) else { drop(search_bar.search("", None, false, window, cx)); return None; }; diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 7d4b469d3b94d5..8b5570125c4bf6 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -18,7 +18,7 @@ use gpui::{KeyBinding, Modifiers, MouseButton, TestAppContext, px}; use itertools::Itertools; use language::{CursorShape, Language, LanguageConfig, Point}; pub use neovim_backed_test_context::*; -use settings::SettingsStore; +use settings::{ActionName, SettingsStore}; use ui::Pixels; use util::{path, test::marked_text_ranges}; pub use vim_test_context::*; @@ -1926,7 +1926,7 @@ async fn test_command_alias(cx: &mut gpui::TestAppContext) { cx.update_global(|store: &mut SettingsStore, cx| { store.update_user_settings(cx, |s| { let mut aliases = HashMap::default(); - aliases.insert("Q".to_string(), "upper".to_string()); + aliases.insert("Q".to_string(), ActionName::new("upper")); s.workspace.command_aliases = aliases }); }); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index ce0a8325891029..d9617d34ecfaba 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -33,9 +33,7 @@ use gpui::{ KeystrokeEvent, Render, Subscription, Task, WeakEntity, Window, actions, }; use insert::{NormalBefore, TemporaryNormal}; -use language::{ - CharKind, CharScopeContext, CursorShape, Point, Selection, SelectionGoal, TransactionId, -}; +use language::{CursorShape, Point, Selection, SelectionGoal, TransactionId}; pub use mode_indicator::ModeIndicator; use motion::Motion; use multi_buffer::ToPoint as _; @@ -967,7 +965,9 @@ impl Vim { Mode::Replace => vim.paste_replace(window, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { vim.selected_register.replace('+'); - vim.paste(&VimPaste::default(), window, cx); + let mut action = VimPaste::default(); + action.preserve_clipboard = true; + vim.paste(&action, window, cx); } _ => { vim.update_editor(cx, |_, editor, cx| editor.paste(&Paste, window, cx)); @@ -1595,32 +1595,6 @@ impl Vim { .unwrap_or_default() } - fn editor_cursor_word( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Option { - self.update_editor(cx, |_, editor, cx| { - let snapshot = &editor.snapshot(window, cx); - let selection = editor - .selections - .newest::(&snapshot.display_snapshot); - - let snapshot = snapshot.buffer_snapshot(); - let (range, kind) = - snapshot.surrounding_word(selection.start, Some(CharScopeContext::Completion)); - if kind == Some(CharKind::Word) { - let text: String = snapshot.text_for_range(range).collect(); - if !text.trim().is_empty() { - return Some(text); - } - } - - None - }) - .unwrap_or_default() - } - /// When doing an action that modifies the buffer, we start recording so that `.` /// will replay the action. pub fn start_recording(&mut self, cx: &mut Context) { diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 2014e7ad6f61ba..f74f8fdb4156e7 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -50,7 +50,6 @@ node_runtime.workspace = true parking_lot.workspace = true postage.workspace = true project.workspace = true -release_channel.workspace = true remote.workspace = true schemars.workspace = true serde.workspace = true @@ -67,7 +66,6 @@ theme_settings.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true -vim_mode_setting.workspace = true zed_actions.workspace = true [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index ca8584fb1eb6dc..1983b2921ffcc5 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -7,7 +7,7 @@ use client::proto; use db::kvp::KeyValueStore; use gpui::{ - Action, AnyView, App, Axis, Context, Corner, Entity, EntityId, EventEmitter, FocusHandle, + Action, Anchor, AnyView, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, IntoElement, KeyContext, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement, Render, SharedString, StyleRefinement, Styled, Subscription, WeakEntity, Window, deferred, div, px, @@ -1189,8 +1189,8 @@ impl Render for PanelButtons { let dock_position = dock.position; let (menu_anchor, menu_attach) = match dock.position { - DockPosition::Left => (Corner::BottomLeft, Corner::TopLeft), - DockPosition::Bottom | DockPosition::Right => (Corner::BottomRight, Corner::TopRight), + DockPosition::Left => (Anchor::BottomLeft, Anchor::TopLeft), + DockPosition::Bottom | DockPosition::Right => (Anchor::BottomRight, Anchor::TopRight), }; let dock_entity = self.dock.clone(); diff --git a/crates/workspace/src/history_manager.rs b/crates/workspace/src/history_manager.rs index 9b03a3252d3279..8e60939a9c25be 100644 --- a/crates/workspace/src/history_manager.rs +++ b/crates/workspace/src/history_manager.rs @@ -44,7 +44,7 @@ impl HistoryManager { let db = WorkspaceDb::global(cx); cx.spawn(async move |cx| { let recent_folders = db - .recent_workspaces_on_disk(fs.as_ref()) + .recent_project_workspaces(fs.as_ref()) .await .unwrap_or_default() .into_iter() diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index c4b664e24803e2..5cd669473c73fd 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -953,24 +953,23 @@ impl ItemHandle for Entity { return; } - let vim_mode = vim_mode_setting::VimModeSetting::is_enabled(cx); - let helix_mode = vim_mode_setting::HelixModeSetting::is_enabled(cx); - - if vim_mode || helix_mode { - // We use the command palette for executing commands in Vim and Helix modes (e.g., `:w`), so - // in those cases we don't want to trigger auto-save if the focus has just been transferred - // to the command palette. - // - // This isn't totally perfect, as you could still switch files indirectly via the command - // palette (such as by opening up the tab switcher from it and then switching tabs that - // way). - if workspace.is_active_modal_command_palette(cx) { - return; + // Add the item to a deferred save list. The actual save will happen when + // focus lands on a pane or panel (via handle_pane_focused or + // handle_panel_focused), or when the window deactivates. + // This avoids saving when opening modals and skips saving if focus + // returns to the same item. + workspace.deferred_save_items.push(item.downgrade_item()); + + // Defer the flush to ensure all focus events are processed first. + // This is needed because on_focus_out fires before handle_pane_focused + // when switching items. + cx.defer_in(window, |workspace, window, cx| { + // Don't flush if a modal is active - the user might return + // to the original item when the modal is dismissed. + if !workspace.has_active_modal(window, cx) { + workspace.flush_deferred_saves(window, cx); } - } - - Pane::autosave_item(&item, workspace.project.clone(), window, cx) - .detach_and_log_err(cx); + }); } }, ) @@ -1472,9 +1471,18 @@ pub mod test { impl TestProjectItem { pub fn new(id: u64, path: &str, cx: &mut App) -> Entity { + Self::new_in_worktree(id, path, WorktreeId::from_usize(0), cx) + } + + pub fn new_in_worktree( + id: u64, + path: &str, + worktree_id: WorktreeId, + cx: &mut App, + ) -> Entity { let entry_id = Some(ProjectEntryId::from_proto(id)); let project_path = Some(ProjectPath { - worktree_id: WorktreeId::from_usize(0), + worktree_id, path: rel_path(path).into(), }); cx.new(|_| Self { diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index cb6f21206fc5e1..5949c0b1fffb21 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -26,15 +26,6 @@ pub trait ModalView: ManagedView { fn render_bare(&self) -> bool { false } - - /// Returns whether this [`ModalView`] is the command palette. - /// - /// This breaks the encapsulation of the [`ModalView`] trait a little bit, but there doesn't seem to be an - /// immediate, more elegant way to have the workspace know about the command palette (due to dependency arrow - /// directions). - fn is_command_palette(&self) -> bool { - false - } } trait ModalViewHandle { @@ -42,7 +33,6 @@ trait ModalViewHandle { fn view(&self) -> AnyView; fn fade_out_background(&self, cx: &mut App) -> bool; fn render_bare(&self, cx: &mut App) -> bool; - fn is_command_palette(&self, cx: &App) -> bool; } impl ModalViewHandle for Entity { @@ -61,10 +51,6 @@ impl ModalViewHandle for Entity { fn render_bare(&self, cx: &mut App) -> bool { self.read(cx).render_bare() } - - fn is_command_palette(&self, cx: &App) -> bool { - self.read(cx).is_command_palette() - } } pub struct ActiveModal { @@ -203,13 +189,6 @@ impl ModalLayer { pub fn has_active_modal(&self) -> bool { self.active_modal.is_some() } - - /// Returns whether the active modal is the command palette. - pub fn is_active_modal_command_palette(&self, cx: &App) -> bool { - self.active_modal - .as_ref() - .map_or(false, |modal| modal.modal.is_command_palette(cx)) - } } impl Render for ModalLayer { diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 840e683e153d5d..c1802e797c9367 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -8,7 +8,6 @@ use gpui::{ }; pub use project::ProjectGroupKey; use project::{DisableAiSettings, Project}; -use release_channel::ReleaseChannel; use remote::RemoteConnectionOptions; use settings::Settings; pub use settings::SidebarSide; @@ -103,7 +102,9 @@ pub fn sidebar_side_context_menu( } pub enum MultiWorkspaceEvent { - ActiveWorkspaceChanged, + ActiveWorkspaceChanged { + source_workspace: Option>, + }, WorkspaceAdded(Entity), WorkspaceRemoved(EntityId), ProjectGroupsChanged, @@ -395,8 +396,7 @@ impl MultiWorkspace { } pub fn multi_workspace_enabled(&self, cx: &App) -> bool { - !matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable)) - && !DisableAiSettings::get_global(cx).disable_ai + !DisableAiSettings::get_global(cx).disable_ai } pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context) { @@ -578,7 +578,7 @@ impl MultiWorkspace { cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| { if let WorkspaceEvent::Activate = event { - this.activate(workspace.clone(), window, cx); + this.activate(workspace.clone(), None, window, cx); } }) .detach(); @@ -730,7 +730,7 @@ impl MultiWorkspace { self.retained_workspaces.push(workspace.clone()); } - self.activate(workspace.clone(), window, cx); + self.activate(workspace.clone(), None, window, cx); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); } @@ -1137,19 +1137,52 @@ impl MultiWorkspace { open_mode: OpenMode, window: &mut Window, cx: &mut Context, + ) -> Task>> { + self.find_or_create_workspace_with_source_workspace( + paths, + host, + provisional_project_group_key, + connect_remote, + excluding, + init, + open_mode, + None, + window, + cx, + ) + } + + pub fn find_or_create_workspace_with_source_workspace( + &mut self, + paths: PathList, + host: Option, + provisional_project_group_key: Option, + connect_remote: impl FnOnce( + RemoteConnectionOptions, + &mut Window, + &mut Context, + ) -> Task>>> + + 'static, + excluding: &[Entity], + init: Option) + Send>>, + open_mode: OpenMode, + source_workspace: Option>, + window: &mut Window, + cx: &mut Context, ) -> Task>> { if let Some(workspace) = self.workspace_for_paths(&paths, host.as_ref(), cx) { - self.activate(workspace.clone(), window, cx); + self.activate(workspace.clone(), source_workspace, window, cx); return Task::ready(Ok(workspace)); } let Some(connection_options) = host else { - return self.find_or_create_local_workspace( + return self.find_or_create_local_workspace_with_source_workspace( paths, provisional_project_group_key, excluding, init, open_mode, + source_workspace, window, cx, ); @@ -1178,16 +1211,44 @@ impl MultiWorkspace { ) }); + let effective_paths_vec = + if let Some(project_group) = provisional_project_group_key.as_ref() { + let resolve_tasks = cx.update(|cx| { + let project = new_project.read(cx); + paths_vec + .iter() + .map(|path| project.resolve_abs_path(&path.to_string_lossy(), cx)) + .collect::>() + }); + let resolved = futures::future::join_all(resolve_tasks).await; + // `resolve_abs_path` returns `None` for both "definitely + // absent" and transport errors (it swallows the error via + // `log_err`). This is a weaker guarantee than the local + // `Ok(None)` check, but it matches how the rest of the + // codebase consumes this API. + let all_paths_missing = + !paths_vec.is_empty() && resolved.iter().all(|resolved| resolved.is_none()); + + if all_paths_missing { + project_group.path_list().paths().to_vec() + } else { + paths_vec + } + } else { + paths_vec + }; + let window_handle = window_handle.ok_or_else(|| anyhow::anyhow!("Window is not a MultiWorkspace"))?; open_remote_project_with_existing_connection( connection_options, new_project, - paths_vec, + effective_paths_vec, app_state, window_handle, provisional_project_group_key, + source_workspace, cx, ) .await?; @@ -1216,10 +1277,33 @@ impl MultiWorkspace { open_mode: OpenMode, window: &mut Window, cx: &mut Context, + ) -> Task>> { + self.find_or_create_local_workspace_with_source_workspace( + path_list, + project_group, + excluding, + init, + open_mode, + None, + window, + cx, + ) + } + + pub fn find_or_create_local_workspace_with_source_workspace( + &mut self, + path_list: PathList, + project_group: Option, + excluding: &[Entity], + init: Option) + Send>>, + open_mode: OpenMode, + source_workspace: Option>, + window: &mut Window, + cx: &mut Context, ) -> Task>> { if let Some(workspace) = self.workspace_for_paths_excluding(&path_list, None, excluding, cx) { - self.activate(workspace.clone(), window, cx); + self.activate(workspace.clone(), source_workspace, window, cx); return Task::ready(Ok(workspace)); } @@ -1264,7 +1348,12 @@ impl MultiWorkspace { cx, ) .inspect(|workspace| { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate( + workspace.clone(), + source_workspace.clone(), + window, + cx, + ); }) }) .ok() @@ -1327,6 +1416,7 @@ impl MultiWorkspace { pub fn activate( &mut self, workspace: Entity, + source_workspace: Option>, window: &mut Window, cx: &mut Context, ) { @@ -1359,7 +1449,7 @@ impl MultiWorkspace { self.detach_workspace(&old_active_workspace, cx); } - cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged { source_workspace }); self.serialize(cx); self.focus_active_workspace(window, cx); cx.notify(); @@ -1644,7 +1734,7 @@ impl MultiWorkspace { cx: &mut Context, ) -> Entity { let workspace = cx.new(|cx| Workspace::test_new(project, window, cx)); - self.activate(workspace.clone(), window, cx); + self.activate(workspace.clone(), None, window, cx); workspace } @@ -1675,7 +1765,7 @@ impl MultiWorkspace { cx, ); let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); - self.activate(new_workspace.clone(), window, cx); + self.activate(new_workspace.clone(), None, window, cx); let weak_workspace = new_workspace.downgrade(); let db = crate::persistence::WorkspaceDb::global(cx); @@ -1800,12 +1890,12 @@ impl MultiWorkspace { !workspaces.contains(&new_active), "fallback workspace must not be one of the workspaces being removed" ); - this.activate(new_active, window, cx); + this.activate(new_active, None, window, cx); })?; } else { this.update_in(cx, |this, window, cx| { if *this.workspace() != original_active { - this.activate(original_active, window, cx); + this.activate(original_active, None, window, cx); } })?; } @@ -1840,15 +1930,55 @@ impl MultiWorkspace { cx: &mut Context, ) -> Task>> { if self.multi_workspace_enabled(cx) { - self.find_or_create_local_workspace( - PathList::new(&paths), - None, - &[], - None, - OpenMode::Activate, - window, - cx, - ) + let empty_workspace = if self + .active_workspace + .read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + .next() + .is_none() + { + Some(self.active_workspace.clone()) + } else { + None + }; + + cx.spawn_in(window, async move |this, cx| { + if let Some(empty_workspace) = empty_workspace.as_ref() { + let should_continue = empty_workspace + .update_in(cx, |workspace, window, cx| { + workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx) + })? + .await?; + if !should_continue { + return Ok(empty_workspace.clone()); + } + } + + let create_task = this.update_in(cx, |this, window, cx| { + this.find_or_create_local_workspace( + PathList::new(&paths), + None, + empty_workspace.as_slice(), + None, + OpenMode::Activate, + window, + cx, + ) + })?; + let new_workspace = create_task.await?; + + if let Some(empty_workspace) = empty_workspace { + this.update(cx, |this, cx| { + if this.is_workspace_retained(&empty_workspace) { + this.detach_workspace(&empty_workspace, cx); + } + })?; + } + + Ok(new_workspace) + }) } else { let workspace = self.workspace().clone(); cx.spawn_in(window, async move |_this, cx| { diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index e5ee718a528765..3b715fe80ca2b8 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -1,9 +1,10 @@ use std::path::PathBuf; use super::*; +use crate::item::test::TestItem; use client::proto; use fs::{FakeFs, Fs}; -use gpui::TestAppContext; +use gpui::{TestAppContext, VisualTestContext}; use project::DisableAiSettings; use serde_json::json; use settings::SettingsStore; @@ -555,7 +556,7 @@ async fn test_close_workspace_prefers_already_loaded_neighboring_workspace( }); multi_workspace.update_in(cx, |multi_workspace, window, cx| { - multi_workspace.activate(workspace_a.clone(), window, cx); + multi_workspace.activate(workspace_a.clone(), None, window, cx); multi_workspace.test_add_project_group(ProjectGroup { key: project_c_key.clone(), workspaces: Vec::new(), @@ -767,3 +768,138 @@ async fn test_remote_project_root_dir_changes_update_groups(cx: &mut TestAppCont ); }); } + +#[gpui::test] +async fn test_open_project_closes_empty_workspace_but_not_non_empty_ones(cx: &mut TestAppContext) { + init_test(cx); + let app_state = cx.update(AppState::test); + let fs = app_state.fs.as_fake(); + fs.insert_tree(path!("/project_a"), json!({ "file_a.txt": "" })) + .await; + fs.insert_tree(path!("/project_b"), json!({ "file_b.txt": "" })) + .await; + + // Start with an empty (no-worktrees) workspace. + let project = Project::test(app_state.fs.clone(), [], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.run_until_parked(); + + window + .update(cx, |mw, _window, cx| mw.open_sidebar(cx)) + .unwrap(); + cx.run_until_parked(); + + let empty_workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + // Add a dirty untitled item to the empty workspace. + let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true)); + empty_workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx); + }); + + // Opening a project while the lone empty workspace has unsaved + // changes prompts the user. + let open_task = window + .update(cx, |mw, window, cx| { + mw.open_project( + vec![PathBuf::from(path!("/project_a"))], + OpenMode::Activate, + window, + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + + // Cancelling keeps the empty workspace. + assert!(cx.has_pending_prompt(),); + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + assert_eq!(open_task.await.unwrap(), empty_workspace); + window + .read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().count(), 1); + assert_eq!(mw.workspace(), &empty_workspace); + assert_eq!(mw.project_group_keys(), vec![]); + }) + .unwrap(); + + // Discarding the unsaved changes closes the empty workspace + // and opens the new project in its place. + let open_task = window + .update(cx, |mw, window, cx| { + mw.open_project( + vec![PathBuf::from(path!("/project_a"))], + OpenMode::Activate, + window, + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + + assert!(cx.has_pending_prompt(),); + cx.simulate_prompt_answer("Don't Save"); + cx.run_until_parked(); + + let workspace_a = open_task.await.unwrap(); + assert_ne!(workspace_a, empty_workspace); + + window + .read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().count(), 1); + assert_eq!(mw.workspace(), &workspace_a); + assert_eq!( + mw.project_group_keys(), + vec![ProjectGroupKey::new( + None, + PathList::new(&[path!("/project_a")]) + )] + ); + }) + .unwrap(); + assert!( + empty_workspace.read_with(cx, |workspace, _cx| workspace.session_id().is_none()), + "the detached empty workspace should no longer be attached to the session", + ); + + let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true)); + workspace_a.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx); + }); + + // Opening another project does not close the existing project or prompt. + let workspace_b = window + .update(cx, |mw, window, cx| { + mw.open_project( + vec![PathBuf::from(path!("/project_b"))], + OpenMode::Activate, + window, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + cx.run_until_parked(); + + assert!(!cx.has_pending_prompt()); + assert_ne!(workspace_b, workspace_a); + window + .read_with(cx, |mw, _cx| { + assert_eq!(mw.workspaces().count(), 2); + assert_eq!(mw.workspace(), &workspace_b); + assert_eq!( + mw.project_group_keys(), + vec![ + ProjectGroupKey::new(None, PathList::new(&[path!("/project_b")])), + ProjectGroupKey::new(None, PathList::new(&[path!("/project_a")])) + ] + ); + }) + .unwrap(); + assert!(workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),); +} diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 861e8657621607..9c68671eb794ac 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -18,7 +18,7 @@ use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; use futures::{StreamExt, stream::FuturesUnordered}; use gpui::{ - Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div, + Action, Anchor, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Div, DragMoveEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusOutEvent, Focusable, KeyContext, MouseButton, NavigationDirection, Pixels, Point, PromptLevel, Render, ScrollHandle, Subscription, Task, WeakEntity, WeakFocusHandle, Window, actions, anchored, @@ -3689,7 +3689,7 @@ impl Pane { pub fn render_menu_overlay(menu: &Entity) -> Div { div().absolute().bottom_0().right_0().size_0().child( - deferred(anchored().anchor(Corner::TopRight).child(menu.clone())).with_priority(1), + deferred(anchored().anchor(Anchor::TopRight).child(menu.clone())).with_priority(1), ) } @@ -4188,7 +4188,7 @@ fn default_render_tab_bar_buttons( IconButton::new("plus", IconName::Plus).icon_size(IconSize::Small), Tooltip::text("New..."), ) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .with_handle(pane.new_item_context_menu_handle.clone()) .menu(move |window, cx| { Some(ContextMenu::build(window, cx, |menu, _, _| { @@ -4214,7 +4214,7 @@ fn default_render_tab_bar_buttons( .disabled(!can_clone && !can_split_move), Tooltip::text("Split Pane"), ) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .with_handle(pane.split_item_context_menu_handle.clone()) .menu(move |window, cx| { ContextMenu::build(window, cx, |menu, _, _| { diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index b1617fbc623314..7248abe9b8dba7 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -66,6 +66,14 @@ fn parse_timestamp(text: &str) -> DateTime { .unwrap_or_else(|_| Utc::now()) } +fn contains_wsl_path(paths: &PathList) -> bool { + cfg!(windows) + && paths + .paths() + .iter() + .any(|path| util::paths::WslPath::from_path(path).is_some()) +} + #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) struct SerializedAxis(pub(crate) gpui::Axis); impl sqlez::bindable::StaticColumnCount for SerializedAxis {} @@ -1740,26 +1748,30 @@ impl WorkspaceDb { WorkspaceId, PathList, Option, + Option, DateTime, )>, > { Ok(self .recent_workspaces_query()? .into_iter() - .map(|(id, paths, order, remote_connection_id, timestamp)| { - ( - id, - PathList::deserialize(&SerializedPathList { paths, order }), - remote_connection_id.map(RemoteConnectionId), - parse_timestamp(×tamp), - ) - }) + .map( + |(id, paths, order, remote_connection_id, session_id, timestamp)| { + ( + id, + PathList::deserialize(&SerializedPathList { paths, order }), + remote_connection_id.map(RemoteConnectionId), + session_id, + parse_timestamp(×tamp), + ) + }, + ) .collect()) } query! { - fn recent_workspaces_query() -> Result, String)>> { - SELECT workspace_id, paths, paths_order, remote_connection_id, timestamp + fn recent_workspaces_query() -> Result, Option, String)>> { + SELECT workspace_id, paths, paths_order, remote_connection_id, session_id, timestamp FROM workspaces WHERE paths IS NOT NULL OR @@ -1921,9 +1933,7 @@ impl WorkspaceDb { let mut any_dir = false; for path in paths { match fs.metadata(path).await.ok().flatten() { - None => { - return false; - } + None => return false, Some(meta) => { if meta.is_dir { any_dir = true; @@ -1934,9 +1944,10 @@ impl WorkspaceDb { any_dir } - // Returns the recent locations which are still valid on disk and deletes ones which no longer - // exist. - pub async fn recent_workspaces_on_disk( + // Returns the recent project workspaces suitable for showing in the recent-projects UI. + // Scratch workspaces (no paths) are filtered out - they aren't really "projects" and + // are restored separately by `last_session_workspace_locations`. + pub async fn recent_project_workspaces( &self, fs: &dyn Fs, ) -> Result< @@ -1947,11 +1958,9 @@ impl WorkspaceDb { DateTime, )>, > { - let mut result = Vec::new(); - let mut workspaces_to_delete = Vec::new(); let remote_connections = self.remote_connections()?; - let now = Utc::now(); - for (id, paths, remote_connection_id, timestamp) in self.recent_workspaces()? { + let mut result = Vec::new(); + for (id, paths, remote_connection_id, _session_id, timestamp) in self.recent_workspaces()? { if let Some(remote_connection_id) = remote_connection_id { if let Some(connection_options) = remote_connections.get(&remote_connection_id) { result.push(( @@ -1960,7 +1969,44 @@ impl WorkspaceDb { paths, timestamp, )); - } else { + } + continue; + } + + if paths.paths().is_empty() || contains_wsl_path(&paths) { + continue; + } + + if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { + result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp)); + } + } + Ok(result) + } + + // Deletes workspace rows that can no longer be restored from. Remote workspaces whose + // connection was removed, and (on Windows) workspaces pointing at WSL paths, are cleaned + // up immediately. Local workspaces with no valid paths on disk are kept for seven days + // after going stale. Workspaces belonging to the current session or the last session are + // always preserved so that an in-progress restore can rehydrate them. + pub async fn garbage_collect_workspaces( + &self, + fs: &dyn Fs, + current_session_id: &str, + last_session_id: Option<&str>, + ) -> Result<()> { + let remote_connections = self.remote_connections()?; + let now = Utc::now(); + let mut workspaces_to_delete = Vec::new(); + for (id, paths, remote_connection_id, session_id, timestamp) in self.recent_workspaces()? { + if let Some(session_id) = session_id.as_deref() { + if session_id == current_session_id || Some(session_id) == last_session_id { + continue; + } + } + + if let Some(remote_connection_id) = remote_connection_id { + if !remote_connections.contains_key(&remote_connection_id) { workspaces_to_delete.push(id); } continue; @@ -1971,20 +2017,14 @@ impl WorkspaceDb { // will wait for the WSL VM and file server to boot up. This can // block for many seconds. Supported scenarios use remote // workspaces. - if cfg!(windows) { - let has_wsl_path = paths - .paths() - .iter() - .any(|path| util::paths::WslPath::from_path(path).is_some()); - if has_wsl_path { - workspaces_to_delete.push(id); - continue; - } + if contains_wsl_path(&paths) { + workspaces_to_delete.push(id); + continue; } - if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { - result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp)); - } else if now - timestamp >= chrono::Duration::days(7) { + if !Self::all_paths_exist_with_a_directory(paths.paths(), fs).await + && now - timestamp >= chrono::Duration::days(7) + { workspaces_to_delete.push(id); } } @@ -1995,7 +2035,7 @@ impl WorkspaceDb { .map(|id| self.delete_workspace_by_id(id)), ) .await; - Ok(result) + Ok(()) } pub async fn last_workspace( @@ -2009,7 +2049,7 @@ impl WorkspaceDb { DateTime, )>, > { - Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next()) + Ok(self.recent_project_workspaces(fs).await?.into_iter().next()) } // Returns the locations of the workspaces that were still opened when the last @@ -2038,23 +2078,16 @@ impl WorkspaceDb { paths, window_id, }); - } else if paths.is_empty() { - // Empty workspace with items (drafts, files) - include for restoration + continue; + } + + if paths.is_empty() || Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { workspaces.push(SessionWorkspace { workspace_id, location: SerializedWorkspaceLocation::Local, paths, window_id, }); - } else { - if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { - workspaces.push(SessionWorkspace { - workspace_id, - location: SerializedWorkspaceLocation::Local, - paths, - window_id, - }); - } } } @@ -2268,6 +2301,15 @@ impl WorkspaceDb { } } + #[cfg(test)] + query! { + pub(crate) async fn set_timestamp_for_tests(workspace_id: WorkspaceId, timestamp: String) -> Result<()> { + UPDATE workspaces + SET timestamp = ?2 + WHERE workspace_id = ?1 + } + } + query! { pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid) -> Result<()> { UPDATE workspaces @@ -2663,7 +2705,7 @@ mod tests { let workspace2 = multi_workspace.update_in(cx, |mw, window, cx| { let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx)); workspace.update(cx, |ws, _cx| ws.set_random_database_id()); - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); workspace }); @@ -3580,6 +3622,228 @@ mod tests { ); } + fn pane_with_items(item_ids: &[ItemId]) -> SerializedPaneGroup { + SerializedPaneGroup::Pane(SerializedPane::new( + item_ids + .iter() + .map(|id| SerializedItem::new("Terminal", *id, true, false)) + .collect(), + true, + 0, + )) + } + + fn empty_pane_group() -> SerializedPaneGroup { + SerializedPaneGroup::Pane(SerializedPane::default()) + } + + fn workspace_with( + id: u64, + paths: &[&Path], + center_group: SerializedPaneGroup, + session_id: Option<&str>, + ) -> SerializedWorkspace { + SerializedWorkspace { + id: WorkspaceId(id as i64), + paths: PathList::new(paths), + location: SerializedWorkspaceLocation::Local, + center_group, + window_bounds: Default::default(), + display: Default::default(), + docks: Default::default(), + bookmarks: Default::default(), + breakpoints: Default::default(), + centered_layout: false, + session_id: session_id.map(|s| s.to_owned()), + window_id: Some(id), + user_toolchains: Default::default(), + } + } + + #[gpui::test] + async fn test_scratch_only_workspace_restores_from_last_session(cx: &mut gpui::TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let db = + WorkspaceDb::open_test_db("test_scratch_only_workspace_restores_from_last_session") + .await; + + db.save_workspace(workspace_with(1, &[], pane_with_items(&[100]), Some("s1"))) + .await; + + let sessions = db + .last_session_workspace_locations("s1", None, fs.as_ref()) + .await + .unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].workspace_id, WorkspaceId(1)); + assert!(sessions[0].paths.is_empty()); + + let recents = db.recent_project_workspaces(fs.as_ref()).await.unwrap(); + assert!( + recents.iter().all(|(id, ..)| *id != WorkspaceId(1)), + "scratch-only workspace must not appear in the recent-projects UI" + ); + } + + #[gpui::test] + async fn test_gc_preserves_scratch_inside_window(cx: &mut gpui::TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let db = WorkspaceDb::open_test_db("test_gc_preserves_scratch_inside_window").await; + + db.save_workspace(workspace_with(1, &[], empty_pane_group(), None)) + .await; + + db.garbage_collect_workspaces(fs.as_ref(), "current", None) + .await + .unwrap(); + assert!( + db.workspace_for_id(WorkspaceId(1)).is_some(), + "fresh stale workspace must not be deleted before the 7-day window" + ); + } + + #[gpui::test] + async fn test_gc_deletes_stale_outside_window(cx: &mut gpui::TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let db = WorkspaceDb::open_test_db("test_gc_deletes_stale_outside_window").await; + + db.save_workspace(workspace_with(1, &[], empty_pane_group(), None)) + .await; + db.set_timestamp_for_tests(WorkspaceId(1), "2000-01-01 00:00:00".to_owned()) + .await + .unwrap(); + + db.garbage_collect_workspaces(fs.as_ref(), "current", None) + .await + .unwrap(); + assert!( + db.workspace_for_id(WorkspaceId(1)).is_none(), + "stale empty workspace older than the retention window must be deleted" + ); + } + + #[gpui::test] + async fn test_gc_preserves_directory_workspace_with_missing_path( + cx: &mut gpui::TestAppContext, + ) { + let fs = fs::FakeFs::new(cx.executor()); + let db = + WorkspaceDb::open_test_db("test_gc_preserves_directory_workspace_with_missing_path") + .await; + + let missing_dir = PathBuf::from("/missing-project-dir"); + db.save_workspace(workspace_with( + 1, + &[missing_dir.as_path()], + empty_pane_group(), + None, + )) + .await; + + db.garbage_collect_workspaces(fs.as_ref(), "current", None) + .await + .unwrap(); + assert!( + db.workspace_for_id(WorkspaceId(1)).is_some(), + "a stale workspace within the retention window must be kept" + ); + + db.set_timestamp_for_tests(WorkspaceId(1), "2000-01-01 00:00:00".to_owned()) + .await + .unwrap(); + db.garbage_collect_workspaces(fs.as_ref(), "current", None) + .await + .unwrap(); + assert!( + db.workspace_for_id(WorkspaceId(1)).is_none(), + "a stale workspace past the retention window must be deleted" + ); + } + + #[gpui::test] + async fn test_gc_preserves_current_and_last_sessions(cx: &mut gpui::TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let db = WorkspaceDb::open_test_db("test_gc_preserves_current_and_last_sessions").await; + + db.save_workspace(workspace_with(1, &[], empty_pane_group(), Some("current"))) + .await; + db.save_workspace(workspace_with(2, &[], empty_pane_group(), Some("last"))) + .await; + db.save_workspace(workspace_with(3, &[], empty_pane_group(), Some("stale"))) + .await; + + for id in [1, 2, 3] { + db.set_timestamp_for_tests(WorkspaceId(id), "2000-01-01 00:00:00".to_owned()) + .await + .unwrap(); + } + + db.garbage_collect_workspaces(fs.as_ref(), "current", Some("last")) + .await + .unwrap(); + + assert!( + db.workspace_for_id(WorkspaceId(1)).is_some(), + "GC must not delete workspaces belonging to the current session" + ); + assert!( + db.workspace_for_id(WorkspaceId(2)).is_some(), + "GC must not delete workspaces belonging to the last session" + ); + assert!( + db.workspace_for_id(WorkspaceId(3)).is_none(), + "GC should still delete stale workspaces from other sessions" + ); + } + + #[gpui::test] + async fn test_gc_deletes_empty_workspace_with_items(cx: &mut gpui::TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let db = WorkspaceDb::open_test_db("test_gc_deletes_empty_workspace_with_items").await; + + db.save_workspace(workspace_with(1, &[], pane_with_items(&[100]), None)) + .await; + db.set_timestamp_for_tests(WorkspaceId(1), "2000-01-01 00:00:00".to_owned()) + .await + .unwrap(); + + db.garbage_collect_workspaces(fs.as_ref(), "current", None) + .await + .unwrap(); + assert!( + db.workspace_for_id(WorkspaceId(1)).is_none(), + "a stale empty-path workspace must be deleted regardless of its items" + ); + } + + #[gpui::test] + async fn test_last_session_restores_workspace_with_missing_paths( + cx: &mut gpui::TestAppContext, + ) { + let fs = fs::FakeFs::new(cx.executor()); + let db = + WorkspaceDb::open_test_db("test_last_session_restores_workspace_with_missing_paths") + .await; + + let missing = PathBuf::from("/gone/file.rs"); + db.save_workspace(workspace_with( + 1, + &[missing.as_path()], + empty_pane_group(), + Some("s"), + )) + .await; + + let sessions = db + .last_session_workspace_locations("s", None, fs.as_ref()) + .await + .unwrap(); + assert!( + sessions.is_empty(), + "workspaces whose paths no longer exist on disk must not restore" + ); + } + #[gpui::test] async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) { let fs = fs::FakeFs::new(cx.executor()); @@ -5066,7 +5330,7 @@ mod tests { // Activate workspace B so removing its group exercises the fallback. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_b.clone(), window, cx); + mw.activate(workspace_b.clone(), None, window, cx); }); cx.run_until_parked(); @@ -5095,7 +5359,7 @@ mod tests { let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone()); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); @@ -5171,7 +5435,7 @@ mod tests { // Activate workspace_a so removing it triggers the fallback path. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index f0932a7d7b3e78..1e6899868334a3 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -116,7 +116,12 @@ pub trait SearchableItem: Item + EventEmitter { window: &mut Window, cx: &mut Context, ); - fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String; + fn query_suggestion( + &mut self, + ignore_settings: bool, + window: &mut Window, + cx: &mut Context, + ) -> String; fn activate_match( &mut self, index: usize, @@ -221,7 +226,7 @@ pub trait SearchableItemHandle: ItemHandle { window: &mut Window, cx: &mut App, ); - fn query_suggestion(&self, window: &mut Window, cx: &mut App) -> String; + fn query_suggestion(&self, ignore_settings: bool, window: &mut Window, cx: &mut App) -> String; fn activate_match( &self, index: usize, @@ -335,8 +340,10 @@ impl SearchableItemHandle for Entity { this.update_matches(matches.as_slice(), active_match_index, token, window, cx) }); } - fn query_suggestion(&self, window: &mut Window, cx: &mut App) -> String { - self.update(cx, |this, cx| this.query_suggestion(window, cx)) + fn query_suggestion(&self, ignore_settings: bool, window: &mut Window, cx: &mut App) -> String { + self.update(cx, |this, cx| { + this.query_suggestion(ignore_settings, window, cx) + }) } fn activate_match( &self, diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index dad5389f2f5574..fcd64e6e7ab935 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -3,7 +3,7 @@ use crate::{ sidebar_side_context_menu, }; use gpui::{ - AnyView, App, Context, Corner, Decorations, Entity, IntoElement, ParentElement, Render, Styled, + Anchor, AnyView, App, Context, Decorations, Entity, IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, }; use std::any::TypeId; @@ -144,14 +144,14 @@ impl StatusBar { let toggle = sidebar_side_context_menu("sidebar-status-toggle-menu", cx) .anchor(if on_right { - Corner::BottomRight + Anchor::BottomRight } else { - Corner::BottomLeft + Anchor::BottomLeft }) .attach(if on_right { - Corner::TopRight + Anchor::TopRight } else { - Corner::TopLeft + Anchor::TopLeft }) .trigger(move |_is_active, _window, _cx| { IconButton::new( diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 98421365532a8f..3ea35678865553 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -2,7 +2,7 @@ use std::process::ExitStatus; use anyhow::Result; use collections::HashSet; -use gpui::{AppContext, Context, Entity, Task}; +use gpui::{AppContext, AsyncWindowContext, Context, Entity, Task, WeakEntity}; use language::Buffer; use project::{TaskSourceKind, WorktreeId}; use remote::ConnectionState; @@ -78,26 +78,7 @@ impl Workspace { if self.terminal_provider.is_some() { let task = cx.spawn_in(window, async move |workspace, cx| { - let save_action = match spawn_in_terminal.save { - SaveStrategy::All => { - let save_all = workspace.update_in(cx, |workspace, window, cx| { - let task = workspace.save_all_internal(SaveIntent::SaveAll, window, cx); - // Match the type of the other arm by ignoring the bool value returned - cx.background_spawn(async { task.await.map(|_| ()) }) - }); - save_all.ok() - } - SaveStrategy::Current => { - let save_current = workspace.update_in(cx, |workspace, window, cx| { - workspace.save_active_item(SaveIntent::SaveAll, window, cx) - }); - save_current.ok() - } - SaveStrategy::None => None, - }; - if let Some(save_action) = save_action { - save_action.log_err().await; - } + Self::save_for_task(&workspace, spawn_in_terminal.save, cx).await; let spawn_task = workspace.update_in(cx, |workspace, window, cx| { workspace @@ -132,6 +113,32 @@ impl Workspace { } } + pub async fn save_for_task( + workspace: &WeakEntity, + save_strategy: SaveStrategy, + cx: &mut AsyncWindowContext, + ) { + let save_action = match save_strategy { + SaveStrategy::All => { + let save_all = workspace.update_in(cx, |workspace, window, cx| { + let task = workspace.save_all_internal(SaveIntent::SaveAll, window, cx); + cx.background_spawn(async { task.await.map(|_| ()) }) + }); + save_all.ok() + } + SaveStrategy::Current => { + let save_current = workspace.update_in(cx, |workspace, window, cx| { + workspace.save_active_item(SaveIntent::SaveAll, window, cx) + }); + save_current.ok() + } + SaveStrategy::None => None, + }; + if let Some(save_action) = save_action { + save_action.log_err().await; + } + } + pub fn start_debug_session( &mut self, scenario: DebugScenario, @@ -417,6 +424,69 @@ mod tests { item } + #[gpui::test] + async fn test_save_for_task_all(cx: &mut TestAppContext) { + let (fixture, cx) = create_fixture(cx, SaveStrategy::All).await; + let workspace = fixture.workspace.downgrade(); + cx.run_until_parked(); + + assert!(cx.read(|cx| fixture.item.read(cx).is_dirty)); + fixture.workspace.update_in(cx, |_workspace, window, cx| { + cx.spawn_in(window, { + let workspace = workspace.clone(); + async move |_this, cx| { + Workspace::save_for_task(&workspace, SaveStrategy::All, cx).await; + } + }) + .detach(); + }); + cx.run_until_parked(); + assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty)); + } + + #[gpui::test] + async fn test_save_for_task_none(cx: &mut TestAppContext) { + let (fixture, cx) = create_fixture(cx, SaveStrategy::None).await; + let workspace = fixture.workspace.downgrade(); + cx.run_until_parked(); + + assert!(cx.read(|cx| fixture.item.read(cx).is_dirty)); + fixture.workspace.update_in(cx, |_workspace, window, cx| { + cx.spawn_in(window, { + let workspace = workspace.clone(); + async move |_this, cx| { + Workspace::save_for_task(&workspace, SaveStrategy::None, cx).await; + } + }) + .detach(); + }); + cx.run_until_parked(); + assert!(cx.read(|cx| fixture.item.read(cx).is_dirty)); + } + + #[gpui::test] + async fn test_save_for_task_current(cx: &mut TestAppContext) { + let (fixture, cx) = create_fixture(cx, SaveStrategy::Current).await; + let inactive = add_test_item(&fixture.workspace, "file2.txt", false, cx); + let workspace = fixture.workspace.downgrade(); + cx.run_until_parked(); + + assert!(cx.read(|cx| fixture.item.read(cx).is_dirty)); + assert!(cx.read(|cx| inactive.read(cx).is_dirty)); + fixture.workspace.update_in(cx, |_workspace, window, cx| { + cx.spawn_in(window, { + let workspace = workspace.clone(); + async move |_this, cx| { + Workspace::save_for_task(&workspace, SaveStrategy::Current, cx).await; + } + }) + .detach(); + }); + cx.run_until_parked(); + assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty)); + assert!(cx.read(|cx| inactive.read(cx).is_dirty)); + } + struct TestTerminalProvider { item: Entity, dirty_before_spawn: Arc>>, diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index a13ec56b2e07a8..de189d89c9a219 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -271,7 +271,7 @@ impl WelcomePage { cx.spawn_in(window, async move |this: WeakEntity, cx| { let Some(fs) = fs else { return }; let workspaces = db - .recent_workspaces_on_disk(fs.as_ref()) + .recent_project_workspaces(fs.as_ref()) .await .log_err() .unwrap_or_default(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 3ad9de443bf82c..22a86cab0b3449 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1111,6 +1111,23 @@ struct GlobalAppState(Arc); impl Global for GlobalAppState {} +/// Tracks worktree creation progress for the workspace. +/// Read by the title bar to show a loading indicator on the worktree button. +#[derive(Default)] +pub struct ActiveWorktreeCreation { + pub label: Option, + pub is_switch: bool, +} + +/// Captured workspace state used when switching between worktrees. +/// Stores the layout and open files so they can be restored in the new workspace. +pub struct PreviousWorkspaceState { + pub dock_structure: DockStructure, + pub open_file_paths: Vec, + pub active_file_path: Option, + pub focused_dock: Option, +} + pub struct WorkspaceStore { workspaces: HashSet<(gpui::AnyWindowHandle, WeakEntity)>, client: Arc, @@ -1271,6 +1288,7 @@ pub enum Event { ModalOpened, Activate, PanelAdded(AnyView), + WorktreeCreationChanged, } #[derive(Debug, Clone)] @@ -1379,6 +1397,8 @@ pub struct Workspace { _panels_task: Option>>, sidebar_focus_handle: Option, multi_workspace: Option>, + active_worktree_creation: ActiveWorktreeCreation, + deferred_save_items: Vec>, } impl EventEmitter for Workspace {} @@ -1807,8 +1827,10 @@ impl Workspace { removing: false, sidebar_focus_handle: None, multi_workspace, + active_worktree_creation: ActiveWorktreeCreation::default(), open_in_dev_container: false, _dev_container_task: None, + deferred_save_items: Vec::new(), } } @@ -1949,7 +1971,7 @@ impl Workspace { }); match open_mode { OpenMode::Activate => { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); } OpenMode::Add => { multi_workspace.add(workspace.clone(), &*window, cx); @@ -2072,6 +2094,15 @@ impl Workspace { }); }) .log_err(); + + if open_mode == OpenMode::NewWindow { + window + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .log_err(); + } + Ok(OpenResult { window, workspace, @@ -2180,6 +2211,64 @@ impl Workspace { } } + /// Returns which dock currently has focus, or `None` if focus is in the + /// center pane or elsewhere. Does NOT fall back to any global state. + pub fn focused_dock_position(&self, window: &Window, cx: &App) -> Option { + [ + (DockPosition::Left, &self.left_dock), + (DockPosition::Right, &self.right_dock), + (DockPosition::Bottom, &self.bottom_dock), + ] + .into_iter() + .find(|(_, dock)| { + dock.read(cx).is_open() && dock.focus_handle(cx).contains_focused(window, cx) + }) + .map(|(position, _)| position) + } + + pub fn active_worktree_creation(&self) -> &ActiveWorktreeCreation { + &self.active_worktree_creation + } + + pub fn set_active_worktree_creation( + &mut self, + label: Option, + is_switch: bool, + cx: &mut Context, + ) { + self.active_worktree_creation.label = label; + self.active_worktree_creation.is_switch = is_switch; + cx.emit(Event::WorktreeCreationChanged); + cx.notify(); + } + + /// Captures the current workspace state for restoring after a worktree switch. + /// This includes dock layout, open file paths, and the active file path. + pub fn capture_state_for_worktree_switch( + &self, + window: &Window, + fallback_focused_dock: Option, + cx: &App, + ) -> PreviousWorkspaceState { + let dock_structure = self.capture_dock_state(window, cx); + let open_file_paths = self.open_item_abs_paths(cx); + let active_file_path = self + .active_item(cx) + .and_then(|item| item.project_path(cx)) + .and_then(|pp| self.project().read(cx).absolute_path(&pp, cx)); + + let focused_dock = self + .focused_dock_position(window, cx) + .or(fallback_focused_dock); + + PreviousWorkspaceState { + dock_structure, + open_file_paths, + active_file_path, + focused_dock, + } + } + pub fn open_item_abs_paths(&self, cx: &App) -> Vec { self.items(cx) .filter_map(|item| { @@ -3286,24 +3375,27 @@ impl Workspace { }; keystroke }; - cx.update(|window, cx| { - let focused = window.focused(cx); - window.dispatch_keystroke(keystroke.clone(), cx); - if window.focused(cx) != focused { - // dispatch_keystroke may cause the focus to change. - // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle - // And we need that to happen before the next keystroke to keep vim mode happy... - // (Note that the tests always do this implicitly, so you must manually test with something like: - // "bindings": { "g z": ["workspace::SendKeystrokes", ": j u"]} - // ) - window.draw(cx).clear(); - } - }) - .ok(); + let focus_changed = cx + .update(|window, cx| { + let focused = window.focused(cx); + window.dispatch_keystroke(keystroke.clone(), cx); + if window.focused(cx) != focused { + // dispatch_keystroke may cause the focus to change. + // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle + // And we need that to happen before the next keystroke to keep vim mode happy... + // (Note that the tests always do this implicitly, so you must manually test with something like: + // "bindings": { "g z": ["workspace::SendKeystrokes", ": j u"]} + // ) + window.draw(cx).clear(); + return true; + } + false + }) + .unwrap_or(false); - // Yield between synthetic keystrokes so deferred focus and - // other effects can settle before dispatching the next key. - yield_now().await; + if focus_changed { + yield_now().await; + } } *keystrokes.borrow_mut() = Default::default(); @@ -3354,24 +3446,26 @@ impl Workspace { let project = self.project.clone(); cx.spawn_in(window, async move |workspace, cx| { let dirty_items = if save_intent == SaveIntent::Close && !dirty_items.is_empty() { - let (serialize_tasks, remaining_dirty_items) = - workspace.update_in(cx, |workspace, window, cx| { - let mut remaining_dirty_items = Vec::new(); - let mut serialize_tasks = Vec::new(); - for (pane, item) in dirty_items { - if let Some(task) = item - .to_serializable_item_handle(cx) - .and_then(|handle| handle.serialize(workspace, true, window, cx)) - { - serialize_tasks.push(task); - } else { - remaining_dirty_items.push((pane, item)); - } + let mut serialize_tasks = Vec::new(); + let mut remaining_dirty_items = Vec::new(); + workspace.update_in(cx, |workspace, window, cx| { + for (pane, item) in dirty_items { + if let Some(task) = item + .to_serializable_item_handle(cx) + .and_then(|handle| handle.serialize(workspace, true, window, cx)) + { + serialize_tasks.push((pane, item, task)); + } else { + remaining_dirty_items.push((pane, item)); } - (serialize_tasks, remaining_dirty_items) - })?; + } + })?; - futures::future::try_join_all(serialize_tasks).await?; + for (pane, item, task) in serialize_tasks { + if task.await.log_err().is_none() { + remaining_dirty_items.push((pane, item)); + } + } if !remaining_dirty_items.is_empty() { workspace.update(cx, |_, cx| cx.emit(Event::Activate))?; @@ -3451,6 +3545,11 @@ impl Workspace { OpenOptions { requesting_window, open_mode, + workspace_matching: if open_mode == OpenMode::NewWindow { + WorkspaceMatching::None + } else { + WorkspaceMatching::default() + }, ..Default::default() }, cx, @@ -3765,11 +3864,18 @@ impl Workspace { .project .read(cx) .worktree_for_id(path.worktree_id, cx)?; - if worktree.read(cx).is_visible() { - abs_path - } else { - None + if !worktree.read(cx).is_visible() { + return None; } + let settings_location = SettingsLocation { + worktree_id: path.worktree_id, + path: &path.path, + }; + if WorktreeSettings::get(Some(settings_location), cx).is_path_read_only(&path.path) + { + return None; + } + abs_path }) .next() } @@ -5115,6 +5221,8 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { + self.flush_deferred_saves(window, cx); + // This is explicitly hoisted out of the following check for pane identity as // terminal panel panes are not registered as a center panes. self.status_bar.update(cx, |status_bar, cx| { @@ -5172,9 +5280,26 @@ impl Workspace { } fn handle_panel_focused(&mut self, window: &mut Window, cx: &mut Context) { + self.flush_deferred_saves(window, cx); self.update_active_view_for_followers(window, cx); } + fn flush_deferred_saves(&mut self, window: &mut Window, cx: &mut Context) { + let deferred = std::mem::take(&mut self.deferred_save_items); + for weak_item in deferred { + let Some(item) = weak_item.upgrade() else { + continue; + }; + // Skip if focus returned to this item + let focus_handle = item.item_focus_handle(cx); + if focus_handle.contains_focused(window, cx) { + continue; + } + Pane::autosave_item(item.as_ref(), self.project.clone(), window, cx) + .detach_and_log_err(cx); + } + } + fn handle_pane_event( &mut self, pane: &Entity, @@ -5708,18 +5833,8 @@ impl Workspace { let mut title = String::new(); for (i, worktree) in project.visible_worktrees(cx).enumerate() { - let name = { - let settings_location = SettingsLocation { - worktree_id: worktree.read(cx).id(), - path: RelPath::empty(), - }; + let name = worktree.read(cx).root_name_str(); - let settings = WorktreeSettings::get(Some(settings_location), cx); - match &settings.project_name { - Some(name) => name.as_str(), - None => worktree.read(cx).root_name_str(), - } - }; if i > 0 { title.push_str(", "); } @@ -6360,6 +6475,8 @@ impl Workspace { .detach(); } } else { + // When window is deactivated, flush any deferred saves since focus has left the window + self.flush_deferred_saves(window, cx); for pane in &self.panes { pane.update(cx, |pane, cx| { if let Some(item) = pane.active_item() { @@ -7401,12 +7518,6 @@ impl Workspace { self.modal_layer.read(cx).has_active_modal() } - pub fn is_active_modal_command_palette(&self, cx: &mut App) -> bool { - self.modal_layer - .read(cx) - .is_active_modal_command_palette(cx) - } - pub fn active_modal(&self, cx: &App) -> Option> { self.modal_layer.read(cx).active_modal() } @@ -9629,7 +9740,7 @@ pub fn open_paths( let open_task = existing .update(cx, |multi_workspace, window, cx| { window.activate_window(); - multi_workspace.activate(target_workspace.clone(), window, cx); + multi_workspace.activate(target_workspace.clone(), None, window, cx); target_workspace.update(cx, |workspace, cx| { if open_in_dev_container { workspace.set_open_in_dev_container(true); @@ -9855,6 +9966,7 @@ pub fn open_remote_project_with_new_connection( app_state, window, None, + None, cx, ) .await @@ -9868,6 +9980,7 @@ pub fn open_remote_project_with_existing_connection( app_state: Arc, window: WindowHandle, provisional_project_group_key: Option, + source_workspace: Option>, cx: &mut AsyncApp, ) -> Task>>>> { cx.spawn(async move |cx| { @@ -9882,6 +9995,7 @@ pub fn open_remote_project_with_existing_connection( app_state, window, provisional_project_group_key, + source_workspace, cx, ) .await @@ -9896,6 +10010,7 @@ async fn open_remote_project_inner( app_state: Arc, window: WindowHandle, provisional_project_group_key: Option, + source_workspace: Option>, cx: &mut AsyncApp, ) -> Result>>> { let db = cx.update(|cx| WorkspaceDb::global(cx)); @@ -9966,7 +10081,7 @@ async fn open_remote_project_inner( cx, ); } else { - multi_workspace.activate(new_workspace.clone(), window, cx); + multi_workspace.activate(new_workspace.clone(), source_workspace, window, cx); } new_workspace })?; @@ -10054,7 +10169,7 @@ pub fn join_in_room_project( { existing_window .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(target_workspace, window, cx); + multi_workspace.activate(target_workspace, None, window, cx); }) .ok(); existing_window @@ -10994,7 +11109,7 @@ mod tests { // Activate workspace A multi_workspace_handle .update(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }) .unwrap(); @@ -11079,7 +11194,7 @@ mod tests { // Activate workspace A. multi_workspace_handle .update(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }) .unwrap(); @@ -11131,7 +11246,7 @@ mod tests { let remove_task = multi_workspace_handle .update(cx, |mw, window, cx| { // First switch back to A. - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); mw.remove([workspace_b.clone()], |_, _, _| unreachable!(), window, cx) }) .unwrap(); @@ -11194,6 +11309,49 @@ mod tests { assert!(task.await.unwrap()); } + #[gpui::test] + async fn test_close_window_with_failing_serialization(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + register_serializable_item::(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx).with_dirty(true).with_serialize(|| { + Some(Task::ready(Err(anyhow::anyhow!( + "FOREIGN KEY constraint failed" + )))) + }) + }); + workspace.update_in(cx, |w, window, cx| { + w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx); + }); + + let task = workspace.update_in(cx, |w, window, cx| { + w.prepare_to_close(CloseIntent::CloseWindow, window, cx) + }); + cx.executor().run_until_parked(); + + // The failing serialization must not short-circuit the close; a + // save/discard prompt must be shown for the dirty scratch item. + assert!( + cx.has_pending_prompt(), + "a save/discard prompt should be shown for the dirty scratch item \ + when its serialization fails" + ); + cx.simulate_prompt_answer("Don't Save"); + cx.executor().run_until_parked(); + + // Preparing to close succeeds, even though serialization failed. + assert!(task.await.unwrap()); + } + #[gpui::test] async fn test_close_pane_items(cx: &mut TestAppContext) { init_test(cx); @@ -11496,10 +11654,13 @@ mod tests { }); item.is_dirty = true; }); - // Blurring the item saves the file. - item.update_in(cx, |_, window, _| window.blur()); + // Focus leaving the item (via window deactivation) saves the file. + // Deferred autosaves are flushed when focus lands elsewhere (pane, panel) + // or when the window is deactivated. + cx.deactivate_window(); cx.executor().run_until_parked(); item.read_with(cx, |item, _| assert_eq!(item.save_count, 2)); + cx.update(|window, _| window.activate_window()); // Deactivating the window still saves the file. item.update_in(cx, |item, window, cx| { @@ -11651,19 +11812,23 @@ mod tests { ); }); - // Blurring the item saves the file. This is the core regression scenario: + // Focus leaving the item saves the file. This is the core regression scenario: // with `on_blur`, this would NOT trigger because `on_blur` only fires when // the item's own focus handle is the leaf that lost focus. In a multibuffer, // the leaf is always a child focus handle, so `on_blur` never detected // focus leaving the item. - item.update_in(cx, |_, window, _| window.blur()); + // + // With deferred saves, the save happens when focus lands on a pane/panel or + // the window deactivates. + cx.deactivate_window(); cx.executor().run_until_parked(); item.read_with(cx, |item, _| { assert_eq!( item.save_count, 1, - "Blurring should trigger autosave when focus was on a child of the item" + "Window deactivation should trigger autosave when focus was on a child of the item" ); }); + cx.update(|window, _| window.activate_window()); // Deactivating the window should also trigger autosave when a child of // the multibuffer item currently owns focus. @@ -11683,6 +11848,141 @@ mod tests { }); } + #[gpui::test] + async fn test_autosave_deferred_for_modals(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx); + }); + + item.update_in(cx, |item, window, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.autosave = Some(AutosaveSetting::OnFocusChange); + }) + }); + item.is_dirty = true; + cx.focus_self(window); + }); + cx.executor().run_until_parked(); + + // Opening a modal moves focus away from the item, but autosave should be + // deferred until focus lands on a pane or panel (not saved immediately). + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, TestModal::new); + }); + cx.executor().run_until_parked(); + item.read_with(cx, |item, _| { + assert_eq!( + item.save_count, 0, + "Opening a modal should NOT immediately trigger autosave" + ); + }); + + // If focus returns to the same item (modal dismissed), the deferred save + // should be skipped. + workspace.update_in(cx, |workspace, window, cx| { + workspace.modal_layer.update(cx, |modal, cx| { + modal.hide_modal(window, cx); + }); + }); + cx.executor().run_until_parked(); + item.read_with(cx, |item, _| { + assert_eq!( + item.save_count, 0, + "Returning focus to the same item should skip deferred save" + ); + }); + + // Open modal again with a dirty item. + item.update_in(cx, |item, window, cx| { + item.is_dirty = true; + cx.focus_self(window); + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, TestModal::new); + }); + cx.executor().run_until_parked(); + item.read_with(cx, |item, _| { + assert_eq!(item.save_count, 0, "Modal open should not trigger save"); + }); + + // Window deactivation should flush deferred saves. + cx.deactivate_window(); + cx.executor().run_until_parked(); + item.read_with(cx, |item, _| { + assert_eq!( + item.save_count, 1, + "Window deactivation should flush deferred saves" + ); + }); + } + + #[gpui::test] + async fn test_autosave_deferred_until_pane_focus(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let item1 = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]) + }); + let item2 = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(2, "2.txt", cx)]) + }); + + let pane = workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item1.clone()), None, false, window, cx); + workspace.add_item_to_active_pane(Box::new(item2.clone()), None, false, window, cx); + workspace.active_pane().clone() + }); + // Ensure added_to_pane is called for both items (sets up focus handlers) + cx.executor().run_until_parked(); + + // Activate item1 (at index 0) and focus it. + pane.update_in(cx, |pane, window, cx| { + pane.activate_item(0, true, true, window, cx); + }); + cx.executor().run_until_parked(); + + // Set up OnFocusChange autosave and make item1 dirty. + item1.update(cx, |item, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.autosave = Some(AutosaveSetting::OnFocusChange); + }) + }); + item.is_dirty = true; + }); + cx.executor().run_until_parked(); + + // Activate item2 via the pane - this should trigger autosave of item1. + pane.update_in(cx, |pane, window, cx| { + pane.activate_item(1, true, true, window, cx); + }); + cx.executor().run_until_parked(); + + item1.read_with(cx, |item, _| { + assert_eq!( + item.save_count, 1, + "Switching to another item should trigger deferred save of the previous item" + ); + }); + } + #[gpui::test] async fn test_pane_navigation(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -14857,7 +15157,7 @@ mod tests { multi_workspace_handle .update(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }) .unwrap(); @@ -14903,7 +15203,7 @@ mod tests { multi_workspace_handle .update(cx, |mw, window, cx| { let workspace = mw.workspaces().nth(1).unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }) .unwrap(); cx.run_until_parked(); @@ -14912,7 +15212,7 @@ mod tests { multi_workspace_handle .update(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }) .unwrap(); cx.run_until_parked(); @@ -15156,4 +15456,51 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_most_recent_active_path_skips_read_only_paths(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "src": { "main.py": "" }, + ".venv": { "lib": { "dep.py": "" } }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let worktree_id = project.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + // Configure .venv as read-only + workspace.update_in(cx, |_workspace, _window, cx| { + cx.update_global::(|store, cx| { + store + .set_user_settings(r#"{"read_only_files": ["**/.venv/**"]}"#, cx) + .ok(); + }); + }); + + let item_dep = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new_in_worktree( + 1001, + ".venv/lib/dep.py", + worktree_id, + cx, + )]) + }); + + // dep.py is active but matches read_only_files → should be skipped + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item_dep.clone()), None, true, window, cx); + }); + let path = workspace.read_with(cx, |workspace, cx| workspace.most_recent_active_path(cx)); + assert_eq!(path, None); + } } diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index f097f381d16a51..53ef067193ef80 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -4,7 +4,7 @@ use crate::DockPosition; use collections::HashMap; use serde::Deserialize; pub use settings::{ - AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity, + ActionName, AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity, PaneSplitDirectionHorizontal, PaneSplitDirectionVertical, RegisterSetting, RestoreOnStartupBehavior, Settings, }; @@ -25,7 +25,7 @@ pub struct WorkspaceSettings { pub drop_target_size: f32, pub use_system_path_prompts: bool, pub use_system_prompts: bool, - pub command_aliases: HashMap, + pub command_aliases: HashMap, pub max_tabs: Option, pub when_closing_with_no_tabs: settings::CloseWindowWhenNoItems, pub on_last_window_closed: settings::OnLastWindowClosed, diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 990ed4fd54c0d2..5877cb1ac11249 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -8,8 +8,7 @@ use clock::ReplicaId; use collections::{HashMap, HashSet, VecDeque}; use encoding_rs::Encoding; use fs::{ - Fs, MTime, PathEvent, PathEventKind, RemoveOptions, TrashedEntry, Watcher, copy_recursive, - read_dir_items, + Fs, MTime, PathEvent, RemoveOptions, TrashedEntry, Watcher, copy_recursive, read_dir_items, }; use futures::{ FutureExt as _, Stream, StreamExt, @@ -260,7 +259,9 @@ pub struct LocalSnapshot { struct BackgroundScannerState { snapshot: LocalSnapshot, + symlink_paths_by_target: HashMap, SmallVec<[Arc; 1]>>, scanned_dirs: HashSet, + 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 @@ -1171,7 +1172,9 @@ impl LocalWorktree { state: async_lock::Mutex::new(BackgroundScannerState { prev_snapshot: snapshot.snapshot.clone(), snapshot, + symlink_paths_by_target: Default::default(), scanned_dirs: Default::default(), + watched_dir_abs_paths_by_entry_id: Default::default(), scanning_enabled, path_prefixes_to_scan: Default::default(), paths_to_scan: Default::default(), @@ -3151,7 +3154,12 @@ impl BackgroundScannerState { let mut removed_dir_abs_paths = Vec::new(); for entry in removed_entries.cursor::<()>(()) { if entry.is_dir() { - removed_dir_abs_paths.push(self.snapshot.absolutize(&entry.path)); + let watch_path = self + .watched_dir_abs_paths_by_entry_id + .remove(&entry.id) + .map(|path| path.as_ref().to_path_buf()) + .unwrap_or_else(|| self.snapshot.absolutize(&entry.path)); + removed_dir_abs_paths.push(watch_path); } match self.removed_entries.entry(entry.inode) { @@ -3295,7 +3303,7 @@ impl BackgroundScannerState { } } -async fn is_git_dir(path: &Path, fs: &dyn Fs) -> bool { +async fn is_dot_git(path: &Path, fs: &dyn Fs) -> bool { if let Some(file_name) = path.file_name() && file_name == DOT_GIT { @@ -4194,6 +4202,67 @@ impl BackgroundScanner { self.send_status_update(scanning, request.done, &[]).await } + fn normalized_events_for_worktree( + state: &BackgroundScannerState, + root_canonical_path: &SanitizedPath, + mut events: Vec, + ) -> Vec { + if state.symlink_paths_by_target.is_empty() { + return events; + } + let mut mapped_events = Vec::new(); + + events.retain(|event| { + let abs_path = SanitizedPath::new(&event.path); + + let mut best_match: Option<(&Arc, &SmallVec<[Arc; 1]>)> = None; + let mut best_depth = 0; + for (target_root, symlink_paths) in &state.symlink_paths_by_target { + if abs_path.as_path().starts_with(target_root.as_ref()) { + let depth = target_root.as_ref().components().count(); + if depth > best_depth { + best_depth = depth; + best_match = Some((target_root, symlink_paths)); + } + } + } + + let Some((target_root, symlink_paths)) = best_match else { + return true; + }; + + let Ok(suffix) = abs_path.as_path().strip_prefix(target_root.as_ref()) else { + return true; + }; + + // If the symlink's real target is outside this worktree, the original path + // isn't visible to the worktree. Keep only the remapped symlink events. + let keep_original = target_root.starts_with(root_canonical_path.as_path()); + + for symlink_path in symlink_paths { + let mapped_path = if suffix.as_os_str().is_empty() { + root_canonical_path + .as_path() + .join(symlink_path.as_std_path()) + } else { + root_canonical_path + .as_path() + .join(symlink_path.as_std_path()) + .join(suffix) + }; + if mapped_path != event.path { + mapped_events.push(PathEvent { + path: mapped_path, + kind: event.kind, + }); + } + } + keep_original + }); + events.extend(mapped_events); + events + } + async fn process_events(&self, mut events: Vec) { let root_path = self.state.lock().await.snapshot.abs_path.clone(); let root_canonical_path = self.fs.canonicalize(root_path.as_path()).await; @@ -4245,6 +4314,11 @@ impl BackgroundScanner { } }; + { + let state = self.state.lock().await; + events = Self::normalized_events_for_worktree(&state, &root_canonical_path, events); + } + // Certain directories may have FS changes, but do not lead to git data changes that Zed cares about. // Ignore these, to avoid Zed unnecessarily rescanning git metadata. let skipped_files_in_dot_git = [COMMIT_MESSAGE, INDEX_LOCK]; @@ -4291,7 +4365,7 @@ impl BackgroundScanner { let mut dot_git_paths = None; for ancestor in abs_path.as_path().ancestors() { - if is_git_dir(ancestor, self.fs.as_ref()).await { + if is_dot_git(ancestor, self.fs.as_ref()).await { let path_in_git_dir = abs_path .as_path() .strip_prefix(ancestor) @@ -4302,32 +4376,11 @@ impl BackgroundScanner { } if let Some((dot_git_abs_path, path_in_git_dir)) = dot_git_paths { - // We ignore `""` as well, as that is going to be the - // `.git` folder itself. WE do not care about it, if - // there are changes within we will see them, we need - // this ignore to prevent us from accidentally observing - // the ignored created file due to the events not being - // empty after filtering. - - let is_dot_git_changed = { - path_in_git_dir == Path::new("") - && event.kind == Some(PathEventKind::Changed) - && abs_path - .strip_prefix(root_canonical_path) - .ok() - .and_then(|it| RelPath::new(it, PathStyle::local()).ok()) - .is_some_and(|it| { - snapshot - .entry_for_path(&it) - .is_some_and(|entry| entry.kind == EntryKind::Dir) - }) - }; let condition = skipped_files_in_dot_git.iter().any(|skipped| { OsStr::new(skipped) == path_in_git_dir.as_path().as_os_str() }) || skipped_dirs_in_dot_git .iter() - .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)) - || is_dot_git_changed; + .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)); if condition { log::debug!( "ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories" @@ -4521,7 +4574,15 @@ impl BackgroundScanner { if let Some(entry) = state.snapshot.entry_for_path(ancestor) && entry.kind == EntryKind::UnloadedDir { - let abs_path = root_path.join(ancestor.as_std_path()); + let abs_path = if entry.is_external { + entry + .canonical_path + .as_ref() + .map(|path| path.as_ref().to_path_buf()) + .unwrap_or_else(|| root_path.join(ancestor.as_std_path())) + } else { + root_path.join(ancestor.as_std_path()) + }; state .enqueue_scan_dir( abs_path.into(), @@ -4785,6 +4846,17 @@ impl BackgroundScanner { child_entry.is_external = true; } + if child_metadata.is_dir { + let mut state = self.state.lock().await; + let paths = state + .symlink_paths_by_target + .entry(Arc::from(canonical_path.clone())) + .or_default(); + if !paths.iter().any(|path| path == &child_path) { + paths.push(child_path.clone()); + } + } + child_entry.canonical_path = Some(canonical_path.into()); } @@ -4860,8 +4932,19 @@ impl BackgroundScanner { } state.populate_dir(job.path.clone(), new_entries, new_ignore); + self.watcher.add(job.abs_path.as_ref()).log_err(); + let entry_id = state + .snapshot + .entry_for_path(&job.path) + .map(|entry| entry.id); + if let Some(entry_id) = entry_id { + state + .watched_dir_abs_paths_by_entry_id + .insert(entry_id, job.abs_path.clone()); + } + for new_job in new_jobs.into_iter().flatten() { job.scan_queue .try_send(new_job) @@ -5326,16 +5409,13 @@ impl BackgroundScanner { match existing_repository_entry { None => { let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else { - // This can happen legitimately when `.git` is a - // gitfile (e.g. in a linked worktree or submodule) - // pointing to a directory outside the worktree root. - // Skip it — the repository was already registered - // during the initial scan via `discover_git_paths`. - debug_assert!( - self.fs.is_file(&dot_git_dir).await, - "update_git_repositories: .git path outside worktree root \ - is not a gitfile: {dot_git_dir:?}", - ); + // 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()); diff --git a/crates/worktree/src/worktree_settings.rs b/crates/worktree/src/worktree_settings.rs index 79b482482942b4..90fe5ba724b286 100644 --- a/crates/worktree/src/worktree_settings.rs +++ b/crates/worktree/src/worktree_settings.rs @@ -10,7 +10,6 @@ use util::{ #[derive(Clone, PartialEq, Eq, RegisterSetting)] pub struct WorktreeSettings { - pub project_name: Option, /// Whether to prevent this project from being shared in public channels. pub prevent_sharing_in_public_channels: bool, pub file_scan_exclusions: PathMatcher, @@ -76,7 +75,6 @@ impl Settings for WorktreeSettings { .collect(); Self { - project_name: worktree.project_name, prevent_sharing_in_public_channels: worktree.prevent_sharing_in_public_channels, file_scan_exclusions: path_matchers(file_scan_exclusions, "file_scan_exclusions") .log_err() diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index 76034c7f5fa01c..922da7f1bf2b73 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/main.rs @@ -199,6 +199,9 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { "src": { "e.rs": "", "f.rs": "", + "nested": { + "deep.rs": "" + } }, } }), @@ -212,6 +215,18 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { fs.create_symlink("/root/dir1/deps/dep-dir3".as_ref(), "../../dir3".into()) .await .unwrap(); + fs.create_symlink( + "/root/dir1/deps/dep-dir3-alias".as_ref(), + "../../dir3".into(), + ) + .await + .unwrap(); + fs.create_symlink( + "/root/dir1/deps/dep-dir3-nested".as_ref(), + "../../dir3/src/nested".into(), + ) + .await + .unwrap(); let tree = Worktree::local( Path::new("/root/dir1"), @@ -254,6 +269,8 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { (rel_path("deps"), false), (rel_path("deps/dep-dir2"), true), (rel_path("deps/dep-dir3"), true), + (rel_path("deps/dep-dir3-alias"), true), + (rel_path("deps/dep-dir3-nested"), true), (rel_path("src"), false), (rel_path("src/a.rs"), false), (rel_path("src/b.rs"), false), @@ -289,6 +306,8 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { (rel_path("deps/dep-dir3"), true), (rel_path("deps/dep-dir3/deps"), true), (rel_path("deps/dep-dir3/src"), true), + (rel_path("deps/dep-dir3-alias"), true), + (rel_path("deps/dep-dir3-nested"), true), (rel_path("src"), false), (rel_path("src/a.rs"), false), (rel_path("src/b.rs"), false), @@ -328,6 +347,9 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { (rel_path("deps/dep-dir3/src"), true), (rel_path("deps/dep-dir3/src/e.rs"), true), (rel_path("deps/dep-dir3/src/f.rs"), true), + (rel_path("deps/dep-dir3/src/nested"), true), + (rel_path("deps/dep-dir3-alias"), true), + (rel_path("deps/dep-dir3-nested"), true), (rel_path("src"), false), (rel_path("src/a.rs"), false), (rel_path("src/b.rs"), false), @@ -346,9 +368,220 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { ( rel_path("deps/dep-dir3/src/f.rs").into(), PathChange::Loaded + ), + ( + rel_path("deps/dep-dir3/src/nested").into(), + PathChange::Loaded ) ] ); + + // After an external symlink subtree is loaded, changes in the target should be reflected. + fs.insert_file(Path::new("/root/dir3/src/new.rs"), b"".to_vec()) + .await; + + wait_for_condition(cx, |cx| { + tree.read_with(cx, |tree, _| { + tree.entry_for_path(rel_path("deps/dep-dir3/src/new.rs")) + .is_some() + }) + }) + .await; + + tree.read_with(cx, |tree, _| { + assert!( + tree.entry_for_path(rel_path("deps/dep-dir3/src/new.rs")) + .is_some() + ); + }); + + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![rel_path("deps/dep-dir3-alias").into()]) + }) + .recv() + .await; + + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![rel_path("deps/dep-dir3-alias/src").into()]) + }) + .recv() + .await; + + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![rel_path("deps/dep-dir3-nested").into()]) + }) + .recv() + .await; + // Create a file in the shared target subtree. Because dep-dir3 and dep-dir3-alias both + // point to the same target, both logical paths should observe the new file. + fs.insert_file(Path::new("/root/dir3/src/shared-new.rs"), b"".to_vec()) + .await; + + wait_for_condition(cx, |cx| { + tree.read_with(cx, |tree, _| { + tree.entry_for_path(rel_path("deps/dep-dir3/src/shared-new.rs")) + .is_some() + && tree + .entry_for_path(rel_path("deps/dep-dir3-alias/src/shared-new.rs")) + .is_some() + }) + }) + .await; + + tree.read_with(cx, |tree, _| { + assert!( + tree.entry_for_path(rel_path("deps/dep-dir3/src/shared-new.rs")) + .is_some() + ); + assert!( + tree.entry_for_path(rel_path("deps/dep-dir3-alias/src/shared-new.rs")) + .is_some() + ); + }); + + // Create a file under the more specific nested target. Longest-prefix matching means this should appear under dep-dir3-nested + fs.insert_file( + Path::new("/root/dir3/src/nested/longest-prefix.rs"), + b"".to_vec(), + ) + .await; + + wait_for_condition(cx, |cx| { + tree.read_with(cx, |tree, _| { + tree.entry_for_path(rel_path("deps/dep-dir3-nested/longest-prefix.rs")) + .is_some() + }) + }) + .await; + + tree.read_with(cx, |tree, _| { + assert!( + tree.entry_for_path(rel_path("deps/dep-dir3-nested/longest-prefix.rs")) + .is_some() + ); + assert!( + tree.entry_for_path(rel_path("deps/dep-dir3/src/nested/longest-prefix.rs")) + .is_none() + ); + assert!( + tree.entry_for_path(rel_path("deps/dep-dir3-alias/src/nested/longest-prefix.rs")) + .is_none() + ); + }); +} + +#[gpui::test] +async fn test_symlinked_dir_inside_project(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + + fs.insert_tree( + "/root", + json!({ + "project": { + "real-dir": { + "existing.rs": "", + "nested": { + "deep.rs": "" + } + }, + "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; + + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("links"), false), + (rel_path("links/internal"), false), + (rel_path("links/internal/existing.rs"), false), + (rel_path("links/internal/nested"), false), + (rel_path("links/internal/nested/deep.rs"), false), + (rel_path("real-dir"), false), + (rel_path("real-dir/existing.rs"), false), + (rel_path("real-dir/nested"), false), + (rel_path("real-dir/nested/deep.rs"), false), + ] + ); + + assert_eq!( + tree.entry_for_path(rel_path("links/internal")) + .unwrap() + .kind, + EntryKind::Dir + ); + }); + + fs.insert_file(Path::new("/root/project/real-dir/new.txt"), b"".to_vec()) + .await; + wait_for_condition(cx, |cx| { + tree.read_with(cx, |tree, _| { + tree.entry_for_path(rel_path("links/internal/new.txt")) + .is_some() + }) + }) + .await; + + tree.read_with(cx, |tree, _| { + assert!( + tree.entry_for_path(rel_path("links/internal/new.txt")) + .is_some() + ); + }); + + fs.insert_file( + Path::new("/root/project/real-dir/nested/inner.txt"), + b"".to_vec(), + ) + .await; + wait_for_condition(cx, |cx| { + tree.read_with(cx, |tree, _| { + tree.entry_for_path(rel_path("links/internal/nested/inner.txt")) + .is_some() + }) + }) + .await; + + tree.read_with(cx, |tree, _| { + assert!( + tree.entry_for_path(rel_path("links/internal/nested/inner.txt")) + .is_some() + ); + }); } #[cfg(target_os = "macos")] @@ -2913,6 +3146,78 @@ async fn test_linked_worktree_git_file_event_does_not_panic( }); } +#[gpui::test] +async fn test_linked_worktree_event_in_unregistered_common_git_dir_does_not_panic( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + // Regression test: a rescan event on a linked worktree's commondir + // must not panic when the worktree's repository has already been + // unregistered from `git_repositories`. + init_test(cx); + + use git::repository::Worktree as GitWorktree; + + let fs = FakeFs::new(executor); + + fs.insert_tree( + path!("/main_repo"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new(path!("/main_repo/.git")), + false, + GitWorktree { + path: PathBuf::from(path!("/linked_worktree")), + ref_name: Some("refs/heads/feature".into()), + sha: "abc123".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + fs.write( + path!("/linked_worktree/file.txt").as_ref(), + "content".as_bytes(), + ) + .await + .unwrap(); + + let tree = Worktree::local( + path!("/linked_worktree").as_ref(), + true, + fs.clone(), + Arc::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + cx.run_until_parked(); + + // Unregister the linked worktree's repository by removing its gitfile. + fs.remove_file( + Path::new(path!("/linked_worktree/.git")), + Default::default(), + ) + .await + .unwrap(); + tree.flush_fs_events(cx).await; + + // Deliver the kind of Rescan event `FsWatcher` emits when the kernel + // signals `need_rescan` for the commondir. + fs.emit_fs_event(path!("/main_repo/.git"), Some(fs::PathEventKind::Rescan)); + cx.run_until_parked(); + tree.flush_fs_events(cx).await; +} + fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); @@ -2922,6 +3227,22 @@ fn init_test(cx: &mut gpui::TestAppContext) { }); } +async fn wait_for_condition( + cx: &mut TestAppContext, + mut condition: impl FnMut(&mut TestAppContext) -> bool, +) { + for _ in 0..50 { + if condition(cx) { + return; + } + cx.executor().run_until_parked(); + cx.background_executor + .timer(std::time::Duration::from_millis(10)) + .await; + } + panic!("timed out waiting for test condition"); +} + #[gpui::test] async fn test_load_file_encoding(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/worktree/tests/integration/worktree_settings.rs b/crates/worktree/tests/integration/worktree_settings.rs index 213d888e9f9998..0a47766f35f480 100644 --- a/crates/worktree/tests/integration/worktree_settings.rs +++ b/crates/worktree/tests/integration/worktree_settings.rs @@ -7,7 +7,6 @@ use worktree::*; fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings { WorktreeSettings { - project_name: None, prevent_sharing_in_public_channels: false, file_scan_exclusions: PathMatcher::default(), file_scan_inclusions: PathMatcher::default(), diff --git a/crates/x_ai/Cargo.toml b/crates/x_ai/Cargo.toml index 2d1c9d0ecebeb8..8ff020df8c1cca 100644 --- a/crates/x_ai/Cargo.toml +++ b/crates/x_ai/Cargo.toml @@ -17,8 +17,6 @@ schemars = ["dep:schemars"] [dependencies] anyhow.workspace = true -language_model_core.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true strum.workspace = true -tiktoken-rs.workspace = true diff --git a/crates/x_ai/src/completion.rs b/crates/x_ai/src/completion.rs deleted file mode 100644 index aad03d227eb827..00000000000000 --- a/crates/x_ai/src/completion.rs +++ /dev/null @@ -1,30 +0,0 @@ -use anyhow::Result; -use language_model_core::{LanguageModelRequest, Role}; - -use crate::Model; - -/// Count tokens for an xAI model using tiktoken. This is synchronous; -/// callers should spawn it on a background thread if needed. -pub fn count_xai_tokens(request: LanguageModelRequest, model: Model) -> Result { - let messages = request - .messages - .into_iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: Some(message.string_contents()), - name: None, - function_call: None, - }) - .collect::>(); - - let model_name = if model.max_token_count() >= 100_000 { - "gpt-4o" - } else { - "gpt-4" - }; - tiktoken_rs::num_tokens_from_messages(model_name, &messages).map(|tokens| tokens as u64) -} diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index bc49a3e2b37d6a..afa7d62aa3c991 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -1,5 +1,3 @@ -pub mod completion; - use anyhow::Result; use serde::{Deserialize, Serialize}; use strum::EnumIter; diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index d853ee4f4dc883..f66b6746696947 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 = "0.234.0" +version = "0.235.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f0bc7d557d5199..627d514f6c469e 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -5,7 +5,7 @@ mod reliability; mod zed; use agent::{SharedThread, ThreadStore}; -use agent_client_protocol; +use agent_client_protocol::schema as acp; use agent_ui::AgentPanel; use anyhow::{Context as _, Result}; use clap::Parser; @@ -21,7 +21,9 @@ use fs::{Fs, RealFs}; use futures::{StreamExt, channel::oneshot, future}; use git::GitHostingProviderRegistry; use git_ui::clone::clone_and_open; -use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _}; +use gpui::{ + App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, Task, UpdateGlobal as _, +}; use gpui_platform; use gpui_tokio::Tokio; @@ -850,26 +852,47 @@ fn main() { }) } - match open_rx + let (current_session_id, last_session_id) = { + let session = app_state.session.read(cx); + ( + session.id().to_owned(), + session.last_session_id().map(|id| id.to_owned()), + ) + }; + + let restore_task = match open_rx .try_recv() .ok() .and_then(|request| OpenRequest::parse(request, cx).log_err()) { Some(request) => { handle_open_request(request, app_state.clone(), cx); + Task::ready(()) } - None => { - cx.spawn({ - let app_state = app_state.clone(); - async move |cx| { - if let Err(e) = restore_or_create_workspace(app_state, cx).await { - fail_to_open_window_async(e, cx) - } + None => cx.spawn({ + let app_state = app_state.clone(); + async move |cx| { + if let Err(e) = restore_or_create_workspace(app_state, cx).await { + fail_to_open_window_async(e, cx) } - }) - .detach(); + } + }), + }; + + cx.spawn({ + let db = workspace::WorkspaceDb::global(cx); + let fs = app_state.fs.clone(); + async move |_cx| { + restore_task.await; + db.garbage_collect_workspaces( + fs.as_ref(), + ¤t_session_id, + last_session_id.as_deref(), + ) + .await } - } + }) + .detach_and_log_err(cx); let app_state = app_state.clone(); @@ -967,7 +990,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let shared_thread = SharedThread::from_bytes(&response.thread_data)?; let db_thread = shared_thread.to_db_thread(); - let session_id = agent_client_protocol::SessionId::new(session_id); + let session_id = acp::SessionId::new(session_id); let save_session_id = session_id.clone(); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 3980bcdc472c1b..8f85fcd3c86090 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -95,7 +95,7 @@ fn main() { #[cfg(target_os = "macos")] use { acp_thread::{AgentConnection, StubAgentConnection}, - agent_client_protocol as acp, + agent_client_protocol::schema as acp, agent_servers::{AgentServer, AgentServerDelegate}, anyhow::{Context as _, Result}, assets::Assets, @@ -2605,7 +2605,7 @@ fn run_multi_workspace_sidebar_visual_tests( }); cx.new(|cx| { let mut multi_workspace = MultiWorkspace::new(workspace1, window, cx); - multi_workspace.activate(workspace2, window, cx); + multi_workspace.activate(workspace2, None, window, cx); multi_workspace }) }, @@ -2657,7 +2657,7 @@ fn run_multi_workspace_sidebar_visual_tests( multi_workspace_window .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspaces().next().unwrap().clone(); - multi_workspace.activate(workspace, window, cx); + multi_workspace.activate(workspace, None, window, cx); }) .context("Failed to activate workspace 1")?; @@ -3393,7 +3393,7 @@ fn open_sidebar_test_window( let ws = cx.new(|cx| { Workspace::new(None, project, app_state.clone(), window, cx) }); - mw.activate(ws, window, cx); + mw.activate(ws, None, window, cx); } mw }) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index b200dc0c1167d8..e599f608206486 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -423,6 +423,38 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { let window_handle = window.window_handle(); let multi_workspace_handle = cx.entity(); + cx.subscribe_in( + &multi_workspace_handle, + window, + |this, _multi_workspace, event: &workspace::MultiWorkspaceEvent, window, cx| { + let workspace::MultiWorkspaceEvent::ActiveWorkspaceChanged { source_workspace } = + event + else { + return; + }; + + let active_workspace = this.workspace().clone(); + let source_workspace = source_workspace.clone(); + active_workspace.update(cx, |workspace, cx| { + if let Some(ref source) = source_workspace { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.initialize_from_source_workspace_if_needed( + source.clone(), + window, + cx, + ); + }); + } + } + + ensure_agent_panel_for_workspace(workspace, source_workspace, window, cx) + .detach_and_log_err(cx); + }); + }, + ) + .detach(); + cx.defer(move |cx| { window_handle .update(cx, |_, window, cx| { @@ -735,24 +767,43 @@ fn setup_or_teardown_ai_panel( } } +fn ensure_agent_panel_for_workspace( + workspace: &mut Workspace, + source_workspace: Option>, + window: &mut Window, + cx: &mut Context, +) -> Task> { + let task = setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, cx) + }); + + cx.spawn_in(window, async move |workspace, cx| { + task.await?; + workspace.update_in(cx, |workspace, window, cx| { + if let Some(source_workspace) = source_workspace.clone() + && let Some(panel) = workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.initialize_from_source_workspace_if_needed(source_workspace, window, cx); + }); + } + }) + }) +} + async fn initialize_agent_panel( workspace_handle: WeakEntity, mut cx: AsyncWindowContext, ) -> anyhow::Result<()> { workspace_handle .update_in(&mut cx, |workspace, window, cx| { - setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { - agent_ui::AgentPanel::load(workspace, cx) - }) + ensure_agent_panel_for_workspace(workspace, None, window, cx) })? .await?; workspace_handle.update_in(&mut cx, |workspace, window, cx| { cx.observe_global_in::(window, move |workspace, window, cx| { - setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { - agent_ui::AgentPanel::load(workspace, cx) - }) - .detach_and_log_err(cx); + ensure_agent_panel_for_workspace(workspace, None, window, cx).detach_and_log_err(cx); }) .detach(); @@ -1558,7 +1609,7 @@ fn quit(_: &Quit, cx: &mut App) { for workspace in workspaces { if let Some(should_close) = window .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); window.activate_window(); workspace.update(cx, |workspace, cx| { workspace.prepare_to_close(CloseIntent::Quit, window, cx) @@ -2977,6 +3028,10 @@ mod tests { let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited()) }; + let workspace_database_id = |window: WindowHandle, + cx: &mut TestAppContext| { + cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).database_id()) + }; let editor = window .read_with(cx, |multi_workspace, cx| { @@ -2991,6 +3046,11 @@ mod tests { .unwrap(); assert!(!window_is_edited(window, cx)); + let initial_database_id = workspace_database_id(window, cx); + assert!( + initial_database_id.is_some(), + "a restored workspace must have a stable database id" + ); // Editing a buffer marks the window as edited. window @@ -3036,6 +3096,11 @@ mod tests { .unwrap() }); assert!(window_is_edited(window, cx)); + assert_eq!( + workspace_database_id(window, cx), + initial_database_id, + "the workspace must keep the same database id across a close/reopen cycle" + ); window .update(cx, |multi_workspace, _, cx| { @@ -5129,6 +5194,7 @@ mod tests { "vim", "window", "workspace", + "worktree_picker", "zed", "zed_actions", "zed_predict_onboarding", @@ -5673,10 +5739,10 @@ mod tests { window .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(workspace2.clone(), window, cx); - multi_workspace.activate(workspace3.clone(), window, cx); + multi_workspace.activate(workspace2.clone(), None, window, cx); + multi_workspace.activate(workspace3.clone(), None, window, cx); // Switch back to workspace1 for test setup - multi_workspace.activate(workspace1.clone(), window, cx); + multi_workspace.activate(workspace1.clone(), None, window, cx); assert_eq!(multi_workspace.workspace(), &workspace1); }) .unwrap(); @@ -5860,8 +5926,8 @@ mod tests { window1 .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(workspace1_2.clone(), window, cx); - multi_workspace.activate(workspace1_1.clone(), window, cx); + multi_workspace.activate(workspace1_2.clone(), None, window, cx); + multi_workspace.activate(workspace1_1.clone(), None, window, cx); }) .unwrap(); @@ -6180,7 +6246,7 @@ mod tests { window_a .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspaces().next().unwrap().clone(); - multi_workspace.activate(workspace, window, cx); + multi_workspace.activate(workspace, None, window, cx); }) .unwrap(); @@ -6388,7 +6454,7 @@ mod tests { }) .expect("workspace_a should exist") .clone(); - mw.activate(workspace_a, window, cx); + mw.activate(workspace_a, None, window, cx); }) .unwrap(); cx.run_until_parked(); diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index e0094cb6556302..6faf0d3fe6835d 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -12,7 +12,6 @@ use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures::channel::{mpsc, oneshot}; use futures::future; -use feature_flags::FeatureFlagAppExt as _; use futures::{FutureExt, StreamExt}; use git_ui::{file_diff_view::FileDiffView, multi_diff_view::MultiDiffView}; use gpui::{App, AsyncApp, Global, WindowHandle}; @@ -558,11 +557,6 @@ async fn resolve_open_behavior( requests: &mut mpsc::UnboundedReceiver, cx: &mut AsyncApp, ) -> Option { - let cli_prompt_enabled = cx.update(|cx| cx.has_flag::()); - if !cli_prompt_enabled { - return Some(settings::CliDefaultOpenBehavior::NewWindow); - } - let has_existing_windows = cx.update(|cx| { cx.windows() .iter() @@ -783,7 +777,7 @@ async fn open_workspaces( } async fn open_local_workspace( - workspace_paths: Vec, + mut workspace_paths: Vec, diff_paths: Vec<[String; 2]>, diff_all: bool, open_options: workspace::OpenOptions, @@ -791,6 +785,16 @@ async fn open_local_workspace( app_state: &Arc, cx: &mut AsyncApp, ) -> bool { + let user_provided_paths = !workspace_paths.is_empty(); + + // When only diff paths are provided (no regular paths), add the current + // working directory so the workspace opens with the right context. + if !user_provided_paths && !diff_paths.is_empty() { + if let Ok(cwd) = std::env::current_dir() { + workspace_paths.push(cwd.to_string_lossy().into_owned()); + } + } + let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), workspace_paths).await; @@ -822,10 +826,12 @@ async fn open_local_workspace( // the entire workspace is closed. if open_options.wait { let mut wait_for_window_close = paths_with_position.is_empty() && diff_paths.is_empty(); - for path_with_position in &paths_with_position { - if app_state.fs.is_dir(&path_with_position.path).await { - wait_for_window_close = true; - break; + if user_provided_paths { + for path_with_position in &paths_with_position { + if app_state.fs.is_dir(&path_with_position.path).await { + wait_for_window_close = true; + break; + } } } diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index e35bd2aad5d087..0f6864e6fa33af 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -11,7 +11,7 @@ use editor::actions::{ use editor::code_context_menus::{CodeContextMenu, ContextMenuOrigin}; use editor::{Editor, EditorSettings}; use gpui::{ - Action, AnchoredPositionMode, ClickEvent, Context, Corner, ElementId, Entity, EventEmitter, + Action, Anchor, AnchoredPositionMode, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, anchored, deferred, point, }; @@ -112,11 +112,13 @@ impl Render for QuickActionBar { let supports_inlay_hints = editor.update(cx, |editor, cx| editor.supports_inlay_hints(cx)); let supports_semantic_tokens = editor.update(cx, |editor, cx| editor.supports_semantic_tokens(cx)); + let supports_code_lens = editor.update(cx, |editor, cx| editor.supports_code_lens(cx)); let editor_value = editor.read(cx); let selection_menu_enabled = editor_value.selection_menu_enabled(cx); let inlay_hints_enabled = editor_value.inlay_hints_enabled(); let inline_values_enabled = editor_value.inline_values_enabled(); let semantic_highlights_enabled = editor_value.semantic_highlights_enabled(); + let code_lens_enabled = editor_value.code_lens_enabled(); let is_full = editor_value.mode().is_full(); let diagnostics_enabled = editor_value.diagnostics_max_severity != DiagnosticSeverity::Off; let supports_inline_diagnostics = editor_value.inline_diagnostics_enabled(); @@ -131,7 +133,7 @@ impl Render for QuickActionBar { editor_value.edit_predictions_enabled_at_cursor(cx); let supports_minimap = editor_value.supports_minimap(cx); let minimap_enabled = supports_minimap && editor_value.minimap().is_some(); - let has_available_code_actions = editor_value.has_available_code_actions(); + let has_available_code_actions = editor_value.has_available_code_actions_for_selection(); let code_action_enabled = editor_value.code_actions_enabled_for_toolbar(cx); let focus_handle = editor_value.focus_handle(cx); @@ -167,7 +169,6 @@ impl Render for QuickActionBar { ); let code_actions_dropdown = code_action_enabled.then(|| { - let focus = editor.focus_handle(cx); let is_deployed = { let menu_ref = editor.read(cx).context_menu().borrow(); let code_action_menu = menu_ref @@ -209,16 +210,18 @@ impl Render for QuickActionBar { ) }) .on_click({ - let focus = focus; + let editor = editor.clone(); move |_, window, cx| { - focus.dispatch_action( - &ToggleCodeActions { - deployed_from: Some(CodeActionSource::QuickActionBar), - quick_launch: false, - }, - window, - cx, - ); + editor.update(cx, |editor, cx| { + editor.toggle_code_actions( + &ToggleCodeActions { + deployed_from: Some(CodeActionSource::QuickActionBar), + quick_launch: false, + }, + window, + cx, + ); + }) } }), ) @@ -227,7 +230,7 @@ impl Render for QuickActionBar { anchored() .position_mode(AnchoredPositionMode::Local) .position(point(px(20.), px(20.))) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .child(menu), ) })) @@ -257,7 +260,7 @@ impl Render for QuickActionBar { Tooltip::text("Selection Controls"), ) .with_handle(self.toggle_selections_handle.clone()) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .menu(move |window, cx| { let focus = focus.clone(); let menu = ContextMenu::build(window, cx, move |menu, _, _| { @@ -329,7 +332,7 @@ impl Render for QuickActionBar { .toggle_state(self.toggle_settings_handle.is_deployed()), Tooltip::text("Editor Controls"), ) - .anchor(Corner::TopRight) + .anchor(Anchor::TopRight) .with_handle(self.toggle_settings_handle.clone()) .menu(move |window, cx| { let menu = ContextMenu::build(window, cx, { @@ -404,6 +407,29 @@ impl Render for QuickActionBar { ); } + if supports_code_lens { + menu = menu.toggleable_entry( + "Code Lens", + code_lens_enabled, + IconPosition::Start, + Some(editor::actions::ToggleCodeLens.boxed_clone()), + { + let editor = editor.clone(); + move |window, cx| { + editor + .update(cx, |editor, cx| { + editor.toggle_code_lens_action( + &editor::actions::ToggleCodeLens, + window, + cx, + ); + }) + .ok(); + } + }, + ); + } + if supports_minimap { menu = menu.toggleable_entry("Minimap", minimap_enabled, IconPosition::Start, Some(editor::actions::ToggleMinimap.boxed_clone()), { let editor = editor.clone(); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 404a2af27d81ec..f09fa27ddf267d 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -1,6 +1,7 @@ use gpui::{Action, actions}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; // If the zed binary doesn't use anything in this crate, it will be optimized away // and the actions won't initialize. So we just provide an empty initialization function @@ -251,6 +252,49 @@ pub mod workspace { ); } +/// Describes which ref to base a new git worktree on. The worktree is +/// always created in a detached HEAD state; users can opt into creating +/// a branch afterwards from the worktree itself. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum NewWorktreeBranchTarget { + /// Create a detached worktree from the current HEAD. + #[default] + CurrentBranch, + /// Create a detached worktree at the tip of an existing branch. + ExistingBranch { name: String }, +} + +/// Creates a new git worktree and switches the workspace to it. +/// Dispatched by the unified worktree picker when the user selects a "Create new worktree" entry. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)] +#[action(namespace = git)] +#[serde(deny_unknown_fields)] +pub struct CreateWorktree { + /// When this is None, Zed will randomly generate a worktree name. + pub worktree_name: Option, + pub branch_target: NewWorktreeBranchTarget, +} + +/// Switches the workspace to an existing linked worktree. +/// Dispatched by the unified worktree picker when the user selects an existing worktree. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)] +#[action(namespace = git)] +#[serde(deny_unknown_fields)] +pub struct SwitchWorktree { + pub path: PathBuf, + pub display_name: String, +} + +/// Opens an existing worktree in a new window. +/// Dispatched by the worktree picker's "Open in New Window" button. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)] +#[action(namespace = git)] +#[serde(deny_unknown_fields)] +pub struct OpenWorktreeInNewWindow { + pub path: PathBuf, +} + pub mod git { use gpui::actions; @@ -423,7 +467,9 @@ pub mod buffer_search { /// Dismisses the search bar. Dismiss, /// Focuses back on the editor. - FocusEditor + FocusEditor, + /// Sets the search query to the current selection without opening the search bar or running a search. + UseSelectionForFind, ] ); } diff --git a/crates/zeta_prompt/src/multi_region.rs b/crates/zeta_prompt/src/multi_region.rs index a2e50ca4459986..5bd486df767aac 100644 --- a/crates/zeta_prompt/src/multi_region.rs +++ b/crates/zeta_prompt/src/multi_region.rs @@ -11,6 +11,7 @@ const MAX_NUDGE_LINES: usize = 5; pub const V0316_END_MARKER: &str = "<[end▁of▁sentence]>"; pub const V0317_END_MARKER: &str = "<[end▁of▁sentence]>"; pub const V0318_END_MARKER: &str = "<[end▁of▁sentence]>"; +pub const V0327_END_MARKER: &str = "<[end▁of▁sentence]>"; pub fn marker_tag(number: usize) -> String { format!("{MARKER_TAG_PREFIX}{number}{MARKER_TAG_SUFFIX}") @@ -143,6 +144,112 @@ pub fn compute_marker_offsets_v0318(editable_text: &str) -> Vec { compute_marker_offsets_with_limits(editable_text, V0318_MIN_BLOCK_LINES, V0318_MAX_BLOCK_LINES) } +fn line_start_at_or_before(text: &str, offset: usize) -> usize { + let bounded_offset = text.floor_char_boundary(offset.min(text.len())); + text[..bounded_offset] + .rfind('\n') + .map(|index| index + 1) + .unwrap_or(0) +} + +fn line_end_at_or_after(text: &str, offset: usize) -> usize { + let bounded_offset = text.floor_char_boundary(offset.min(text.len())); + if bounded_offset >= text.len() { + return text.len(); + } + + text[bounded_offset..] + .find('\n') + .map(|index| bounded_offset + index + 1) + .unwrap_or(text.len()) +} + +fn grow_v0327_candidate_range( + text: &str, + cursor_offset: usize, + editable_token_limit: usize, +) -> std::ops::Range { + if text.is_empty() { + return 0..0; + } + + let byte_budget = editable_token_limit.saturating_mul(3).max(1); + let half_budget = byte_budget / 2; + + let mut start = cursor_offset.saturating_sub(half_budget); + let mut end = start.saturating_add(byte_budget).min(text.len()); + + if end.saturating_sub(start) < byte_budget { + start = end.saturating_sub(byte_budget); + } + + start = line_start_at_or_before(text, start); + end = line_end_at_or_after(text, end); + + if start < end { + start..end + } else { + let line_start = line_start_at_or_before(text, cursor_offset); + let line_end = line_end_at_or_after(text, cursor_offset); + line_start..line_end.max(line_start) + } +} + +fn trim_v0327_candidate_range_to_markers( + text: &str, + candidate_range: std::ops::Range, + cursor_offset: usize, +) -> std::ops::Range { + let candidate_text = &text[candidate_range.clone()]; + let marker_offsets = compute_marker_offsets_v0318(candidate_text); + + if marker_offsets.len() <= 2 { + return candidate_range; + } + + let candidate_cursor_offset = cursor_offset + .saturating_sub(candidate_range.start) + .min(candidate_text.len()); + let first_internal_marker_index = if candidate_cursor_offset >= marker_offsets[1] { + 1 + } else { + 0 + }; + let last_internal_marker_index = marker_offsets.len() - 2; + let last_marker_index = marker_offsets.len() - 1; + let end_marker_index = if candidate_cursor_offset <= marker_offsets[last_internal_marker_index] + { + last_internal_marker_index + } else { + last_marker_index + }; + + let trimmed_start = candidate_range.start + marker_offsets[first_internal_marker_index]; + let trimmed_end = candidate_range.start + marker_offsets[end_marker_index]; + + if trimmed_start < trimmed_end { + trimmed_start..trimmed_end + } else { + let block_index = cursor_block_index(Some(candidate_cursor_offset), &marker_offsets); + let start = candidate_range.start + marker_offsets[block_index]; + let end = candidate_range.start + marker_offsets[block_index + 1]; + if start < end { + start..end + } else { + candidate_range + } + } +} + +pub fn compute_v0327_editable_range( + text: &str, + cursor_offset: usize, + editable_token_limit: usize, +) -> std::ops::Range { + let candidate_range = grow_v0327_candidate_range(text, cursor_offset, editable_token_limit); + trim_v0327_candidate_range_to_markers(text, candidate_range, cursor_offset) +} + /// Write the editable region content with marker tags, inserting the cursor /// marker at the given offset within the editable text. pub fn write_editable_with_markers( @@ -1113,6 +1220,32 @@ hhhhhhhhhh = 8; assert_eq!(offsets, vec![0, 0]); } + #[test] + fn test_compute_v0327_editable_range_trims_to_marker_boundaries() { + let text = (0..80).map(|_| "x\n").collect::(); + let cursor_offset = text.find("x\nx\nx\nx\nx\n").expect("cursor anchor exists") + 40; + + let candidate_range = grow_v0327_candidate_range(&text, cursor_offset, 20); + let editable_range = compute_v0327_editable_range(&text, cursor_offset, 20); + let marker_offsets = compute_marker_offsets_v0318(&text[candidate_range.clone()]); + let relative_start = editable_range.start - candidate_range.start; + let relative_end = editable_range.end - candidate_range.start; + + assert!( + marker_offsets.len() > 2, + "expected interior markers: {marker_offsets:?}" + ); + assert!(marker_offsets.contains(&relative_start)); + assert!(marker_offsets.contains(&relative_end)); + assert!(editable_range.start <= cursor_offset); + assert!(editable_range.end >= cursor_offset); + assert!( + editable_range.start > candidate_range.start + || editable_range.end < candidate_range.end, + "expected at least one side to trim from {candidate_range:?} down to {editable_range:?}" + ); + } + #[test] fn test_compute_marker_offsets_avoid_short_markdown_blocks() { let text = "\ diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 3fa12a7a789b19..37799d528b923b 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -15,7 +15,6 @@ pub use crate::excerpt_ranges::{ }; pub const CURSOR_MARKER: &str = "<|user_cursor|>"; -pub const MAX_PROMPT_TOKENS: usize = 4096; /// Use up to this amount of the editable region for prefill. /// Larger values may result in more robust generation, but @@ -82,6 +81,7 @@ pub enum ZetaFormat { V0131GitMergeMarkersPrefix, V0211Prefill, V0211SeedCoder, + V0331SeedCoderModelPy, v0226Hashline, V0304VariableEdit, V0304SeedNoEdits, @@ -89,10 +89,12 @@ pub enum ZetaFormat { V0306SeedMultiRegions, /// Byte-exact marker spans; all intermediate markers emitted; repeated marker means no-edit. V0316SeedMultiRegions, - /// V0316 with larger block sizes. - V0318SeedMultiRegions, /// V0316, but marker numbers are relative to the cursor block (e.g. -1, -0, +1). V0317SeedMultiRegions, + /// V0316 with larger block sizes. + V0318SeedMultiRegions, + /// V0318-style markers over the full available current file excerpt with no related files. + V0327SingleFile, } impl std::fmt::Display for ZetaFormat { @@ -228,7 +230,26 @@ pub fn prompt_input_contains_special_tokens(input: &ZetaPromptInput, format: Zet } pub fn format_zeta_prompt(input: &ZetaPromptInput, format: ZetaFormat) -> Option { - format_prompt_with_budget_for_format(input, format, MAX_PROMPT_TOKENS) + let max_prompt_tokens = match format { + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion + | ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill + | ZetaFormat::V0211SeedCoder + | ZetaFormat::v0226Hashline + | ZetaFormat::V0304VariableEdit + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0306SeedMultiRegions + | ZetaFormat::V0316SeedMultiRegions + | ZetaFormat::V0317SeedMultiRegions + | ZetaFormat::V0331SeedCoderModelPy + | ZetaFormat::V0318SeedMultiRegions => 4096, + ZetaFormat::V0327SingleFile => 16384, + }; + + format_prompt_with_budget_for_format(input, format, max_prompt_tokens) } pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] { @@ -239,7 +260,9 @@ pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] ZetaFormat::V0120GitMergeMarkers => v0120_git_merge_markers::special_tokens(), ZetaFormat::V0131GitMergeMarkersPrefix => v0131_git_merge_markers_prefix::special_tokens(), ZetaFormat::V0211Prefill => v0211_prefill::special_tokens(), - ZetaFormat::V0211SeedCoder => seed_coder::special_tokens(), + ZetaFormat::V0211SeedCoder | ZetaFormat::V0331SeedCoderModelPy => { + seed_coder::special_tokens() + } ZetaFormat::v0226Hashline => hashline::special_tokens(), ZetaFormat::V0304VariableEdit => v0304_variable_edit::special_tokens(), ZetaFormat::V0304SeedNoEdits => seed_coder::special_tokens(), @@ -279,6 +302,18 @@ pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] ]; TOKENS } + ZetaFormat::V0327SingleFile => { + static TOKENS: &[&str] = &[ + seed_coder::FIM_SUFFIX, + seed_coder::FIM_PREFIX, + seed_coder::FIM_MIDDLE, + seed_coder::FILE_MARKER, + multi_region::V0327_END_MARKER, + CURSOR_MARKER, + multi_region::MARKER_TAG_PREFIX, + ]; + TOKENS + } ZetaFormat::V0306SeedMultiRegions => { static TOKENS: &[&str] = &[ seed_coder::FIM_SUFFIX, @@ -305,12 +340,15 @@ pub fn token_limits_for_format(format: ZetaFormat) -> (usize, usize) { | ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill | ZetaFormat::V0211SeedCoder + | ZetaFormat::V0331SeedCoderModelPy | ZetaFormat::v0226Hashline | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0316SeedMultiRegions | ZetaFormat::V0318SeedMultiRegions | ZetaFormat::V0317SeedMultiRegions + | ZetaFormat::V0327SingleFile | ZetaFormat::V0304SeedNoEdits => (350, 150), + ZetaFormat::V0304VariableEdit => (1024, 0), } } @@ -325,15 +363,18 @@ pub fn stop_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] { | ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill | ZetaFormat::V0211SeedCoder + | ZetaFormat::V0331SeedCoderModelPy | ZetaFormat::V0304VariableEdit | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0304SeedNoEdits => &[], ZetaFormat::V0316SeedMultiRegions => &[multi_region::V0316_END_MARKER], ZetaFormat::V0318SeedMultiRegions => &[multi_region::V0318_END_MARKER], ZetaFormat::V0317SeedMultiRegions => &[multi_region::V0317_END_MARKER], + ZetaFormat::V0327SingleFile => &[multi_region::V0327_END_MARKER], } } +/// Return (editable_range, context_range) for the prompt format pub fn excerpt_ranges_for_format( format: ZetaFormat, ranges: &ExcerptRanges, @@ -351,6 +392,7 @@ pub fn excerpt_ranges_for_format( | ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill | ZetaFormat::V0211SeedCoder + | ZetaFormat::V0331SeedCoderModelPy | ZetaFormat::v0226Hashline | ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions @@ -360,6 +402,14 @@ pub fn excerpt_ranges_for_format( ranges.editable_350.clone(), ranges.editable_350_context_150.clone(), ), + ZetaFormat::V0327SingleFile => ( + ranges.editable_350_context_150.clone(), + ranges.context_8192.clone().unwrap_or( + // shouldn't be used, only for compat with old data/clients + ranges.editable_350_context_150.clone(), + ), + ), + ZetaFormat::V0304VariableEdit => { let context = ranges .editable_350_context_1024 @@ -412,15 +462,15 @@ pub fn write_cursor_excerpt_section_for_format( cursor_offset, ) } - ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => { - seed_coder::write_cursor_excerpt_section( - prompt, - path, - context, - editable_range, - cursor_offset, - ) - } + ZetaFormat::V0211SeedCoder + | ZetaFormat::V0331SeedCoderModelPy + | ZetaFormat::V0304SeedNoEdits => seed_coder::write_cursor_excerpt_section( + prompt, + path, + context, + editable_range, + cursor_offset, + ), ZetaFormat::v0226Hashline => hashline::write_cursor_excerpt_section( prompt, path, @@ -463,6 +513,14 @@ pub fn write_cursor_excerpt_section_for_format( cursor_offset, )); } + ZetaFormat::V0327SingleFile => { + prompt.push_str(&build_v0318_cursor_prefix( + path, + context, + editable_range, + cursor_offset, + )); + } } } @@ -585,6 +643,40 @@ fn offset_range_to_row_range(text: &str, range: Range) -> Range { return start_row..end_row; } +fn assemble_single_file_fim_prompt( + context: &str, + editable_range: &Range, + cursor_prefix_section: &str, + events: &[Arc], + max_tokens: usize, +) -> String { + let suffix_section = seed_coder::build_suffix_section(context, editable_range); + + let suffix_tokens = estimate_tokens(suffix_section.len() + seed_coder::FIM_PREFIX.len()); + let cursor_prefix_tokens = + estimate_tokens(cursor_prefix_section.len() + seed_coder::FIM_MIDDLE.len()); + let budget_after_cursor = max_tokens.saturating_sub(suffix_tokens + cursor_prefix_tokens); + + let edit_history_section = format_edit_history_within_budget( + events, + seed_coder::FILE_MARKER, + "edit_history", + budget_after_cursor, + max_edit_event_count_for_format(&ZetaFormat::V0327SingleFile), + ); + + let mut prompt = String::new(); + prompt.push_str(&suffix_section); + prompt.push_str(seed_coder::FIM_PREFIX); + prompt.push_str(&edit_history_section); + if !edit_history_section.is_empty() { + prompt.push('\n'); + } + prompt.push_str(cursor_prefix_section); + prompt.push_str(seed_coder::FIM_MIDDLE); + prompt +} + pub fn format_prompt_with_budget_for_format( input: &ZetaPromptInput, format: ZetaFormat, @@ -596,21 +688,23 @@ pub fn format_prompt_with_budget_for_format( let empty_files = Vec::new(); let input_related_files = input.related_files.as_deref().unwrap_or(&empty_files); - let related_files = if let Some(cursor_excerpt_start_row) = input.excerpt_start_row { + let filtered_related_files = if let Some(cursor_excerpt_start_row) = input.excerpt_start_row { let relative_row_range = offset_range_to_row_range(&input.cursor_excerpt, context_range); let row_range = relative_row_range.start + cursor_excerpt_start_row ..relative_row_range.end + cursor_excerpt_start_row; - &filter_redundant_excerpts( + filter_redundant_excerpts( input_related_files.to_vec(), input.cursor_path.as_ref(), row_range, ) } else { - input_related_files + input_related_files.to_vec() }; + let related_files = filtered_related_files.as_slice(); let prompt = match format { ZetaFormat::V0211SeedCoder + | ZetaFormat::V0331SeedCoderModelPy | ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0316SeedMultiRegions @@ -636,6 +730,25 @@ pub fn format_prompt_with_budget_for_format( budget_with_margin, ) } + ZetaFormat::V0327SingleFile => { + let mut cursor_section = String::new(); + write_cursor_excerpt_section_for_format( + format, + &mut cursor_section, + path, + context, + &editable_range, + cursor_offset, + ); + + assemble_single_file_fim_prompt( + context, + &editable_range, + &cursor_section, + &input.events, + apply_prompt_budget_margin(max_tokens), + ) + } _ => { let mut cursor_section = String::new(); write_cursor_excerpt_section_for_format( @@ -708,13 +821,15 @@ pub fn max_edit_event_count_for_format(format: &ZetaFormat) -> usize { | ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill | ZetaFormat::V0211SeedCoder + | ZetaFormat::V0331SeedCoderModelPy | ZetaFormat::v0226Hashline | ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0304VariableEdit | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0316SeedMultiRegions | ZetaFormat::V0318SeedMultiRegions - | ZetaFormat::V0317SeedMultiRegions => 6, + | ZetaFormat::V0317SeedMultiRegions + | ZetaFormat::V0327SingleFile => 6, } } @@ -731,13 +846,15 @@ pub fn get_prefill_for_format( | ZetaFormat::V0120GitMergeMarkers | ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211SeedCoder + | ZetaFormat::V0331SeedCoderModelPy | ZetaFormat::v0226Hashline | ZetaFormat::V0304VariableEdit => String::new(), ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0316SeedMultiRegions | ZetaFormat::V0318SeedMultiRegions - | ZetaFormat::V0317SeedMultiRegions => String::new(), + | ZetaFormat::V0317SeedMultiRegions + | ZetaFormat::V0327SingleFile => String::new(), } } @@ -747,11 +864,14 @@ pub fn output_end_marker_for_format(format: ZetaFormat) -> Option<&'static str> ZetaFormat::V0131GitMergeMarkersPrefix => Some(v0131_git_merge_markers_prefix::END_MARKER), ZetaFormat::V0211Prefill => Some(v0131_git_merge_markers_prefix::END_MARKER), ZetaFormat::V0211SeedCoder + | ZetaFormat::V0331SeedCoderModelPy | ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions => Some(seed_coder::END_MARKER), ZetaFormat::V0316SeedMultiRegions => Some(multi_region::V0316_END_MARKER), ZetaFormat::V0318SeedMultiRegions => Some(multi_region::V0318_END_MARKER), ZetaFormat::V0317SeedMultiRegions => Some(multi_region::V0317_END_MARKER), + ZetaFormat::V0327SingleFile => Some(multi_region::V0327_END_MARKER), + ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered | ZetaFormat::V0114180EditableRegion @@ -822,6 +942,22 @@ pub fn encode_patch_as_output_for_format( Ok(None) } } + ZetaFormat::V0327SingleFile => { + let empty_patch = patch.lines().count() <= 3; + if empty_patch { + let marker_offsets = + multi_region::compute_marker_offsets_v0318(old_editable_region); + let marker_num = + multi_region::nearest_marker_number(cursor_offset, &marker_offsets); + let tag = multi_region::marker_tag(marker_num); + Ok(Some(format!( + "{tag}{tag}{}", + multi_region::V0327_END_MARKER + ))) + } else { + Ok(None) + } + } _ => Ok(None), } } @@ -865,7 +1001,7 @@ pub fn format_expected_output( multi_region::V0316_END_MARKER, ) } - ZetaFormat::V0318SeedMultiRegions => { + ZetaFormat::V0318SeedMultiRegions | ZetaFormat::V0327SingleFile => { let (new_editable, first_hunk_offset) = udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable)?; let cursor_in_new = cursor_in_new_text(cursor_offset, first_hunk_offset, &new_editable); @@ -891,7 +1027,18 @@ pub fn format_expected_output( } // V0131-style formats and fallback: produce new editable text with // cursor marker inserted, followed by the end marker. - _ => { + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion + | ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill + | ZetaFormat::V0211SeedCoder + | ZetaFormat::v0226Hashline + | ZetaFormat::V0304VariableEdit + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0331SeedCoderModelPy + | ZetaFormat::V0306SeedMultiRegions => { let (mut result, first_hunk_offset) = if empty_patch { (old_editable.clone(), None) } else { @@ -1027,6 +1174,10 @@ pub fn parse_zeta2_model_output( Some(cursor_offset_in_editable), )?, ), + ZetaFormat::V0327SingleFile => ( + editable_range_in_context, + multi_region::apply_marker_span_v0318(old_editable_region, output)?, + ), _ => (editable_range_in_context, output.to_string()), }; @@ -1135,7 +1286,16 @@ pub fn resolve_cursor_region( input: &ZetaPromptInput, format: ZetaFormat, ) -> (&str, Range, Range, usize) { - let (editable_range, context_range) = if let Some(syntax_ranges) = &input.syntax_ranges { + let (editable_range, context_range) = if format == ZetaFormat::V0327SingleFile { + let (editable_tokens, _) = token_limits_for_format(format); + let context_range = 0..input.cursor_excerpt.len(); + let editable_range = multi_region::compute_v0327_editable_range( + &input.cursor_excerpt, + input.cursor_offset_in_excerpt, + editable_tokens, + ); + (editable_range, context_range) + } else if let Some(syntax_ranges) = &input.syntax_ranges { let (editable_tokens, context_tokens) = token_limits_for_format(format); compute_editable_and_context_ranges( &input.cursor_excerpt, @@ -1147,6 +1307,7 @@ pub fn resolve_cursor_region( } else { excerpt_range_for_format(format, &input.excerpt_ranges) }; + let context_start = context_range.start; let context_text = &input.cursor_excerpt[context_range.clone()]; let adjusted_editable = @@ -3218,7 +3379,7 @@ pub mod seed_coder { prompt } - fn build_suffix_section(context: &str, editable_range: &Range) -> String { + pub(crate) fn build_suffix_section(context: &str, editable_range: &Range) -> String { let mut section = String::new(); section.push_str(FIM_SUFFIX); section.push_str(&context[editable_range.end..]); @@ -4944,6 +5105,26 @@ mod tests { .expect("seed coder prompt formatting should succeed") } + #[test] + fn test_seed_coder_alias_matches_v0211_seed_coder() { + let input = make_input( + "prefix\neditable\nsuffix", + 7..15, + 10, + vec![make_event("a.rs", "-old\n+new\n")], + vec![make_related_file("related.rs", "fn helper() {}\n")], + ); + + assert_eq!( + format_prompt_with_budget_for_format(&input, ZetaFormat::V0211SeedCoder, 10000), + format_prompt_with_budget_for_format(&input, ZetaFormat::V0331SeedCoderModelPy, 10000) + ); + assert_eq!( + ZetaFormat::parse("V0331SeedCoderModelPy").unwrap(), + ZetaFormat::V0331SeedCoderModelPy + ); + } + #[test] fn test_seed_coder_basic_format() { let input = make_input( @@ -5005,6 +5186,71 @@ mod tests { assert!(prompt.contains(CURSOR_MARKER)); } + #[test] + fn test_v0327_formats_single_file_prompt_without_related_files() { + let excerpt = indoc! {" + line01 + line02 + line03 + line04 + line05 + line06 + line07 + line08 + line09 + line10 + line11 + line12 + line13 + line14 + line15 + line16 + line17 + line18 + line19 + line20 + "}; + let cursor_offset = excerpt.find("line10").expect("cursor line exists"); + let input = make_input( + excerpt, + 0..excerpt.len(), + cursor_offset, + vec![make_event("a.rs", "-x\n+y\n")], + vec![make_related_file("related.rs", "fn helper() {}\n")], + ); + + let prompt = + format_prompt_with_budget_for_format(&input, ZetaFormat::V0327SingleFile, 4096) + .expect("v0327 prompt should fit"); + + assert!(prompt.contains("line01")); + assert!(prompt.contains("line20")); + assert!(prompt.contains("edit_history")); + assert!(prompt.contains("test.rs")); + assert!(prompt.contains(CURSOR_MARKER)); + assert!(!prompt.contains("related.rs")); + assert!(!prompt.contains("fn helper() {}")); + } + + #[test] + fn test_v0327_resolve_cursor_region_uses_full_excerpt_context() { + let excerpt = (0..80) + .map(|index| format!("l{index:02}\n")) + .collect::(); + let cursor_offset = excerpt.find("l40").expect("cursor line exists"); + let input = make_input(&excerpt, 0..excerpt.len(), cursor_offset, vec![], vec![]); + + let (context, editable_range, context_range, adjusted_cursor) = + resolve_cursor_region(&input, ZetaFormat::V0327SingleFile); + + assert_eq!(context, excerpt); + assert_eq!(context_range, 0..excerpt.len()); + assert_eq!(adjusted_cursor, cursor_offset); + assert!(editable_range.start < adjusted_cursor); + assert!(editable_range.end > adjusted_cursor); + assert!(editable_range.end < excerpt.len()); + } + #[test] fn test_seed_coder_no_context() { let input = make_input("before\nmiddle\nafter", 7..13, 10, vec![], vec![]); diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2dca46d99a4a27..59b0a5b1cefb35 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -15,6 +15,7 @@ - [Tools](./ai/tools.md) - [Tool Permissions](./ai/tool-permissions.md) - [External Agents](./ai/external-agents.md) +- [Parallel Agents](./ai/parallel-agents.md) - [Inline Assistant](./ai/inline-assistant.md) - [Edit Prediction](./ai/edit-prediction.md) - [Rules](./ai/rules.md) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 89b0126c55a12b..5f7fe17baec03f 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -34,7 +34,26 @@ The sections below cover what you can do from here. By default, the Agent Panel uses Zed's first-party agent. -To choose another agent, go to the plus button in the top-right of the Agent Panel and pick one of the [external agents](./external-agents.md) installed out of the box. +Start a new thread with {#kb agent::NewThread}, or open the "New Thread…" menu via the `+` icon in the top-right of the panel toolbar (in the empty state, this menu is exposed as the agent selector button on the left). You can also open that menu with {#kb agent::ToggleNewThreadMenu}. + +From the "New Thread…" menu you can: + +- Pick **Zed Agent** or any installed [external agent](./external-agents.md) to start a new thread with that agent. +- Choose **New From Summary** to start a fresh Zed Agent thread seeded with a summary of the current conversation — useful for compacting long threads as you approach the context window limit. + +{#action agent::NewExternalAgentThread} creates another thread with the currently selected agent. + +You can also start a new thread from the [Threads Sidebar](./parallel-agents.md#threads-sidebar), scoped to a specific project — see [Running Multiple Threads](./parallel-agents.md#running-multiple-threads). + +### Managing Multiple Threads {#multiple-threads} + +You can run multiple agent threads at once, each working independently with its own agent, context window, and conversation history. Open the Threads Sidebar with {#kb multi_workspace::ToggleWorkspaceSidebar} to see all your threads grouped by project. Click any thread to switch to it, or use the thread switcher ({#kb agents_sidebar::ToggleThreadSwitcher}) to cycle between recent threads without opening the sidebar. + +Threads you're no longer working on can be archived by hovering over them in the sidebar and clicking the archive icon, or selecting them and pressing {#kb agent::ArchiveSelectedThread}. The Thread History holds all your threads across all projects, sorted chronologically, and you can restore them at any time. + +If two threads might edit the same files, you can isolate one in a new Git worktree. Use the worktree picker in the title bar to pick which worktree the agent runs in, or create a new one. See [Worktree Isolation](./parallel-agents.md#worktree-isolation) for details. + +For more details on the Threads Sidebar and managing multiple projects, see [Parallel Agents](./parallel-agents.md). ### Editing Messages {#editing-messages} @@ -71,12 +90,7 @@ In long conversations, use the scroll arrow buttons at the bottom of the panel t When focus is in the message editor, you can also use {#kb agent::ScrollOutputPageUp}, {#kb agent::ScrollOutputPageDown}, {#kb agent::ScrollOutputToTop}, {#kb agent::ScrollOutputToBottom}, {#kb agent::ScrollOutputLineUp}, and {#kb agent::ScrollOutputLineDown} to navigate the thread, or {#kb agent::ScrollOutputToPreviousMessage} and {#kb agent::ScrollOutputToNextMessage} to jump between your prompts. -### Navigating History {#navigating-history} - -To quickly navigate through recently updated threads, use the {#kb agent::ToggleNavigationMenu} binding when focused on the panel's editor, or click the menu icon button at the top right of the panel. -Doing that will open a dropdown that shows you your six most recently updated threads. - -To view all historical conversations, reach for the `View All` option from within the same menu or via the {#kb agent::OpenHistory} binding. +### Thread titles {#thread-titles} Thread titles are auto-generated based on the content of the conversation. But you can also edit them manually by clicking the title and typing, or regenerate them by clicking the "Regenerate Thread Title" button in the ellipsis menu in the top right of the panel. diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index ea84f223dd0f73..92c490a05d2031 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -607,6 +607,7 @@ By default, OpenAI-compatible models inherit the following capabilities: - `parallel_tool_calls`: false (does not support `parallel_tool_calls` parameter) - `prompt_cache_key`: false (does not support `prompt_cache_key` parameter) - `chat_completions`: true (calls the `/chat/completions` endpoint) +- `interleaved_reasoning`: false (thinking tokens are sent inline in message text; set to true to send them as a dedicated `reasoning_content` field for models that expect it) If a provider exposes models that only work with the Responses API, set `chat_completions` to `false` for those entries. Zed uses the Responses endpoint for these models. @@ -762,7 +763,7 @@ You can also set a custom endpoint for Vercel AI Gateway in your settings file: [Vercel v0](https://v0.app/docs/api/model) is a model for generating full-stack apps, with framework-aware completions for stacks like Next.js and Vercel. It supports text and image inputs and provides fast streaming responses. -The v0 models are [OpenAI-compatible models](/#openai-api-compatible), and Vercel appears as a dedicated provider in the panel's settings view. +The v0 models are [OpenAI-compatible models](#openai-api-compatible), and Vercel appears as a dedicated provider in the panel's settings view. To start using it with Zed, ensure you have first created a [v0 API key](https://v0.dev/chat/settings/keys). Once you have it, paste it directly into the Vercel provider section in the panel's settings view. diff --git a/docs/src/ai/overview.md b/docs/src/ai/overview.md index 7ea435975ec5ba..0859d47c127cb6 100644 --- a/docs/src/ai/overview.md +++ b/docs/src/ai/overview.md @@ -18,7 +18,9 @@ Zed's AI features run inside a native, GPU-accelerated application built in Rust ## Agentic editing -The [Agent Panel](./agent-panel.md) is where you work with AI agents. Agents can read files, edit code, run terminal commands, search the web, and access diagnostics through [built-in tools](./tools.md). +The [Threads Sidebar](./parallel-agents.md#threads-sidebar) is where you organize agent work. Start a thread, give it a task, and the agent reads, edits, and runs code in your project. You can run multiple threads at once, each using a different agent and working against different projects. See [Tools](./tools.md) for the capabilities available to Zed's built-in agent. + +The [Agent Panel](./agent-panel.md) is the conversation view for the active thread. Use it to send prompts, review changes, add context, and interact with the agent as it works. You can extend agents with additional tools through [MCP servers](./mcp.md), control what they can access with [tool permissions](./tool-permissions.md), and shape their behavior with [rules](./rules.md). @@ -33,6 +35,7 @@ The default provider is Zeta, Zed's open-source model trained on open data. You ## Getting started - [Configuration](./configuration.md): Connect to Anthropic, OpenAI, Ollama, Google AI, or other LLM providers. +- [Parallel Agents](./parallel-agents.md): Run multiple threads at once with the Threads Sidebar. - [External Agents](./external-agents.md): Run Claude Agent, Codex, Aider, or other external agents inside Zed. - [Subscription](./subscription.md): Zed's hosted models and billing. - [Privacy and Security](./privacy-and-security.md): How Zed handles data when using AI features. diff --git a/docs/src/ai/parallel-agents.md b/docs/src/ai/parallel-agents.md new file mode 100644 index 00000000000000..f7d007f0f3b2c6 --- /dev/null +++ b/docs/src/ai/parallel-agents.md @@ -0,0 +1,77 @@ +--- +title: Parallel Agents - Zed +description: Run multiple agent threads concurrently using the Threads Sidebar, manage them across projects, and isolate work using Git worktrees. +--- + +# Parallel Agents + +Parallel Agents lets you run multiple agent threads at once, each working independently with its own agent, context window, and conversation history. The Threads Sidebar is where you start, manage, and switch between them. + +Open the Threads Sidebar with {#kb multi_workspace::ToggleWorkspaceSidebar}. + +> **Note:** From version 0.233.0 onward, the Agent Panel and Threads Sidebar are on the left by default. The Project Panel, Git Panel, and other panels move to the right, keeping the thread list and conversation next to each other. To rearrange panels, right-click any panel icon. + +## Threads Sidebar {#threads-sidebar} + +The sidebar shows your threads grouped by project. Each project gets its own section with a header. Threads appear below with their title, status indicator, and which agent is running them. Threads running in linked Git worktrees appear under the same project as their main worktree. See [Worktree Isolation](#worktree-isolation). + +To focus the sidebar without toggling it, use {#kb multi_workspace::FocusWorkspaceSidebar}. To search your threads, press {#kb agents_sidebar::FocusSidebarFilter} while the sidebar is focused. + +### Switching Threads {#switching-threads} + +Click any thread in the sidebar to switch to it. The Agent Panel updates to show that thread's conversation. + +For quick switching without opening the sidebar, use the thread switcher: press {#kb agents_sidebar::ToggleThreadSwitcher} to cycle forward through recent threads, or hold `Shift` while pressing that binding to go backward. This works from both the Agent Panel and the Threads Sidebar. + +### Thread History {#threads-history} + +To remove a thread from the sidebar, you can archive it by hovering over it and clicking the archive icon that appears. You can also select a thread and press {#kb agent::ArchiveSelectedThread}. Running threads cannot be moved to history until they finish. + +The Thread History view holds all your threads, including ones that you have archived. Toggle it with {#kb agents_sidebar::ToggleThreadHistory} or by clicking the clock icon in the sidebar bottom bar, next to the sidebar toggle. + +To restore a thread, open Thread History and click the thread you want to bring back. Zed moves it back to the thread list and opens it in the Agent Panel. If the thread was running in a Git worktree that was removed, Zed restores the worktree automatically. + +To permanently delete a thread, open Thread History, hover over the thread, and click the trash icon. This removes the thread's conversation history and cleans up any associated worktree data. Deleted threads cannot be recovered. + +You can search your threads in history; search will fuzzy match on thread titles. + +### Importing External Agent Threads {#importing-threads} + +If you have external agents installed, Zed will detect whether you have existing threads and invite you to import them into Zed. Once you open Thread History, you'll find an import icon button in the Thread History toolbar that lets you import threads at any time. Clicking on it opens a modal where you can select the agents whose threads you want to import. + +## Running Multiple Threads {#running-multiple-threads} + +Each thread runs independently, so you can send a prompt, open a second thread, and give it a different task while the first continues working. To scope a new thread to a specific project, hover over that project's header in the Threads Sidebar and click the `+` button, or use {#action agents_sidebar::NewThreadInGroup} from the keyboard. See [Creating New Threads](./agent-panel.md#new-thread) for the other entry points. + +Each thread can use a different agent, so you can run Zed's built-in agent in one thread and an [external agent](./external-agents.md) like Claude Code or Codex in another. + +## Multiple Projects {#multiple-projects} + +The Threads Sidebar can hold multiple projects at once. Each project gets its own group with its own threads and conversation history. + +To add another project to the sidebar, click the **Add Project** button (open-folder icon) in the sidebar bottom bar. The popover that opens lists your recent projects and also provides **Add Local Folders** and **Add Remote Folder** buttons at the bottom. + +### Multi-Root Folder Projects {#multi-root-folder-projects} + +A single project can contain multiple folders (a multi-root folder project). Agents can then read and write across all of those folders in a single thread. There are two ways to set one up: + +- **From the sidebar:** Click the **Add Project** button, choose **Add Local Folders**, and select multiple folders in the file picker. They open together as one multi-root project. +- **From the title bar:** Click the project picker (the leftmost project name). For any local entry in the recent projects list, hover it and click the folder-with-plus icon (**Add Folder to this Project**) to merge that project's folders into the current project. + +## Worktree Isolation {#worktree-isolation} + +If two threads might edit the same files, start one in a new Git worktree to give it an isolated checkout. + +Worktrees are managed from the title bar. Click the worktree picker (to the right of the project picker) to switch between existing worktrees or create a new one. New worktrees are created in a detached HEAD state, so you won't accidentally share a branch between worktrees. + +Once you're in a new worktree, use the branch picker next to the worktree picker to create a new branch or check out an existing one. If the branch you pick is already checked out in another worktree, the current worktree stays in detached HEAD until you choose a different branch. + +To automate setup steps whenever a new worktree is created use a [Task hook](../tasks.md#hooks). The `create_worktree` hook runs automatically after Zed creates a linked worktree, with `ZED_WORKTREE_ROOT` pointing at the new worktree and `ZED_MAIN_GIT_WORKTREE` pointing at the original repository. + +After the agent finishes, review the diff and merge the changes through your normal Git workflow. If the thread was running in a linked worktree and no other active threads use it, moving the thread to Thread History saves the worktree's Git state and removes it from disk. Restoring the thread from history restores the worktree. + +## See Also {#see-also} + +- [Agent Panel](./agent-panel.md): Manage individual threads and configure the agent +- [External Agents](./external-agents.md): Use Claude Code, Gemini CLI, and other agents +- [Tools](./tools.md): Built-in tools available in each thread diff --git a/docs/src/languages/go.md b/docs/src/languages/go.md index e55bd2e67acd0b..c535acd80f0881 100644 --- a/docs/src/languages/go.md +++ b/docs/src/languages/go.md @@ -78,6 +78,39 @@ to override these settings. See [gopls inlayHints documentation](https://github.com/golang/tools/blob/master/gopls/doc/inlayHints.md) for more information. +## Code Lens + +Zed enables the `test` code lens for `gopls` by default. This shows "run test" and "run benchmark" links above `Test` and `Benchmark` functions in `*_test.go` files. To use them, enable the `code_lens` setting: + +```json [settings] +{ + "code_lens": "on" +} +``` + +You can override the default code lens settings in your `settings.json`: + +```json [settings] +{ + "lsp": { + "gopls": { + "initialization_options": { + "codelenses": { + "test": true, + "generate": true, + "regenerate_cgo": true, + "tidy": true, + "upgrade_dependency": true, + "vendor": true + } + } + } + } +} +``` + +See [gopls code lenses documentation](https://go.dev/gopls/codelenses) for more information. + ## Debugging Zed supports zero-configuration debugging of Go tests and entry points (`func main`) using Delve. Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these preconfigured debug tasks. diff --git a/docs/src/languages/typescript.md b/docs/src/languages/typescript.md index 25ec709e565df8..c4c454118ec2e2 100644 --- a/docs/src/languages/typescript.md +++ b/docs/src/languages/typescript.md @@ -189,6 +189,51 @@ When using `vtsls`: } ``` +## Code Lens + +Zed enables references and implementations code lenses for `vtsls` by default. These show reference counts and implementation counts above functions, classes, and interfaces. To use them, enable the `code_lens` setting: + +```json [settings] +{ + "code_lens": "on" +} +``` + +You can override the default code lens settings in your `settings.json`: + +```json [settings] +{ + "lsp": { + "vtsls": { + "settings": { + "typescript": { + "implementationsCodeLens": { + "enabled": true, + "showOnAllClassMethods": true, + "showOnInterfaceMethods": true + }, + "referencesCodeLens": { + "enabled": true, + "showOnAllFunctions": true + } + }, + "javascript": { + "implementationsCodeLens": { + "enabled": true, + "showOnAllClassMethods": true, + "showOnInterfaceMethods": true + }, + "referencesCodeLens": { + "enabled": true, + "showOnAllFunctions": true + } + } + } + } + } +} +``` + ## Debugging Zed supports debugging TypeScript code out of the box with `vscode-js-debug`. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index f6994bc7c79a21..1de73e0486f824 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -450,6 +450,24 @@ When enabled, this setting will automatically close tabs for files that have bee Note: Dirty files (files with unsaved changes) will not be automatically closed even when this setting is enabled, ensuring you don't lose unsaved work. +## Code Lens + +- Description: Whether and how to display code lenses from language servers. Code lenses show contextual information such as reference counts, implementations, and other metadata provided by the language server. +- Setting: `code_lens` +- Default: `off` + +**Options** + +1. `off`: Do not query and display code lenses. +2. `on`: Display code lenses from language servers above code elements. +3. `menu`: Display code lenses in the code action menu. + +```json [settings] +{ + "code_lens": "on" +} +``` + ## Confirm Quit - Description: Whether or not to prompt the user to confirm before closing the application. @@ -1618,6 +1636,56 @@ This setting enables integration with macOS’s native window tabbing feature. W `boolean` values +## Line Ending + +- Description: How line endings should be handled for new files and during format and save. This can be specified on a per-language basis. +- Setting: `line_ending` +- Default: `detect` + +**Options** + +1. To detect existing line endings and otherwise use the platform default (`lf` on Unix, `crlf` on Windows), set it to `detect`: + +```json [settings] +{ + "line_ending": "detect" +} +``` + +2. To prefer LF (`\n`) for new files and files with no existing line ending, use `prefer_lf`: + +```json [settings] +{ + "line_ending": "prefer_lf" +} +``` + +3. To prefer CRLF (`\r\n`) for new files and files with no existing line ending, use `prefer_crlf`: + +```json [settings] +{ + "line_ending": "prefer_crlf" +} +``` + +4. To enforce LF (`\n`) during format and save, use `enforce_lf`: + +```json [settings] +{ + "line_ending": "enforce_lf" +} +``` + +5. To enforce CRLF (`\r\n`) during format and save, use `enforce_crlf`: + +```json [settings] +{ + "line_ending": "enforce_crlf" +} +``` + +The [`.editorconfig`](https://editorconfig.org) `end_of_line` property overrides this setting and behaves like `enforce_lf` or `enforce_crlf`. + ## Expand Excerpt Lines - Description: The default number of lines to expand excerpts in the multibuffer by @@ -2772,6 +2840,7 @@ The following settings can be overridden for each specific language: - [`enable_language_server`](#enable-language-server) - [`ensure_final_newline_on_save`](#ensure-final-newline-on-save) +- [`line_ending`](#line-ending) - [`format_on_save`](#format-on-save) - [`formatter`](#formatter) - [`hard_tabs`](#hard-tabs) @@ -4659,7 +4728,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a ```json [settings] { "title_bar": { - "show_branch_icon": false, + "show_branch_status_icon": false, "show_branch_name": true, "show_project_items": true, "show_onboarding_banner": true, @@ -4674,7 +4743,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a **Options** -- `show_branch_icon`: Whether to show the branch icon beside branch switcher in the titlebar +- `show_branch_status_icon`: Whether to show git status indicators on the branch icon in the titlebar - `show_branch_name`: Whether to show the branch name button in the titlebar - `show_project_items`: Whether to show the project host and name in the titlebar - `show_onboarding_banner`: Whether to show onboarding banners in the titlebar diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 3bbef85e9760ad..8364b460378a28 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -96,6 +96,7 @@ These variables allow you to pull information from the current editor and use it - `ZED_SELECTED_TEXT`: currently selected text - `ZED_LANGUAGE`: language of the currently opened buffer (e.g. `Rust`, `Python`, `Shell Script`) - `ZED_WORKTREE_ROOT`: absolute path to the root of the current worktree. (e.g. `/Users/my-user/path/to/project`) +- `ZED_MAIN_GIT_WORKTREE`: absolute path to the main git worktree's working directory. For normal checkouts this equals `ZED_WORKTREE_ROOT`; for linked git worktrees this is the original repository's working directory. - `ZED_CUSTOM_RUST_PACKAGE`: (Rust-specific) name of the parent package of $ZED_FILE source file. To use a variable in a task, prefix it with a dollar sign (`$`): @@ -229,6 +230,31 @@ This could be useful for launching a terminal application that you want to use i } ``` +## Hooks + +In addition to being spawned manually, tasks can be configured to run automatically in response to certain Zed events by adding a hook to the `hooks` field on a task template. A task with a matching hook will be resolved and spawned when that event fires. + +The following hooks are currently supported: + +- `create_worktree` — runs after Zed creates a new linked Git worktree, either directly through the CLI or through the UI with the worktree modal. The task is spawned with `ZED_WORKTREE_ROOT` pointing at the newly created worktree and `ZED_MAIN_GIT_WORKTREE` pointing at the original repository's working directory, which makes these hooks well-suited to copying untracked files (such as `.env` files) or running per-worktree setup commands. + +Hook tasks are resolved from the same global and worktree-local `tasks.json` files as manually spawned tasks, and multiple tasks may register for the same hook; they all run when the hook fires. A hook task still benefits from the usual task configuration fields — `cwd`, `env`, `reveal`, `hide`, and so on — so you can control how much of the terminal UI is shown while it runs. + +```json [tasks] +[ + { + "label": "copy .env into new worktree", + "command": "cp", + "args": ["$ZED_MAIN_GIT_WORKTREE/.env", "$ZED_WORKTREE_ROOT/.env"], + "hooks": ["create_worktree"], + "reveal": "no_focus", + "hide": "on_success" + } +] +``` + +Tasks that define `hooks` are still available from the task modal like any other task, so the same template can be reused for manual runs. + ## VS Code Task Format When importing VS Code tasks from `.vscode/tasks.json`, you can omit the `label` field. Zed automatically generates labels based on the task type: diff --git a/docs/src/vim.md b/docs/src/vim.md index e60e084ac13cf9..e53e37fb31235c 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -354,14 +354,35 @@ These commands modify editor options locally for the current buffer. ### Command mnemonics -As any Zed command is available, you may find that it's helpful to remember mnemonics that run the correct command. For example: - -- `:diffs` for "toggle all hunk diffs" -- `:cpp` for "copy path to file" -- `:crp` for "copy relative path" -- `:reveal` for "reveal in finder" -- `:zlog` for "open zed log" -- `:clank` for "cancel language server work" +Zed does not ship with any command mnemonics by default, but you can define short aliases for Zed commands using the `command_aliases` setting in your settings file. When you type an alias from this map in the command palette, it resolves to the mapped command. + +#### Example Configuration + +To configure command mnemonics, add the `command_aliases` key to your settings file. Here's an example configuration with useful mnemonics: + +```json [settings] +{ + "command_aliases": { + "zlog": "zed::OpenLog", + "newf": "workspace::NewFile", + "diffs": "editor::ToggleSelectedDiffHunks", + "crp": "workspace::CopyRelativePath", + "cpp": "workspace::CopyPath", + "reveal": "editor::RevealInFileManager", + "clank": "editor::CancelLanguageServerWork" + } +} +``` + +With this configuration, you can use commands like: + +- `:zlog` - Open the Zed log +- `:newf` - Create a new file +- `:diffs` - Toggle selected diff hunks +- `:crp` - Copy the relative path to the current file +- `:cpp` - Copy the full path to the current file +- `:reveal` - Reveal the current file in the file manager +- `:clank` - Cancel language server work ## Customizing key bindings diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index cabfe01dc6822e..6140475eb71294 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -118,7 +118,7 @@ To disable this behavior use: ```json [settings] // Control which items are shown/hidden in the title bar "title_bar": { - "show_branch_icon": false, // Show/hide branch icon beside branch switcher + "show_branch_status_icon": false, // Show git status on branch icon "show_branch_name": true, // Show/hide branch name "show_project_items": true, // Show/hide project host and name "show_onboarding_banner": true, // Show/hide onboarding banners diff --git a/tooling/compliance/src/checks.rs b/tooling/compliance/src/checks.rs index cda37a84d2ae70..6d5116b4a8c526 100644 --- a/tooling/compliance/src/checks.rs +++ b/tooling/compliance/src/checks.rs @@ -1,11 +1,11 @@ -use std::{fmt, ops::Not as _}; +use std::{fmt, ops::Not as _, rc::Rc}; use itertools::Itertools as _; use crate::{ - git::{CommitDetails, CommitList}, + git::{CommitDetails, CommitList, ZED_ZIPPY_LOGIN}, github::{ - CommitAuthor, GithubClient, GithubLogin, PullRequestComment, PullRequestData, + CommitAuthor, GithubApiClient, GithubLogin, PullRequestComment, PullRequestData, PullRequestReview, Repository, ReviewState, }, report::Report, @@ -13,12 +13,14 @@ use crate::{ const ZED_ZIPPY_COMMENT_APPROVAL_PATTERN: &str = "@zed-zippy approve"; const ZED_ZIPPY_GROUP_APPROVAL: &str = "@zed-industries/approved"; +const EXPECTED_VERSION_BUMP_LOC: u64 = 2; #[derive(Debug)] pub enum ReviewSuccess { ApprovingComment(Vec), CoAuthored(Vec), PullRequestReviewed(Vec), + ZedZippyCommit(GithubLogin), } impl ReviewSuccess { @@ -34,6 +36,7 @@ impl ReviewSuccess { .iter() .map(|comment| format!("@{}", comment.user.login)) .collect_vec(), + Self::ZedZippyCommit(login) => vec![login.to_string()], }; let reviewers = reviewers.into_iter().unique().collect_vec(); @@ -56,6 +59,9 @@ impl fmt::Display for ReviewSuccess { Self::ApprovingComment(_) => { formatter.write_str("Approved by an organization approval comment") } + Self::ZedZippyCommit(_) => { + formatter.write_str("Fully untampered automated version bump commit") + } } } } @@ -65,6 +71,7 @@ pub enum ReviewFailure { // todo: We could still query the GitHub API here to search for one NoPullRequestFound, Unreviewed, + UnexpectedZippyAction(VersionBumpFailure), Other(anyhow::Error), } @@ -74,11 +81,50 @@ impl fmt::Display for ReviewFailure { Self::NoPullRequestFound => formatter.write_str("No pull request found"), Self::Unreviewed => formatter .write_str("No qualifying organization approval found for the pull request"), + Self::UnexpectedZippyAction(failure) => { + write!(formatter, "Validating Zed Zippy change failed: {failure}") + } Self::Other(error) => write!(formatter, "Failed to inspect review state: {error}"), } } } +#[derive(Debug)] +pub enum VersionBumpFailure { + NoMentionInTitle, + MissingCommitData, + AuthorMismatch, + UnexpectedCoAuthors, + NotSigned, + InvalidSignature, + UnexpectedLineChanges { additions: u64, deletions: u64 }, +} + +impl fmt::Display for VersionBumpFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoMentionInTitle => formatter.write_str("No @-mention found in commit title"), + Self::MissingCommitData => formatter.write_str("No commit data found on GitHub"), + Self::AuthorMismatch => { + formatter.write_str("GitHub author does not match bot identity") + } + Self::UnexpectedCoAuthors => formatter.write_str("Commit has unexpected co-authors"), + Self::NotSigned => formatter.write_str("Commit is not signed"), + Self::InvalidSignature => formatter.write_str("Commit signature is invalid"), + Self::UnexpectedLineChanges { + additions, + deletions, + } => { + write!( + formatter, + "Unexpected line changes ({additions} additions, {deletions} deletions, \ + expected {EXPECTED_VERSION_BUMP_LOC} each)" + ) + } + } + } +} + pub(crate) type ReviewResult = Result; impl> From for ReviewFailure { @@ -87,26 +133,39 @@ impl> From for ReviewFailure { } } -pub struct Reporter<'a> { +pub struct Reporter { commits: CommitList, - github_client: &'a GithubClient, + github_client: Rc, } -impl<'a> Reporter<'a> { - pub fn new(commits: CommitList, github_client: &'a GithubClient) -> Self { +impl Reporter { + pub fn new(commits: CommitList, github_client: Rc) -> Self { Self { commits, github_client, } } + pub async fn result_for_commit( + commit: CommitDetails, + github_client: Rc, + ) -> ReviewResult { + Self::new(Default::default(), github_client) + .check_commit(&commit) + .await + } + /// Method that checks every commit for compliance pub async fn check_commit( &self, commit: &CommitDetails, ) -> Result { let Some(pr_number) = commit.pr_number() else { - return Err(ReviewFailure::NoPullRequestFound); + if commit.author().is_zed_zippy() { + return self.check_zippy_version_bump(commit).await; + } else { + return Err(ReviewFailure::NoPullRequestFound); + } }; let pull_request = self @@ -135,6 +194,72 @@ impl<'a> Reporter<'a> { Err(ReviewFailure::Unreviewed) } + async fn check_zippy_version_bump( + &self, + commit: &CommitDetails, + ) -> Result { + let responsible_actor = + commit + .version_bump_mention() + .ok_or(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::NoMentionInTitle, + ))?; + + let commit_data = self + .github_client + .get_commit_metadata(&Repository::ZED, &[commit.sha()]) + .await?; + + let authors = commit_data + .get(commit.sha()) + .ok_or(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::MissingCommitData, + ))?; + + if !authors + .primary_author() + .user() + .is_some_and(|login| login.as_str() == ZED_ZIPPY_LOGIN) + { + return Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::AuthorMismatch, + )); + } + + if authors.co_authors().is_some() { + return Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::UnexpectedCoAuthors, + )); + } + + let signature = authors + .signature() + .ok_or(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::NotSigned, + ))?; + + if !signature.is_valid() { + return Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::InvalidSignature, + )); + } + + if authors.additions() != EXPECTED_VERSION_BUMP_LOC + || authors.deletions() != EXPECTED_VERSION_BUMP_LOC + { + return Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::UnexpectedLineChanges { + additions: authors.additions(), + deletions: authors.deletions(), + }, + )); + } + + Ok(ReviewSuccess::ZedZippyCommit(GithubLogin::new( + responsible_actor.to_owned(), + ))) + } + async fn check_commit_co_authors( &self, commit: &CommitDetails, @@ -142,7 +267,7 @@ impl<'a> Reporter<'a> { if commit.co_authors().is_some() && let Some(commit_authors) = self .github_client - .get_commit_authors(&Repository::ZED, &[commit.sha()]) + .get_commit_metadata(&Repository::ZED, &[commit.sha()]) .await? .get(commit.sha()) .and_then(|authors| authors.co_authors()) @@ -291,19 +416,19 @@ mod tests { use std::rc::Rc; use std::str::FromStr; - use crate::git::{CommitDetails, CommitList, CommitSha}; + use crate::git::{CommitDetails, CommitList, CommitSha, ZED_ZIPPY_EMAIL, ZED_ZIPPY_LOGIN}; use crate::github::{ - AuthorsForCommits, GithubApiClient, GithubClient, GithubLogin, GithubUser, - PullRequestComment, PullRequestData, PullRequestReview, Repository, ReviewState, + CommitMetadataBySha, GithubApiClient, GithubLogin, GithubUser, PullRequestComment, + PullRequestData, PullRequestReview, Repository, ReviewState, }; - use super::{Reporter, ReviewFailure, ReviewSuccess}; + use super::{Reporter, ReviewFailure, ReviewSuccess, VersionBumpFailure}; struct MockGithubApi { pull_request: PullRequestData, reviews: Vec, comments: Vec, - commit_authors_json: serde_json::Value, + commit_metadata_json: serde_json::Value, org_members: Vec, } @@ -333,12 +458,12 @@ mod tests { Ok(self.comments.clone()) } - async fn get_commit_authors( + async fn get_commit_metadata( &self, _repo: &Repository<'_>, _commit_shas: &[&CommitSha], - ) -> anyhow::Result { - serde_json::from_value(self.commit_authors_json.clone()).map_err(Into::into) + ) -> anyhow::Result { + serde_json::from_value(self.commit_metadata_json.clone()).map_err(Into::into) } async fn check_repo_write_permission( @@ -399,11 +524,43 @@ mod tests { } } + fn alice_author() -> serde_json::Value { + serde_json::json!({ + "name": "Alice", + "email": "alice@test.com", + "user": { "login": "alice" } + }) + } + + fn bob_author() -> serde_json::Value { + serde_json::json!({ + "name": "Bob", + "email": "bob@test.com", + "user": { "login": "bob" } + }) + } + + fn charlie_author() -> serde_json::Value { + serde_json::json!({ + "name": "Charlie", + "email": "charlie@test.com", + "user": { "login": "charlie" } + }) + } + + fn zippy_author() -> serde_json::Value { + serde_json::json!({ + "name": "Zed Zippy", + "email": ZED_ZIPPY_EMAIL, + "user": { "login": ZED_ZIPPY_LOGIN } + }) + } + struct TestScenario { pull_request: PullRequestData, reviews: Vec, comments: Vec, - commit_authors_json: serde_json::Value, + commit_metadata_json: serde_json::Value, org_members: Vec, commit: CommitDetails, } @@ -421,7 +578,7 @@ mod tests { }, reviews: vec![], comments: vec![], - commit_authors_json: serde_json::json!({}), + commit_metadata_json: serde_json::json!({}), org_members: vec![], commit: make_commit( "abc12345abc12345", @@ -448,8 +605,8 @@ mod tests { self } - fn with_commit_authors_json(mut self, json: serde_json::Value) -> Self { - self.commit_authors_json = json; + fn with_commit_metadata_json(mut self, json: serde_json::Value) -> Self { + self.commit_metadata_json = json; self } @@ -458,16 +615,49 @@ mod tests { self } + fn zippy_version_bump() -> Self { + Self { + pull_request: PullRequestData { + number: 0, + user: None, + merged_by: None, + labels: None, + }, + reviews: vec![], + comments: vec![], + commit_metadata_json: serde_json::json!({ + "abc12345abc12345": { + "author": zippy_author(), + "authors": { "nodes": [] }, + "signature": { + "isValid": true, + "signer": { "login": ZED_ZIPPY_LOGIN } + }, + "additions": 2, + "deletions": 2 + } + }), + org_members: vec![], + commit: make_commit( + "abc12345abc12345", + "Zed Zippy", + ZED_ZIPPY_EMAIL, + "Bump to 0.230.2 for @cole-miller", + "", + ), + } + } + async fn run_scenario(self) -> Result { let mock = MockGithubApi { pull_request: self.pull_request, reviews: self.reviews, comments: self.comments, - commit_authors_json: self.commit_authors_json, + commit_metadata_json: self.commit_metadata_json, org_members: self.org_members, }; - let client = GithubClient::new(Rc::new(mock)); - let reporter = Reporter::new(CommitList::default(), &client); + let client = Rc::new(mock); + let reporter = Reporter::new(CommitList::default(), client); reporter.check_commit(&self.commit).await } } @@ -581,18 +771,10 @@ mod tests { async fn comment_takes_precedence_over_co_author() { let result = TestScenario::single_commit() .with_comments(vec![comment("bob", "@zed-zippy approve")]) - .with_commit_authors_json(serde_json::json!({ + .with_commit_metadata_json(serde_json::json!({ "abc12345abc12345": { - "author": { - "name": "Alice", - "email": "alice@test.com", - "user": { "login": "alice" } - }, - "authors": { "nodes": [{ - "name": "Charlie", - "email": "charlie@test.com", - "user": { "login": "charlie" } - }] } + "author": alice_author(), + "authors": { "nodes": [charlie_author()] } } })) .with_commit(make_commit( @@ -611,18 +793,10 @@ mod tests { #[tokio::test] async fn co_author_org_member_succeeds() { let result = TestScenario::single_commit() - .with_commit_authors_json(serde_json::json!({ + .with_commit_metadata_json(serde_json::json!({ "abc12345abc12345": { - "author": { - "name": "Alice", - "email": "alice@test.com", - "user": { "login": "alice" } - }, - "authors": { "nodes": [{ - "name": "Bob", - "email": "bob@test.com", - "user": { "login": "bob" } - }] } + "author": alice_author(), + "authors": { "nodes": [bob_author()] } } })) .with_commit(make_commit( @@ -702,4 +876,186 @@ mod tests { .await; assert!(matches!(result, Err(ReviewFailure::Unreviewed))); } + + #[tokio::test] + async fn zippy_version_bump_with_valid_signature_succeeds() { + let result = TestScenario::zippy_version_bump().run_scenario().await; + assert!(matches!(result, Ok(ReviewSuccess::ZedZippyCommit(_)))); + if let Ok(ReviewSuccess::ZedZippyCommit(login)) = &result { + assert_eq!(login.as_str(), "cole-miller"); + } + } + + #[tokio::test] + async fn zippy_version_bump_without_mention_fails() { + let result = TestScenario::zippy_version_bump() + .with_commit(make_commit( + "abc12345abc12345", + "Zed Zippy", + ZED_ZIPPY_EMAIL, + "Bump to 0.230.2", + "", + )) + .run_scenario() + .await; + assert!(matches!( + result, + Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::NoMentionInTitle + )) + )); + } + + #[tokio::test] + async fn zippy_version_bump_without_signature_fails() { + let result = TestScenario::zippy_version_bump() + .with_commit_metadata_json(serde_json::json!({ + "abc12345abc12345": { + "author": zippy_author(), + "authors": { "nodes": [] }, + "additions": 2, + "deletions": 2 + } + })) + .run_scenario() + .await; + assert!(matches!( + result, + Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::NotSigned + )) + )); + } + + #[tokio::test] + async fn zippy_version_bump_with_invalid_signature_fails() { + let result = TestScenario::zippy_version_bump() + .with_commit_metadata_json(serde_json::json!({ + "abc12345abc12345": { + "author": zippy_author(), + "authors": { "nodes": [] }, + "signature": { + "isValid": false, + "signer": { "login": ZED_ZIPPY_LOGIN } + }, + "additions": 2, + "deletions": 2 + } + })) + .run_scenario() + .await; + assert!(matches!( + result, + Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::InvalidSignature + )) + )); + } + + #[tokio::test] + async fn zippy_version_bump_with_unequal_line_changes_fails() { + let result = TestScenario::zippy_version_bump() + .with_commit_metadata_json(serde_json::json!({ + "abc12345abc12345": { + "author": zippy_author(), + "authors": { "nodes": [] }, + "signature": { + "isValid": true, + "signer": { "login": ZED_ZIPPY_LOGIN } + }, + "additions": 5, + "deletions": 2 + } + })) + .run_scenario() + .await; + assert!(matches!( + result, + Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::UnexpectedLineChanges { .. } + )) + )); + } + + #[tokio::test] + async fn zippy_version_bump_with_wrong_github_author_fails() { + let result = TestScenario::zippy_version_bump() + .with_commit_metadata_json(serde_json::json!({ + "abc12345abc12345": { + "author": alice_author(), + "authors": { "nodes": [] }, + "signature": { + "isValid": true, + "signer": { "login": "alice" } + }, + "additions": 2, + "deletions": 2 + } + })) + .run_scenario() + .await; + assert!(matches!( + result, + Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::AuthorMismatch + )) + )); + } + + #[tokio::test] + async fn zippy_version_bump_with_co_authors_fails() { + let result = TestScenario::zippy_version_bump() + .with_commit_metadata_json(serde_json::json!({ + "abc12345abc12345": { + "author": zippy_author(), + "authors": { "nodes": [alice_author()] }, + "signature": { + "isValid": true, + "signer": { "login": ZED_ZIPPY_LOGIN } + }, + "additions": 2, + "deletions": 2 + } + })) + .run_scenario() + .await; + assert!(matches!( + result, + Err(ReviewFailure::UnexpectedZippyAction( + VersionBumpFailure::UnexpectedCoAuthors + )) + )); + } + + #[tokio::test] + async fn non_zippy_commit_without_pr_is_no_pr_found() { + let result = TestScenario::single_commit() + .with_commit(make_commit( + "abc12345abc12345", + "Alice", + "alice@test.com", + "Some direct push", + "", + )) + .run_scenario() + .await; + assert!(matches!(result, Err(ReviewFailure::NoPullRequestFound))); + } + + #[tokio::test] + async fn zippy_commit_with_pr_number_goes_through_normal_flow() { + let result = TestScenario::single_commit() + .with_commit(make_commit( + "abc12345abc12345", + "Zed Zippy", + ZED_ZIPPY_EMAIL, + "Some change (#1234)", + "", + )) + .with_reviews(vec![review("bob", ReviewState::Approved)]) + .with_org_members(vec!["bob"]) + .run_scenario() + .await; + assert!(matches!(result, Ok(ReviewSuccess::PullRequestReviewed(_)))); + } } diff --git a/tooling/compliance/src/git.rs b/tooling/compliance/src/git.rs index e2581b1c7fa79a..08adcb4b1376b0 100644 --- a/tooling/compliance/src/git.rs +++ b/tooling/compliance/src/git.rs @@ -15,6 +15,9 @@ use regex::Regex; use semver::Version; use serde::Deserialize; +pub(crate) const ZED_ZIPPY_LOGIN: &str = "zed-zippy[bot]"; +pub(crate) const ZED_ZIPPY_EMAIL: &str = "234243425+zed-zippy[bot]@users.noreply.github.com"; + pub trait Subcommand { type ParsedOutput: FromStr; @@ -138,6 +141,18 @@ impl CommitDetails { } } +impl FromStr for CommitDetails { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + CommitList::from_str(s).and_then(|list| { + list.into_iter() + .next() + .ok_or_else(|| anyhow!("No commit found")) + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Committer { name: String, @@ -151,6 +166,10 @@ impl Committer { email: email.to_owned(), } } + + pub(crate) fn is_zed_zippy(&self) -> bool { + self.email == ZED_ZIPPY_EMAIL + } } impl fmt::Display for Committer { @@ -226,6 +245,17 @@ impl CommitDetails { pub(crate) fn sha(&self) -> &CommitSha { &self.sha } + + pub(crate) fn version_bump_mention(&self) -> Option<&str> { + static VERSION_BUMP_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"^Bump to [0-9]+\.[0-9]+\.[0-9]+ for @([a-zA-Z0-9][a-zA-Z0-9-]*)$").unwrap() + }); + + VERSION_BUMP_REGEX + .captures(&self.title) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str()) + } } #[derive(Debug, Deref, Default, DerefMut)] @@ -318,6 +348,30 @@ impl FromStr for VersionTagList { } } +pub struct InfoForCommit { + sha: String, +} + +impl InfoForCommit { + pub fn new(sha: impl ToString) -> Self { + Self { + sha: sha.to_string(), + } + } +} + +impl Subcommand for InfoForCommit { + type ParsedOutput = CommitDetails; + + fn args(&self) -> impl IntoIterator { + [ + "log".to_string(), + format!("--pretty=format:{}", CommitDetails::FORMAT_STRING), + format!("{sha}~1..{sha}", sha = self.sha), + ] + } +} + pub struct CommitsFromVersionToVersion { version_tag: VersionTag, branch: String, @@ -575,6 +629,68 @@ mod tests { assert_eq!(sha.short(), "abcdef12"); } + #[test] + fn version_bump_mention_extracts_username() { + let line = format!( + "abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller", + d = CommitDetails::FIELD_DELIMITER + ); + let commit = CommitDetails::parse(&line, "").unwrap(); + assert_eq!(commit.version_bump_mention(), Some("cole-miller")); + } + + #[test] + fn version_bump_mention_returns_none_without_mention() { + let line = format!( + "abc123{d}Alice{d}alice@test.com{d}Fix a bug", + d = CommitDetails::FIELD_DELIMITER + ); + let commit = CommitDetails::parse(&line, "").unwrap(); + assert!(commit.version_bump_mention().is_none()); + } + + #[test] + fn version_bump_mention_rejects_wrong_prefix() { + let line = format!( + "abc123{d}Zed Zippy{d}bot@test.com{d}Fix thing for @cole-miller", + d = CommitDetails::FIELD_DELIMITER + ); + let commit = CommitDetails::parse(&line, "").unwrap(); + assert!(commit.version_bump_mention().is_none()); + } + + #[test] + fn version_bump_mention_rejects_bare_mention() { + let line = format!( + "abc123{d}Zed Zippy{d}bot@test.com{d}@cole-miller bumped something", + d = CommitDetails::FIELD_DELIMITER + ); + let commit = CommitDetails::parse(&line, "").unwrap(); + assert!(commit.version_bump_mention().is_none()); + } + + #[test] + fn version_bump_mention_rejects_trailing_text() { + let line = format!( + "abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller extra", + d = CommitDetails::FIELD_DELIMITER + ); + let commit = CommitDetails::parse(&line, "").unwrap(); + assert!(commit.version_bump_mention().is_none()); + } + + #[test] + fn committer_is_zed_zippy() { + let committer = Committer::new("Zed Zippy", ZED_ZIPPY_EMAIL); + assert!(committer.is_zed_zippy()); + } + + #[test] + fn committer_is_not_zed_zippy() { + let committer = Committer::new("Alice", "alice@test.com"); + assert!(!committer.is_zed_zippy()); + } + #[test] fn parse_commit_list_from_git_log_format() { let fd = CommitDetails::FIELD_DELIMITER; diff --git a/tooling/compliance/src/github.rs b/tooling/compliance/src/github.rs index 29e22c92fc831a..d6ebcc227d758c 100644 --- a/tooling/compliance/src/github.rs +++ b/tooling/compliance/src/github.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, collections::HashMap, fmt, ops::Not, rc::Rc}; +use std::{borrow::Cow, collections::HashMap, fmt}; use anyhow::Result; use derive_more::Deref; @@ -97,42 +97,83 @@ impl fmt::Display for CommitAuthor { } } +#[derive(Debug, Deserialize, Clone)] +pub struct CommitSignature { + #[serde(rename = "isValid")] + is_valid: bool, + signer: Option, +} + +impl CommitSignature { + pub fn is_valid(&self) -> bool { + self.is_valid + } + + pub fn signer(&self) -> Option<&GithubLogin> { + self.signer.as_ref() + } +} + #[derive(Debug, Deserialize)] -pub struct CommitAuthors { +pub struct CommitMetadata { #[serde(rename = "author")] primary_author: CommitAuthor, #[serde(rename = "authors", deserialize_with = "graph_ql::deserialize_nodes")] co_authors: Vec, + #[serde(default)] + signature: Option, + #[serde(default)] + additions: u64, + #[serde(default)] + deletions: u64, } -impl CommitAuthors { +impl CommitMetadata { pub fn co_authors(&self) -> Option> { - self.co_authors.is_empty().not().then(|| { - self.co_authors - .iter() - .filter(|co_author| *co_author != &self.primary_author) - }) + let mut co_authors = self + .co_authors + .iter() + .filter(|co_author| *co_author != &self.primary_author) + .peekable(); + + co_authors.peek().is_some().then_some(co_authors) + } + + pub fn primary_author(&self) -> &CommitAuthor { + &self.primary_author + } + + pub fn signature(&self) -> Option<&CommitSignature> { + self.signature.as_ref() + } + + pub fn additions(&self) -> u64 { + self.additions + } + + pub fn deletions(&self) -> u64 { + self.deletions } } #[derive(Debug, Deref)] -pub struct AuthorsForCommits(HashMap); +pub struct CommitMetadataBySha(HashMap); -impl AuthorsForCommits { +impl CommitMetadataBySha { const SHA_PREFIX: &'static str = "commit"; } -impl<'de> serde::Deserialize<'de> for AuthorsForCommits { +impl<'de> serde::Deserialize<'de> for CommitMetadataBySha { fn deserialize(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, { - let raw = HashMap::::deserialize(deserializer)?; + let raw = HashMap::::deserialize(deserializer)?; let map = raw .into_iter() .map(|(key, value)| { let sha = key - .strip_prefix(AuthorsForCommits::SHA_PREFIX) + .strip_prefix(CommitMetadataBySha::SHA_PREFIX) .unwrap_or(&key); (CommitSha::new(sha.to_owned()), value) }) @@ -192,11 +233,11 @@ pub trait GithubApiClient { repo: &Repository<'_>, pr_number: u64, ) -> Result>; - async fn get_commit_authors( + async fn get_commit_metadata( &self, repo: &Repository<'_>, commit_shas: &[&CommitSha], - ) -> Result; + ) -> Result; async fn check_repo_write_permission( &self, repo: &Repository<'_>, @@ -210,23 +251,6 @@ pub trait GithubApiClient { ) -> Result<()>; } -#[derive(Deref)] -pub struct GithubClient { - api: Rc, -} - -impl GithubClient { - pub fn new(api: Rc) -> Self { - Self { api } - } - - #[cfg(feature = "octo-client")] - pub async fn for_app_in_repo(app_id: u64, app_private_key: &str, org: &str) -> Result { - let client = OctocrabClient::new(app_id, app_private_key, org).await?; - Ok(Self::new(Rc::new(client))) - } -} - pub mod graph_ql { use anyhow::{Context as _, Result}; use itertools::Itertools as _; @@ -234,7 +258,7 @@ pub mod graph_ql { use crate::git::CommitSha; - use super::AuthorsForCommits; + use super::CommitMetadataBySha; #[derive(Debug, Deserialize)] pub struct GraphQLResponse { @@ -261,8 +285,8 @@ pub mod graph_ql { } #[derive(Debug, Deserialize)] - pub struct CommitAuthorsResponse { - pub repository: AuthorsForCommits, + pub struct CommitMetadataResponse { + pub repository: CommitMetadataBySha, } pub fn deserialize_nodes<'de, T, D>(deserializer: D) -> std::result::Result, D::Error> @@ -277,7 +301,7 @@ pub mod graph_ql { Nodes::::deserialize(deserializer).map(|wrapper| wrapper.nodes) } - pub fn build_co_authors_query<'a>( + pub fn build_commit_metadata_query<'a>( org: &str, repo: &str, shas: impl IntoIterator, @@ -296,6 +320,12 @@ pub mod graph_ql { user { login } } } + signature { + isValid + signer { login } + } + additions + deletions } "#; @@ -304,7 +334,7 @@ pub mod graph_ql { .map(|commit_sha| { format!( "{sha_prefix}{sha}: object(oid: \"{sha}\") {{ {FRAGMENT} }}", - sha_prefix = AuthorsForCommits::SHA_PREFIX, + sha_prefix = CommitMetadataBySha::SHA_PREFIX, sha = **commit_sha, ) }) @@ -333,7 +363,7 @@ mod octo_client { }; use super::{ - AuthorsForCommits, GithubApiClient, GithubLogin, GithubUser, PullRequestComment, + CommitMetadataBySha, GithubApiClient, GithubLogin, GithubUser, PullRequestComment, PullRequestData, PullRequestReview, ReviewState, }; @@ -481,18 +511,18 @@ mod octo_client { .collect()) } - async fn get_commit_authors( + async fn get_commit_metadata( &self, repo: &Repository<'_>, commit_shas: &[&CommitSha], - ) -> Result { - let query = graph_ql::build_co_authors_query( + ) -> Result { + let query = graph_ql::build_commit_metadata_query( repo.owner.as_ref(), repo.name.as_ref(), commit_shas.iter().copied(), ); let query = serde_json::json!({ "query": query }); - self.graphql::(&query) + self.graphql::(&query) .await .map(|response| response.repository) } diff --git a/tooling/compliance/src/report.rs b/tooling/compliance/src/report.rs index 1f99a2f061da8d..146ab0d3b0024d 100644 --- a/tooling/compliance/src/report.rs +++ b/tooling/compliance/src/report.rs @@ -68,7 +68,8 @@ impl ReportEntry { #[derive(Debug, Default)] pub struct ReportSummary { pub pull_requests: usize, - pub reviewed: usize, + pub reviewed_prs: usize, + pub other_checked: usize, pub not_reviewed: usize, pub errors: usize, } @@ -87,13 +88,22 @@ impl ReportSummary { .filter_map(|entry| entry.commit.pr_number()) .unique() .count(), - reviewed: entries.iter().filter(|entry| entry.reason.is_ok()).count(), + reviewed_prs: entries + .iter() + .filter(|entry| entry.reason.is_ok() && entry.commit.pr_number().is_some()) + .count(), + other_checked: entries + .iter() + .filter(|entry| entry.reason.is_ok() && entry.commit.pr_number().is_none()) + .count(), not_reviewed: entries .iter() .filter(|entry| { matches!( entry.reason, - Err(ReviewFailure::NoPullRequestFound | ReviewFailure::Unreviewed) + Err(ReviewFailure::NoPullRequestFound + | ReviewFailure::Unreviewed + | ReviewFailure::UnexpectedZippyAction(_)) ) }) .count(), @@ -117,7 +127,7 @@ impl ReportSummary { } pub fn prs_with_errors(&self) -> usize { - self.pull_requests - self.reviewed + self.pull_requests.saturating_sub(self.reviewed_prs) } } @@ -195,8 +205,13 @@ impl Report { writeln!(writer, "## Overview")?; writeln!(writer)?; writeln!(writer, "- PRs: {}", summary.pull_requests)?; - writeln!(writer, "- Reviewed: {}", summary.reviewed)?; + writeln!(writer, "- Reviewed: {}", summary.reviewed_prs)?; writeln!(writer, "- Not reviewed: {}", summary.not_reviewed)?; + writeln!( + writer, + "- Differently validated commits: {}", + summary.other_checked + )?; if summary.has_errors() { writeln!(writer, "- Errors: {}", summary.errors)?; } @@ -306,7 +321,7 @@ mod tests { use crate::{ checks::{ReviewFailure, ReviewSuccess}, git::{CommitDetails, CommitList}, - github::{GithubUser, PullRequestReview, ReviewState}, + github::{GithubLogin, GithubUser, PullRequestReview, ReviewState}, }; use super::{Report, ReportReviewSummary}; @@ -364,10 +379,17 @@ mod tests { make_commit("ddd", "Dave", "dave@test.com", "Error commit (#300)", ""), Err(ReviewFailure::Other(anyhow::anyhow!("some error"))), ); + report.add( + make_commit("ddd", "Dave", "dave@test.com", "Bump Version", ""), + Ok(ReviewSuccess::ZedZippyCommit(GithubLogin::new( + "dave".to_string(), + ))), + ); let summary = report.summary(); assert_eq!(summary.pull_requests, 3); - assert_eq!(summary.reviewed, 1); + assert_eq!(summary.reviewed_prs, 1); + assert_eq!(summary.other_checked, 1); assert_eq!(summary.not_reviewed, 2); assert_eq!(summary.errors, 1); } diff --git a/tooling/xtask/src/tasks/compliance.rs b/tooling/xtask/src/tasks/compliance.rs index fbe06383cf5115..46c40fb82c581c 100644 --- a/tooling/xtask/src/tasks/compliance.rs +++ b/tooling/xtask/src/tasks/compliance.rs @@ -1,20 +1,37 @@ -use std::path::PathBuf; +use std::{path::PathBuf, rc::Rc}; use anyhow::{Context, Result}; -use clap::Parser; +use clap::{Parser, Subcommand}; use compliance::{ checks::Reporter, - git::{CommitsFromVersionToVersion, GetVersionTags, GitCommand, VersionTag}, - github::{GithubClient, Repository}, + git::{CommitsFromVersionToVersion, GetVersionTags, GitCommand, InfoForCommit, VersionTag}, + github::{GithubApiClient as _, OctocrabClient, Repository}, report::ReportReviewSummary, }; #[derive(Parser)] -pub struct ComplianceArgs { +pub(crate) struct ComplianceArgs { + #[clap(subcommand)] + mode: ComplianceMode, +} + +#[derive(Subcommand)] +pub(crate) enum ComplianceMode { + // Check compliance for all commits between two version tags + Version(VersionArgs), + // Check compliance for a single commit + Single { + // The full commit SHA to check + commit_sha: String, + }, +} + +#[derive(Parser)] +pub(crate) struct VersionArgs { #[arg(value_parser = VersionTag::parse)] // The version to be on the lookout for - pub(crate) version_tag: VersionTag, + version_tag: VersionTag, #[arg(long)] // The markdown file to write the compliance report to report_path: PathBuf, @@ -23,7 +40,7 @@ pub struct ComplianceArgs { branch: Option, } -impl ComplianceArgs { +impl VersionArgs { pub(crate) fn version_tag(&self) -> &VersionTag { &self.version_tag } @@ -39,6 +56,35 @@ async fn check_compliance_impl(args: ComplianceArgs) -> Result<()> { let app_id = std::env::var("GITHUB_APP_ID").context("Missing GITHUB_APP_ID")?; let key = std::env::var("GITHUB_APP_KEY").context("Missing GITHUB_APP_KEY")?; + let client = Rc::new( + OctocrabClient::new( + app_id.parse().context("Failed to parse app ID as int")?, + key.as_ref(), + Repository::ZED.owner(), + ) + .await?, + ); + + println!("Initialized GitHub client for app ID {app_id}"); + + let args = match args.mode { + ComplianceMode::Version(version) => version, + ComplianceMode::Single { commit_sha } => { + let commit = GitCommand::run(InfoForCommit::new(&commit_sha))?; + + return match Reporter::result_for_commit(commit, client).await { + Ok(review_success) => { + println!("Check for commit {commit_sha} succeeded. Result: {review_success}",); + Ok(()) + } + + Err(review_failure) => Err(anyhow::anyhow!( + "Check for commit {commit_sha} failed. Result: {review_failure}" + )), + }; + } + }; + let tag = args.version_tag(); let previous_version = GitCommand::run(GetVersionTags)? @@ -69,16 +115,9 @@ async fn check_compliance_impl(args: ComplianceArgs) -> Result<()> { println!("Checking commit range {range}, {} total", commits.len()); - let client = GithubClient::for_app_in_repo( - app_id.parse().context("Failed to parse app ID as int")?, - key.as_ref(), - Repository::ZED.owner(), - ) - .await?; - - println!("Initialized GitHub client for app ID {app_id}"); - - let report = Reporter::new(commits, &client).generate_report().await?; + let report = Reporter::new(commits, client.clone()) + .generate_report() + .await?; println!( "Generated report for version {}", diff --git a/tooling/xtask/src/tasks/workflows/bump_patch_version.rs b/tooling/xtask/src/tasks/workflows/bump_patch_version.rs index 7db348c1d5980c..bf1df69bcf3447 100644 --- a/tooling/xtask/src/tasks/workflows/bump_patch_version.rs +++ b/tooling/xtask/src/tasks/workflows/bump_patch_version.rs @@ -28,7 +28,7 @@ fn run_bump_patch_version(branch: &WorkflowInput) -> steps::NamedJob { .with_ref(branch.to_string()) } - fn bump_patch_version(token: &StepOutput) -> Step { + fn bump_version() -> Step { named::bash(indoc::indoc! {r#" channel="$(cat crates/zed/RELEASE_CHANNEL)" @@ -45,25 +45,67 @@ fn run_bump_patch_version(branch: &WorkflowInput) -> steps::NamedJob { ;; esac which cargo-set-version > /dev/null || cargo install cargo-edit -f --no-default-features --features "set-version" - output="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')" - git commit -am "Bump to $output for @$GITHUB_ACTOR" - git tag "v${output}${tag_suffix}" - git push origin HEAD "v${output}${tag_suffix}" + version="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag_suffix=$tag_suffix" >> "$GITHUB_OUTPUT" "#}) - .add_env(("GIT_COMMITTER_NAME", "Zed Zippy")) - .add_env(( - "GIT_COMMITTER_EMAIL", - "234243425+zed-zippy[bot]@users.noreply.github.com", - )) - .add_env(("GIT_AUTHOR_NAME", "Zed Zippy")) - .add_env(( - "GIT_AUTHOR_EMAIL", - "234243425+zed-zippy[bot]@users.noreply.github.com", + .id("bump-version") + } + + fn commit_changes( + version: &StepOutput, + token: &StepOutput, + branch: &WorkflowInput, + ) -> Step { + named::uses( + "IAreKyleW00t", + "verified-bot-commit", + "126a6a11889ab05bcff72ec2403c326cd249b84c", // v2.3.0 + ) + .id("commit") + .add_with(( + "message", + format!("Bump to {version} for @${{{{ github.actor }}}}"), )) - .add_env(("GITHUB_TOKEN", token)) + .add_with(("ref", format!("refs/heads/{branch}"))) + .add_with(("files", "**")) + .add_with(("token", token.to_string())) + } + + fn create_version_tag( + version: &StepOutput, + tag_suffix: &StepOutput, + commit_sha: &StepOutput, + token: &StepOutput, + ) -> Step { + named::uses( + "actions", + "github-script", + "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7 + ) + .with( + Input::default() + .add( + "script", + indoc::formatdoc! {r#" + github.rest.git.createRef({{ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'refs/tags/v{version}{tag_suffix}', + sha: '{commit_sha}' + }}) + "#}, + ) + .add("github-token", token.to_string()), + ) } let (authenticate, token) = steps::authenticate_as_zippy().into(); + let bump_version_step = bump_version(); + let version = StepOutput::new(&bump_version_step, "version"); + let tag_suffix = StepOutput::new(&bump_version_step, "tag_suffix"); + let commit_step = commit_changes(&version, &token, branch); + let commit_sha = StepOutput::new_unchecked(&commit_step, "commit"); named::job( Job::default() @@ -73,6 +115,13 @@ fn run_bump_patch_version(branch: &WorkflowInput) -> steps::NamedJob { .runs_on(runners::LINUX_XL) .add_step(authenticate) .add_step(checkout_branch(branch, &token)) - .add_step(bump_patch_version(&token)), + .add_step(bump_version_step) + .add_step(commit_step) + .add_step(create_version_tag( + &version, + &tag_suffix, + &commit_sha, + &token, + )), ) } diff --git a/tooling/xtask/src/tasks/workflows/release.rs b/tooling/xtask/src/tasks/workflows/release.rs index e3e0fb78a86208..76895a37e3da84 100644 --- a/tooling/xtask/src/tasks/workflows/release.rs +++ b/tooling/xtask/src/tasks/workflows/release.rs @@ -185,7 +185,7 @@ pub(crate) fn add_compliance_steps( fn run_compliance_check(context: &ComplianceContext) -> (Step, StepOutput) { let job = named::bash( formatdoc! {r#" - cargo xtask compliance {target} --report-path "{COMPLIANCE_REPORT_PATH}" + cargo xtask compliance version {target} --report-path "{COMPLIANCE_REPORT_PATH}" "#, target = if context.tag_source().is_some() { r#""$LATEST_TAG" --branch main"# } else { r#""$GITHUB_REF_NAME""# }, } diff --git a/typos.toml b/typos.toml index f647c5ac91e1d5..22823e6b2d90d9 100644 --- a/typos.toml +++ b/typos.toml @@ -24,8 +24,9 @@ extend-exclude = [ "crates/livekit_api/", # Vim makes heavy use of partial typing tables. "crates/vim/", - # Editor and file finder rely on partial typing and custom in-string syntax. + # Editor, file finder, and fuzzy matching rely on partial typing and custom in-string syntax. "crates/file_finder/src/file_finder_tests.rs", + "crates/fuzzy_nucleo/src/strings.rs", "crates/editor/src/editor_tests.rs", "crates/editor/src/edit_prediction_tests.rs", # There are some names in the test data that are incorrectly flagged as typos. @@ -63,6 +64,7 @@ extend-exclude = [ "crates/gpui_macos/src/dispatcher.rs", # Tests contain partially incomplete words (by design) "crates/edit_prediction_cli/src/split_commit.rs", + "crates/edit_prediction_metrics/src/kept_rate.rs", # Eval examples contain intentionally partial words (e.g. "secur" for "secure") "crates/edit_prediction_cli/evals/", # Tests contain `baˇr` that cause `"ba" should be "by" or "be".`-like false-positives