diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml new file mode 100644 index 0000000000000000000000000000000000000000..308849ccbeed0be7f9ab5c8f7e5846ed61a8724d --- /dev/null +++ b/.github/workflows/autofix_pr.yml @@ -0,0 +1,83 @@ +# Generated from xtask::workflows::autofix_pr +# Rebuild with `cargo xtask workflows`. +name: autofix_pr +run-name: 'autofix PR #${{ inputs.pr_number }}' +on: + workflow_dispatch: + inputs: + pr_number: + description: pr_number + required: true + type: string +jobs: + run_autofix: + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - id: get-app-token + name: autofix_pr::run_autofix::authenticate_as_zippy + uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + - name: steps::checkout_repo_with_token + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + token: ${{ steps.get-app-token.outputs.token }} + - name: autofix_pr::run_autofix::checkout_pr + run: gh pr checkout ${{ inputs.pr_number }} + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::setup_pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + with: + version: '9' + - name: autofix_pr::run_autofix::run_prettier_fix + run: ./script/prettier --write + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_cargo_fmt + run: cargo fmt --all + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_clippy_fix + run: cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::commit_and_push + run: | + if git diff --quiet; then + echo "No changes to commit" + else + git add -A + git commit -m "Autofix" + git push + fi + shell: bash -euxo pipefail {0} + 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.get-app-token.outputs.token }} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index c7582378f1c9e87254e1a0b4e202d9f56b99877b..31676e5c914719a34f8b2e61193475ed107cd2db 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -113,6 +113,7 @@ jobs: delete-branch: true token: ${{ steps.generate-token.outputs.token }} sign-commits: true + assignees: ${{ github.actor }} timeout-minutes: 1 create_version_label: needs: diff --git a/.gitignore b/.gitignore index ccf4f471d5a7b70be0dc8d619ac64050dd6681ec..54faaf1374299ee8f97925a95a93b375c349d707 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .DS_Store .blob_store .build +.claude/settings.local.json .envrc .flatpak-builder .idea @@ -41,4 +42,4 @@ xcuserdata/ .env.secret.toml # `nix build` output -/result +/result diff --git a/.rules b/.rules index 82d15eb9e88299ee7c7fe6c717b2da2646e676a7..7c98c65d7e0eaf3ed0d57898dbd8acee28a220ae 100644 --- a/.rules +++ b/.rules @@ -26,6 +26,12 @@ }); ``` +# Timers in tests + +* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`: + - Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher. + - Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping. + # GPUI GPUI is a UI framework which also provides primitives for state and concurrency management. diff --git a/Cargo.lock b/Cargo.lock index 4bf6236499c6685a501306ba7980e32da37130bb..12090c7da99d5678e43c2ec00fc5e762622deffc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,7 @@ dependencies = [ "terminal", "ui", "url", + "urlencoding", "util", "uuid", "watch", @@ -3672,6 +3673,7 @@ dependencies = [ "task", "theme", "ui", + "url", "util", "workspace", "zlog", @@ -7086,6 +7088,7 @@ dependencies = [ "picker", "pretty_assertions", "project", + "prompt_store", "rand 0.9.2", "recent_projects", "remote", @@ -10852,7 +10855,6 @@ dependencies = [ "documented", "fs", "fuzzy", - "git", "gpui", "menu", "notifications", @@ -20107,11 +20109,13 @@ dependencies = [ "feature_flags", "fs", "futures 0.3.31", + "git", "gpui", "http_client", "itertools 0.14.0", "language", "log", + "markdown", "menu", "node_runtime", "parking_lot", diff --git a/Cargo.toml b/Cargo.toml index f3a5fefc7168c5296d032ae89ec5817673d9c333..903d17fc3378519d3e632f63c1a1a0e08e6513cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -857,8 +857,6 @@ unexpected_cfgs = { level = "allow" } dbg_macro = "deny" todo = "deny" -# This is not a style lint, see https://github.com/rust-lang/rust-clippy/pull/15454 -# Remove when the lint gets promoted to `suspicious`. declare_interior_mutable_const = "deny" redundant_clone = "deny" diff --git a/README.md b/README.md index d1e2a75beccc9b115bd3b2e09bcc812aebc98329..d3a5fd20526e5eae6826241dce2bb94e8533ecb3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Welcome to Zed, a high-performance, multiplayer code editor from the creators of ### Installation -On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or [install Zed via your local package manager](https://zed.dev/docs/linux#installing-via-a-package-manager). +On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or install Zed via your local package manager ([macOS](https://zed.dev/docs/installation#macos)/[Linux](https://zed.dev/docs/linux#installing-via-a-package-manager)/[Windows](https://zed.dev/docs/windows#package-managers)). Other platforms are not yet available: diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 342c4b0b7cb9608c13bed2899dd67b3ac0378db5..38ef7d092d534163ead569c522227b089f84af99 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -262,9 +262,9 @@ { "context": "AgentPanel > Markdown", "bindings": { - "copy": "markdown::CopyAsMarkdown", - "ctrl-insert": "markdown::CopyAsMarkdown", - "ctrl-c": "markdown::CopyAsMarkdown", + "copy": "markdown::Copy", + "ctrl-insert": "markdown::Copy", + "ctrl-c": "markdown::Copy", }, }, { @@ -746,7 +746,8 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction", + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", }, }, { @@ -754,7 +755,8 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction", + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", }, }, { @@ -1261,6 +1263,11 @@ "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + "ctrl-1": ["welcome::OpenRecentProject", 0], + "ctrl-2": ["welcome::OpenRecentProject", 1], + "ctrl-3": ["welcome::OpenRecentProject", 2], + "ctrl-4": ["welcome::OpenRecentProject", 3], + "ctrl-5": ["welcome::OpenRecentProject", 4], }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 50fc0c7222b76c9e5218c47a481442534debe2b0..8a0e3dfdcddbd448e6a6b9bf66f3731153208120 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -303,7 +303,7 @@ "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::CopyAsMarkdown", + "cmd-c": "markdown::Copy", }, }, { @@ -810,7 +810,8 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction", + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", }, }, { @@ -818,7 +819,8 @@ "use_key_equivalents": true, "bindings": { "alt-tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction", + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", }, }, { @@ -1364,6 +1366,11 @@ "cmd-+": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd--": ["zed::DecreaseUiFontSize", { "persist": false }], "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], + "cmd-1": ["welcome::OpenRecentProject", 0], + "cmd-2": ["welcome::OpenRecentProject", 1], + "cmd-3": ["welcome::OpenRecentProject", 2], + "cmd-4": ["welcome::OpenRecentProject", 3], + "cmd-5": ["welcome::OpenRecentProject", 4], }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 61793d2158d35ed25f71da3606534d64b523de9f..e344ea356fb171fb07474f498056df73c73d8307 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -265,7 +265,7 @@ "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::CopyAsMarkdown", + "ctrl-c": "markdown::Copy", }, }, { @@ -747,7 +747,8 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction", + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", }, }, { @@ -756,7 +757,8 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction", + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", }, }, { @@ -1293,6 +1295,11 @@ "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + "ctrl-1": ["welcome::OpenRecentProject", 0], + "ctrl-2": ["welcome::OpenRecentProject", 1], + "ctrl-3": ["welcome::OpenRecentProject", 2], + "ctrl-4": ["welcome::OpenRecentProject", 3], + "ctrl-5": ["welcome::OpenRecentProject", 4], }, }, { diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 53f38234bb47a0f7c4412bf767e3eedf0465ba2a..58a7309cf902a3f69f949830cace2200f41fb0fe 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -70,7 +70,8 @@ "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "ctrl-right": "editor::AcceptPartialEditPrediction", + "ctrl-right": "editor::AcceptNextWordEditPrediction", + "ctrl-down": "editor::AcceptNextLineEditPrediction", }, }, { diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index 3a54c92bf33decd968ee8d711fb1a929534ded21..d3bf53a0d3694943252e0fccb2ac821cc6c2a6d3 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -70,7 +70,9 @@ "bindings": { "ctrl-f12": "outline::Toggle", "ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }], + "ctrl-e": "file_finder::Toggle", "ctrl-shift-n": "file_finder::Toggle", + "ctrl-alt-n": "file_finder::Toggle", "ctrl-g": "go_to_line::Toggle", "alt-enter": "editor::ToggleCodeActions", "ctrl-space": "editor::ShowCompletions", @@ -105,8 +107,8 @@ "ctrl-e": "file_finder::Toggle", "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "ctrl-shift-n": "file_finder::Toggle", - "ctrl-n": "project_symbols::Toggle", "ctrl-alt-n": "file_finder::Toggle", + "ctrl-n": "project_symbols::Toggle", "ctrl-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle", "ctrl-alt-shift-n": "project_symbols::Toggle", diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index 6a2f46e0ce6d037de6de2d801d80671c63a3e3cd..93e259db37ac718d2e0258d83e4de436a0a378fd 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -71,7 +71,8 @@ "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "cmd-right": "editor::AcceptPartialEditPrediction", + "cmd-right": "editor::AcceptNextWordEditPrediction", + "cmd-down": "editor::AcceptNextLineEditPrediction", }, }, { diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 1721a9d743a67abddbc55a4b505be497920d15aa..9946d8b124957349181db659259174d906d08d3a 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -68,8 +68,10 @@ "bindings": { "cmd-f12": "outline::Toggle", "cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }], - "cmd-shift-o": "file_finder::Toggle", "cmd-l": "go_to_line::Toggle", + "cmd-e": "file_finder::Toggle", + "cmd-shift-o": "file_finder::Toggle", + "cmd-shift-n": "file_finder::Toggle", "alt-enter": "editor::ToggleCodeActions", "ctrl-space": "editor::ShowCompletions", "cmd-j": "editor::Hover", diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index c72c92471761c473bea05edc37b1f96f18b2f683..13f94991ad44fc997144a3d44527dcbce5231504 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -68,34 +68,34 @@ "editor.active_wrap_guide": "#c8ccd41a", "editor.document_highlight.read_background": "#74ade81a", "editor.document_highlight.write_background": "#555a6366", - "terminal.background": "#282c33ff", - "terminal.foreground": "#dce0e5ff", + "terminal.background": "#282c34ff", + "terminal.foreground": "#abb2bfff", "terminal.bright_foreground": "#dce0e5ff", - "terminal.dim_foreground": "#282c33ff", - "terminal.ansi.black": "#282c33ff", - "terminal.ansi.bright_black": "#525561ff", - "terminal.ansi.dim_black": "#dce0e5ff", - "terminal.ansi.red": "#d07277ff", - "terminal.ansi.bright_red": "#673a3cff", - "terminal.ansi.dim_red": "#eab7b9ff", - "terminal.ansi.green": "#a1c181ff", - "terminal.ansi.bright_green": "#4d6140ff", - "terminal.ansi.dim_green": "#d1e0bfff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#e5c07bff", - "terminal.ansi.dim_yellow": "#f1dfc1ff", - "terminal.ansi.blue": "#74ade8ff", - "terminal.ansi.bright_blue": "#385378ff", - "terminal.ansi.dim_blue": "#bed5f4ff", - "terminal.ansi.magenta": "#b477cfff", - "terminal.ansi.bright_magenta": "#d6b4e4ff", - "terminal.ansi.dim_magenta": "#612a79ff", - "terminal.ansi.cyan": "#6eb4bfff", - "terminal.ansi.bright_cyan": "#3a565bff", - "terminal.ansi.dim_cyan": "#b9d9dfff", - "terminal.ansi.white": "#dce0e5ff", + "terminal.dim_foreground": "#636d83ff", + "terminal.ansi.black": "#282c34ff", + "terminal.ansi.bright_black": "#636d83ff", + "terminal.ansi.dim_black": "#3b3f4aff", + "terminal.ansi.red": "#e06c75ff", + "terminal.ansi.bright_red": "#EA858Bff", + "terminal.ansi.dim_red": "#a7545aff", + "terminal.ansi.green": "#98c379ff", + "terminal.ansi.bright_green": "#AAD581ff", + "terminal.ansi.dim_green": "#6d8f59ff", + "terminal.ansi.yellow": "#e5c07bff", + "terminal.ansi.bright_yellow": "#FFD885ff", + "terminal.ansi.dim_yellow": "#b8985bff", + "terminal.ansi.blue": "#61afefff", + "terminal.ansi.bright_blue": "#85C1FFff", + "terminal.ansi.dim_blue": "#457cadff", + "terminal.ansi.magenta": "#c678ddff", + "terminal.ansi.bright_magenta": "#D398EBff", + "terminal.ansi.dim_magenta": "#8d54a0ff", + "terminal.ansi.cyan": "#56b6c2ff", + "terminal.ansi.bright_cyan": "#6ED5DEff", + "terminal.ansi.dim_cyan": "#3c818aff", + "terminal.ansi.white": "#abb2bfff", "terminal.ansi.bright_white": "#fafafaff", - "terminal.ansi.dim_white": "#575d65ff", + "terminal.ansi.dim_white": "#8f969bff", "link_text.hover": "#74ade8ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", @@ -473,33 +473,33 @@ "editor.document_highlight.read_background": "#5c78e225", "editor.document_highlight.write_background": "#a3a3a466", "terminal.background": "#fafafaff", - "terminal.foreground": "#242529ff", - "terminal.bright_foreground": "#242529ff", - "terminal.dim_foreground": "#fafafaff", - "terminal.ansi.black": "#242529ff", - "terminal.ansi.bright_black": "#747579ff", - "terminal.ansi.dim_black": "#97979aff", - "terminal.ansi.red": "#d36151ff", - "terminal.ansi.bright_red": "#f0b0a4ff", - "terminal.ansi.dim_red": "#6f312aff", - "terminal.ansi.green": "#669f59ff", - "terminal.ansi.bright_green": "#b2cfa9ff", - "terminal.ansi.dim_green": "#354d2eff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#826221ff", - "terminal.ansi.dim_yellow": "#786441ff", - "terminal.ansi.blue": "#5c78e2ff", - "terminal.ansi.bright_blue": "#b5baf2ff", - "terminal.ansi.dim_blue": "#2d3d75ff", - "terminal.ansi.magenta": "#984ea5ff", - "terminal.ansi.bright_magenta": "#cea6d3ff", - "terminal.ansi.dim_magenta": "#4b2a50ff", - "terminal.ansi.cyan": "#3a82b7ff", - "terminal.ansi.bright_cyan": "#a3bedaff", - "terminal.ansi.dim_cyan": "#254058ff", - "terminal.ansi.white": "#fafafaff", + "terminal.foreground": "#2a2c33ff", + "terminal.bright_foreground": "#2a2c33ff", + "terminal.dim_foreground": "#bbbbbbff", + "terminal.ansi.black": "#000000ff", + "terminal.ansi.bright_black": "#000000ff", + "terminal.ansi.dim_black": "#555555ff", + "terminal.ansi.red": "#de3e35ff", + "terminal.ansi.bright_red": "#de3e35ff", + "terminal.ansi.dim_red": "#9c2b26ff", + "terminal.ansi.green": "#3f953aff", + "terminal.ansi.bright_green": "#3f953aff", + "terminal.ansi.dim_green": "#2b6927ff", + "terminal.ansi.yellow": "#d2b67cff", + "terminal.ansi.bright_yellow": "#d2b67cff", + "terminal.ansi.dim_yellow": "#a48c5aff", + "terminal.ansi.blue": "#2f5af3ff", + "terminal.ansi.bright_blue": "#2f5af3ff", + "terminal.ansi.dim_blue": "#2140abff", + "terminal.ansi.magenta": "#950095ff", + "terminal.ansi.bright_magenta": "#a00095ff", + "terminal.ansi.dim_magenta": "#6a006aff", + "terminal.ansi.cyan": "#3f953aff", + "terminal.ansi.bright_cyan": "#3f953aff", + "terminal.ansi.dim_cyan": "#2b6927ff", + "terminal.ansi.white": "#bbbbbbff", "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#aaaaaaff", + "terminal.ansi.dim_white": "#888888ff", "link_text.hover": "#5c78e2ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", diff --git a/clippy.toml b/clippy.toml index 0ce7a6cd68d4e8210788eb7a67aa06c742cc8274..9dd246074a06c4db7b66eff7a83ef68e3612c378 100644 --- a/clippy.toml +++ b/clippy.toml @@ -14,6 +14,7 @@ disallowed-methods = [ { path = "std::process::Command::stderr", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stderr" }, { path = "serde_json::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892. Use `serde_json::from_slice` instead." }, { path = "serde_json_lenient::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892, Use `serde_json_lenient::from_slice` instead." }, + { path = "cocoa::foundation::NSString::alloc", reason = "NSString must be autoreleased to avoid memory leaks. Use `ns_string()` helper instead." }, ] disallowed-types = [ # { path = "std::collections::HashMap", replacement = "collections::HashMap" }, diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 8ef6f1a52c8b207658d59a1e6b877964df9e42ce..70f2e4d259f1611fb42ebc0b064d278c8b3b9c4d 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -46,6 +46,7 @@ url.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true +urlencoding.workspace = true [dev-dependencies] env_logger.workspace = true diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index c1b7032cfaa904764055bb79a3cac7e7ac74b0c1..3e2e53fb7fbdf581b45566bd747cfcbfc1c0a004 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -4,12 +4,14 @@ use file_icons::FileIcons; use prompt_store::{PromptId, UserPromptId}; use serde::{Deserialize, Serialize}; use std::{ + borrow::Cow, fmt, ops::RangeInclusive, path::{Path, PathBuf}, }; use ui::{App, IconName, SharedString}; use url::Url; +use urlencoding::decode; use util::paths::PathStyle; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -74,11 +76,13 @@ impl MentionUri { let path = url.path(); match url.scheme() { "file" => { - let path = if path_style.is_windows() { + let normalized = if path_style.is_windows() { path.trim_start_matches("/") } else { path }; + let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized)); + let path = decoded.as_ref(); if let Some(fragment) = url.fragment() { let line_range = parse_line_range(fragment)?; @@ -406,6 +410,19 @@ mod tests { assert_eq!(parsed.to_uri().to_string(), selection_uri); } + #[test] + fn test_parse_file_uri_with_non_ascii() { + let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"); + let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap(); + match &parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt"))); + } + _ => panic!("Expected File variant"), + } + assert_eq!(parsed.to_uri().to_string(), file_uri); + } + #[test] fn test_parse_untitled_selection_uri() { let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10"); diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index e350cf04d43ec532a774d467bd2e4eb392895682..3bb22824548665a0981e707213fe391e384d153a 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -33,7 +33,8 @@ use gpui::{ use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; use project::{Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{ - ProjectContext, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext, + ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, + WorktreeContext, }; use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, update_settings_file}; @@ -51,18 +52,6 @@ pub struct ProjectSnapshot { pub timestamp: DateTime, } -const RULES_FILE_NAMES: [&str; 9] = [ - ".rules", - ".cursorrules", - ".windsurfrules", - ".clinerules", - ".github/copilot-instructions.md", - "CLAUDE.md", - "AGENT.md", - "AGENTS.md", - "GEMINI.md", -]; - pub struct RulesLoadingError { pub message: SharedString, } @@ -1224,6 +1213,15 @@ impl TerminalHandle for AcpTerminalHandle { self.terminal .read_with(cx, |term, cx| term.current_output(cx)) } + + fn kill(&self, cx: &AsyncApp) -> Result<()> { + cx.update(|cx| { + self.terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + })?; + Ok(()) + } } #[cfg(test)] diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 4620647135631fdb367b0dc2604e89770a938c07..2477e46a85183813f61bb60d7e3de7f119a4f00c 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -16,7 +16,7 @@ You are a highly skilled software engineer with extensive knowledge in many prog 3. DO NOT use tools to access items that are already available in the context section. 4. Use only the tools that are currently available. 5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off. -6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers. +6. When running commands that may run indefinitely or for a long time (such as build scripts, tests, servers, or file watchers), specify `timeout_ms` to bound runtime. If the command times out, the user can always ask you to run it again with a longer timeout or no timeout if they're willing to wait or cancel manually. 7. Avoid HTML entity escaping - use plain characters instead. ## Searching and Reading diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 9ff870353279635957cd2b84f418f881c3444aa2..5a581c5db80a4c4f527efc8b1711fbf16c8097f8 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -9,14 +9,16 @@ use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use fs::{FakeFs, Fs}; use futures::{ - StreamExt, + FutureExt as _, StreamExt, channel::{ mpsc::{self, UnboundedReceiver}, oneshot, }, + future::{Fuse, Shared}, }; use gpui::{ - App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient, + App, AppContext, AsyncApp, Entity, Task, TestAppContext, UpdateGlobal, + http_client::FakeHttpClient, }; use indoc::indoc; use language_model::{ @@ -35,12 +37,109 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; use settings::{Settings, SettingsStore}; -use std::{path::Path, rc::Rc, sync::Arc, time::Duration}; +use std::{ + path::Path, + pin::Pin, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use util::path; mod test_tools; use test_tools::*; +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); +} + +struct FakeTerminalHandle { + killed: Arc, + wait_for_exit: Shared>, + output: acp::TerminalOutputResponse, + id: acp::TerminalId, +} + +impl FakeTerminalHandle { + fn new_never_exits(cx: &mut App) -> Self { + let killed = Arc::new(AtomicBool::new(false)); + + let killed_for_task = killed.clone(); + let wait_for_exit = cx + .spawn(async move |cx| { + loop { + if killed_for_task.load(Ordering::SeqCst) { + return acp::TerminalExitStatus::new(); + } + cx.background_executor() + .timer(Duration::from_millis(1)) + .await; + } + }) + .shared(); + + Self { + killed, + wait_for_exit, + output: acp::TerminalOutputResponse::new("partial output".to_string(), false), + id: acp::TerminalId::new("fake_terminal".to_string()), + } + } + + fn was_killed(&self) -> bool { + self.killed.load(Ordering::SeqCst) + } +} + +impl crate::TerminalHandle for FakeTerminalHandle { + fn id(&self, _cx: &AsyncApp) -> Result { + Ok(self.id.clone()) + } + + fn current_output(&self, _cx: &AsyncApp) -> Result { + Ok(self.output.clone()) + } + + fn wait_for_exit(&self, _cx: &AsyncApp) -> Result>> { + Ok(self.wait_for_exit.clone()) + } + + fn kill(&self, _cx: &AsyncApp) -> Result<()> { + self.killed.store(true, Ordering::SeqCst); + Ok(()) + } +} + +struct FakeThreadEnvironment { + handle: Rc, +} + +impl crate::ThreadEnvironment for FakeThreadEnvironment { + fn create_terminal( + &self, + _command: String, + _cwd: Option, + _output_byte_limit: Option, + _cx: &mut AsyncApp, + ) -> Task>> { + Task::ready(Ok(self.handle.clone() as Rc)) + } +} + +fn always_allow_tools(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + }); +} + #[gpui::test] async fn test_echo(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -71,6 +170,120 @@ async fn test_echo(cx: &mut TestAppContext) { assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); } +#[gpui::test] +async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: Some(5), + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + let mut task_future: Pin>>>> = Box::pin(task.fuse()); + + let deadline = std::time::Instant::now() + Duration::from_millis(500); + loop { + if let Some(result) = task_future.as_mut().now_or_never() { + let result = result.expect("terminal tool task should complete"); + + assert!( + handle.was_killed(), + "expected terminal handle to be killed on timeout" + ); + assert!( + result.contains("partial output"), + "expected result to include terminal output, got: {result}" + ); + return; + } + + if std::time::Instant::now() >= deadline { + panic!("timed out waiting for terminal tool task to complete"); + } + + cx.run_until_parked(); + cx.background_executor.timer(Duration::from_millis(1)).await; + } +} + +#[gpui::test] +#[ignore] +async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let _task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: None, + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + smol::Timer::after(Duration::from_millis(25)).await; + + assert!( + !handle.was_killed(), + "did not expect terminal handle to be killed without a timeout" + ); +} + #[gpui::test] async fn test_thinking(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 4aabf8069bc3380b6908187b28517f99a9548f26..dbf29c68766cfe28d0bce1d82ed53536446326e2 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -530,6 +530,7 @@ pub trait TerminalHandle { fn id(&self, cx: &AsyncApp) -> Result; fn current_output(&self, cx: &AsyncApp) -> Result; fn wait_for_exit(&self, cx: &AsyncApp) -> Result>>; + fn kill(&self, cx: &AsyncApp) -> Result<()>; } pub trait ThreadEnvironment { @@ -2658,7 +2659,6 @@ impl From for acp::ContentBlock { fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { LanguageModelImage { source: image_content.data.into(), - // TODO: make this optional? - size: gpui::Size::new(0.into(), 0.into()), + size: None, } } diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 2db4a2d86038579fca62224f3a7c567f93fc6922..f3302fb1894612287bf04acfbfa301188bf853fb 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -1,6 +1,7 @@ use agent_client_protocol as acp; use anyhow::Result; -use gpui::{App, Entity, SharedString, Task}; +use futures::FutureExt as _; +use gpui::{App, AppContext, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,6 +9,7 @@ use std::{ path::{Path, PathBuf}, rc::Rc, sync::Arc, + time::Duration, }; use util::markdown::MarkdownInlineCode; @@ -25,13 +27,17 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// /// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. /// +/// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs. +/// /// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. - command: String, + pub command: String, /// Working directory for the command. This must be one of the root directories of the project. - cd: String, + pub cd: String, + /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. + pub timeout_ms: Option, } pub struct TerminalTool { @@ -116,7 +122,26 @@ impl AgentTool for TerminalTool { acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)), ])); - let exit_status = terminal.wait_for_exit(cx)?.await; + let timeout = input.timeout_ms.map(Duration::from_millis); + + let exit_status = match timeout { + Some(timeout) => { + let wait_for_exit = terminal.wait_for_exit(cx)?; + let timeout_task = cx.background_spawn(async move { + smol::Timer::after(timeout).await; + }); + + futures::select! { + status = wait_for_exit.clone().fuse() => status, + _ = timeout_task.fuse() => { + terminal.kill(cx)?; + wait_for_exit.await + } + } + } + None => terminal.wait_for_exit(cx)?.await, + }; + let output = terminal.current_output(cx)?; Ok(process_content(output, &input.command, exit_status)) diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 3f175b60414bab4876da633c3ccab03dcebb603a..3dcfdf9b6b38c6a00d11bbfe70b95391529d706d 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -407,9 +407,7 @@ async fn fuzzy_search( let candidates = model_list .iter() .enumerate() - .map(|(ix, model)| { - StringMatchCandidate::new(ix, &format!("{}/{}", model.id, model.name)) - }) + .map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref())) .collect::>(); let mut matches = match_strings( &candidates, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 6cd2ec2fa3442bbf4961dffb0c4538ac9615d982..aa02e22635c1585003fbfc540b50687ae0930ecd 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -63,10 +63,7 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::agent_diff::AgentDiff; use crate::profile_selector::{ProfileProvider, ProfileSelector}; -use crate::ui::{ - AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip, - UsageCallout, -}; +use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout}; use crate::{ AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAgentDiff, OpenHistory, @@ -693,7 +690,7 @@ impl AcpThreadView { this.new_server_version_available = Some(new_version.into()); cx.notify(); }) - .log_err(); + .ok(); } } }) @@ -2091,10 +2088,23 @@ impl AcpThreadView { .icon_size(IconSize::Small) .icon_color(Color::Muted) .style(ButtonStyle::Transparent) - .tooltip(move |_window, cx| { - cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone())) - .into() - }) + .tooltip(Tooltip::element({ + move |_, _| { + v_flex() + .gap_1() + .child(Label::new("Unavailable Editing")).child( + div().max_w_64().child( + Label::new(format!( + "Editing previous messages is not available for {} yet.", + agent_name.clone() + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } + })) ) ) } @@ -4208,7 +4218,11 @@ impl AcpThreadView { } })) .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { - if let Some(mode_selector) = this.mode_selector() { + if let Some(profile_selector) = this.profile_selector.as_ref() { + profile_selector.update(cx, |profile_selector, cx| { + profile_selector.cycle_profile(cx); + }); + } else if let Some(mode_selector) = this.mode_selector() { mode_selector.update(cx, |mode_selector, cx| { mode_selector.cycle_mode(window, cx); }); @@ -4859,6 +4873,32 @@ impl AcpThreadView { cx.notify(); } + fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { + let Some(thread) = self.thread() else { + return; + }; + + let entries = thread.read(cx).entries(); + if entries.is_empty() { + return; + } + + // Find the most recent user message and scroll it to the top of the viewport. + // (Fallback: if no user message exists, scroll to the bottom.) + if let Some(ix) = entries + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) + { + self.list_state.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: px(0.0), + }); + cx.notify(); + } else { + self.scroll_to_bottom(cx); + } + } + pub fn scroll_to_bottom(&mut self, cx: &mut Context) { if let Some(thread) = self.thread() { let entry_count = thread.read(cx).entries().len(); @@ -5077,6 +5117,16 @@ impl AcpThreadView { } })); + let scroll_to_recent_user_prompt = + IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_most_recent_user_prompt(cx); + })); + let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -5153,6 +5203,7 @@ impl AcpThreadView { container .child(open_as_markdown) + .child(scroll_to_recent_user_prompt) .child(scroll_to_top) .into_any_element() } @@ -6785,6 +6836,70 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + + // Each user prompt will result in a user message entry plus an agent message entry. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response 1".into()), + )]); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + + let thread = thread_view + .read_with(cx, |view, _| view.thread().cloned()) + .unwrap(); + + thread + .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response 2".into()), + )]); + + thread + .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + // Move somewhere else first so we're not trivially already on the last user prompt. + thread_view.update(cx, |view, cx| { + view.scroll_to_top(cx); + }); + cx.run_until_parked(); + + thread_view.update(cx, |view, cx| { + view.scroll_to_most_recent_user_prompt(cx); + let scroll_top = view.list_state.logical_scroll_top(); + // Entries layout is: [User1, Assistant1, User2, Assistant2] + assert_eq!(scroll_top.item_ix, 2); + }); + } + + #[gpui::test] + async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + // With no entries, scrolling should be a no-op and must not panic. + thread_view.update(cx, |view, cx| { + view.scroll_to_most_recent_user_prompt(cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 0); + }); + } + #[gpui::test] async fn test_message_editing_cancel(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 2f17349c3d1da1cf68a3ab513ccad434a115087b..ed00b2b5c716fdf27abc1c9d7c5850b36fce830f 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -8,6 +8,7 @@ use editor::Editor; use fs::Fs; use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, prelude::*}; use language_model::{LanguageModel, LanguageModelRegistry}; +use settings::SettingsStore; use settings::{ LanguageModelProviderSetting, LanguageModelSelection, Settings as _, update_settings_file, }; @@ -94,6 +95,7 @@ pub struct ViewProfileMode { configure_default_model: NavigableEntry, configure_tools: NavigableEntry, configure_mcps: NavigableEntry, + delete_profile: NavigableEntry, cancel_item: NavigableEntry, } @@ -109,6 +111,7 @@ pub struct ManageProfilesModal { active_model: Option>, focus_handle: FocusHandle, mode: Mode, + _settings_subscription: Subscription, } impl ManageProfilesModal { @@ -148,12 +151,23 @@ impl ManageProfilesModal { ) -> Self { let focus_handle = cx.focus_handle(); + // Keep this modal in sync with settings changes (including profile deletion). + let settings_subscription = + cx.observe_global_in::(window, |this, window, cx| { + if matches!(this.mode, Mode::ChooseProfile(_)) { + this.mode = Mode::choose_profile(window, cx); + this.focus_handle(cx).focus(window); + cx.notify(); + } + }); + Self { fs, active_model, context_server_registry, focus_handle, mode: Mode::choose_profile(window, cx), + _settings_subscription: settings_subscription, } } @@ -192,6 +206,7 @@ impl ManageProfilesModal { configure_default_model: NavigableEntry::focusable(cx), configure_tools: NavigableEntry::focusable(cx), configure_mcps: NavigableEntry::focusable(cx), + delete_profile: NavigableEntry::focusable(cx), cancel_item: NavigableEntry::focusable(cx), }); self.focus_handle(cx).focus(window); @@ -369,6 +384,42 @@ impl ManageProfilesModal { } } + fn delete_profile( + &mut self, + profile_id: AgentProfileId, + window: &mut Window, + cx: &mut Context, + ) { + if builtin_profiles::is_builtin(&profile_id) { + self.view_profile(profile_id, window, cx); + return; + } + + let fs = self.fs.clone(); + + update_settings_file(fs, cx, move |settings, _cx| { + let Some(agent_settings) = settings.agent.as_mut() else { + return; + }; + + let Some(profiles) = agent_settings.profiles.as_mut() else { + return; + }; + + profiles.shift_remove(profile_id.0.as_ref()); + + if agent_settings + .default_profile + .as_deref() + .is_some_and(|default_profile| default_profile == profile_id.0.as_ref()) + { + agent_settings.default_profile = Some(AgentProfileId::default().0); + } + }); + + self.choose_profile(window, cx); + } + fn cancel(&mut self, window: &mut Window, cx: &mut Context) { match &self.mode { Mode::ChooseProfile { .. } => { @@ -756,6 +807,40 @@ impl ManageProfilesModal { }), ), ) + .child( + div() + .id("delete-profile") + .track_focus(&mode.delete_profile.focus_handle) + .on_action({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _: &menu::Confirm, window, cx| { + this.delete_profile(profile_id.clone(), window, cx); + }) + }) + .child( + ListItem::new("delete-profile") + .toggle_state( + mode.delete_profile + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Trash) + .size(IconSize::Small) + .color(Color::Error), + ) + .child(Label::new("Delete Profile").color(Color::Error)) + .disabled(builtin_profiles::is_builtin(&mode.profile_id)) + .on_click({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _, window, cx| { + this.delete_profile(profile_id.clone(), window, cx); + }) + }), + ), + ) .child(ListSeparator) .child( div() @@ -805,6 +890,7 @@ impl ManageProfilesModal { .entry(mode.configure_default_model) .entry(mode.configure_tools) .entry(mode.configure_mcps) + .entry(mode.delete_profile) .entry(mode.cancel_item) } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 83614bdb422926ab41195bf3380b7df1abcde8a4..71918bfd65f5333d73291ec90995f0844c6904a4 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -261,12 +261,14 @@ fn update_command_palette_filter(cx: &mut App) { CommandPaletteFilter::update_global(cx, |filter, _| { use editor::actions::{ - AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction, - PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, + AcceptEditPrediction, AcceptNextLineEditPrediction, AcceptNextWordEditPrediction, + NextEditPrediction, PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, }; let edit_prediction_actions = [ TypeId::of::(), - TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), TypeId::of::(), TypeId::of::(), TypeId::of::(), diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index d8d0efda0fbd70153b02452f6281ee66b90eca92..25395278745a9eb18fbbfa1cd920af3e3b26e24d 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -409,6 +409,9 @@ impl CodegenAlternative { model: Arc, cx: &mut Context, ) -> Result<()> { + // Clear the model explanation since the user has started a new generation. + self.description = None; + if let Some(transformation_transaction_id) = self.transformation_transaction_id.take() { self.buffer.update(cx, |buffer, cx| { buffer.undo_transaction(transformation_transaction_id, cx); diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 0182be0912d3b8a8a046371ce725e7d21a0ddb58..ac08070fcefa92854b51bc8a66d4d388d08e087d 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -1,4 +1,4 @@ -use crate::{ManageProfiles, ToggleProfileSelector}; +use crate::{CycleModeSelector, ManageProfiles, ToggleProfileSelector}; use agent_settings::{ AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles, }; @@ -70,6 +70,29 @@ impl ProfileSelector { self.picker_handle.clone() } + pub fn cycle_profile(&mut self, cx: &mut Context) { + if !self.provider.profiles_supported(cx) { + return; + } + + let profiles = AgentProfile::available_profiles(cx); + if profiles.is_empty() { + return; + } + + let current_profile_id = self.provider.profile_id(cx); + let current_index = profiles + .keys() + .position(|id| id == ¤t_profile_id) + .unwrap_or(0); + + let next_index = (current_index + 1) % profiles.len(); + + if let Some((next_profile_id, _)) = profiles.get_index(next_index) { + self.provider.set_profile(next_profile_id.clone(), cx); + } + } + fn ensure_picker( &mut self, window: &mut Window, @@ -163,14 +186,29 @@ impl Render for ProfileSelector { PickerPopoverMenu::new( picker, trigger_button, - move |_window, cx| { - Tooltip::for_action_in( - "Toggle Profile Menu", - &ToggleProfileSelector, - &focus_handle, - cx, - ) - }, + Tooltip::element({ + move |_window, cx| { + let container = || h_flex().gap_1().justify_between(); + v_flex() + .gap_1() + .child( + container() + .pb_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Cycle Through Profiles")) + .child(KeyBinding::for_action_in( + &CycleModeSelector, + &focus_handle, + cx, + )), + ) + .child(container().child(Label::new("Toggle Profile Menu")).child( + KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx), + )) + .into_any() + } + }), gpui::Corner::BottomRight, cx, ) diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index e604df416e2725a6f1b7bff8eed883a8cc36e184..6c3d8bc1427092b0d0380cf286da1706337932fe 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -5,7 +5,7 @@ mod claude_code_onboarding_modal; mod end_trial_upsell; mod hold_for_default; mod onboarding_modal; -mod unavailable_editing_tooltip; + mod usage_callout; pub use acp_onboarding_modal::*; @@ -15,5 +15,5 @@ pub use claude_code_onboarding_modal::*; pub use end_trial_upsell::*; pub use hold_for_default::*; pub use onboarding_modal::*; -pub use unavailable_editing_tooltip::*; + pub use usage_callout::*; diff --git a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs deleted file mode 100644 index 2993fb89a989619ecfe3d79b06d82a2a6f71fc31..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs +++ /dev/null @@ -1,29 +0,0 @@ -use gpui::{Context, IntoElement, Render, Window}; -use ui::{prelude::*, tooltip_container}; - -pub struct UnavailableEditingTooltip { - agent_name: SharedString, -} - -impl UnavailableEditingTooltip { - pub fn new(agent_name: SharedString) -> Self { - Self { agent_name } - } -} - -impl Render for UnavailableEditingTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, |this, _| { - this.child(Label::new("Unavailable Editing")).child( - div().max_w_64().child( - Label::new(format!( - "Editing previous messages is not available for {} yet.", - self.agent_name - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - } -} diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs index a7afdddda43514ade40b7fd9dfd8bcd8ace33dc7..254b8d2999dd3f9ce99c07a20273cbb1ca9cb929 100644 --- a/crates/agent_ui_v2/src/agents_panel.rs +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -384,8 +384,7 @@ impl Panel for AgentsPanel { update_settings_file(self.fs.clone(), cx, move |settings, _| { settings.agent.get_or_insert_default().agents_panel_dock = Some(match position { DockPosition::Left => settings::DockSide::Left, - DockPosition::Bottom => settings::DockSide::Right, - DockPosition::Right => settings::DockSide::Left, + DockPosition::Right | DockPosition::Bottom => settings::DockSide::Right, }); }); self.re_register_utility_pane(window, cx); diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index fc15b4e4395ae7aa3100a165d942a6906cf1976d..ccc8c067c25a91aa44c01911be89c21f0ea9367c 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -305,6 +305,7 @@ impl Room { pub(crate) fn leave(&mut self, cx: &mut Context) -> Task> { cx.notify(); + self.emit_video_track_unsubscribed_events(cx); self.leave_internal(cx) } @@ -352,6 +353,14 @@ impl Room { self.maintain_connection.take(); } + fn emit_video_track_unsubscribed_events(&self, cx: &mut Context) { + for participant in self.remote_participants.values() { + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } + } + } + async fn maintain_connection( this: WeakEntity, client: Arc, @@ -882,6 +891,9 @@ impl Room { project_id: project.id, }); } + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } false } }); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 92c0ce2377b8c200b2367148226f3bd3b81f0008..e1a7a1481b56633364cb011f46cd55e616244f2c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -61,6 +61,8 @@ Examples: )] struct Args { /// Wait for all of the given paths to be opened/closed before exiting. + /// + /// When opening a directory, waits until the created window is closed. #[arg(short, long)] wait: bool, /// Add files to the currently open workspace diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 14311d6bbf52ecb6df8dcc4a2fbc9454836a4834..801c8c3de8d3f02e3d73809df2c651c6973f231a 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1730,23 +1730,59 @@ impl ProtoClient for Client { /// prefix for the zed:// url scheme pub const ZED_URL_SCHEME: &str = "zed"; +/// A parsed Zed link that can be handled internally by the application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ZedLink { + /// Join a channel: `zed.dev/channel/channel-name-123` or `zed://channel/channel-name-123` + Channel { channel_id: u64 }, + /// Open channel notes: `zed.dev/channel/channel-name-123/notes` or with heading `notes#heading` + ChannelNotes { + channel_id: u64, + heading: Option, + }, +} + /// Parses the given link into a Zed link. /// -/// Returns a [`Some`] containing the unprefixed link if the link is a Zed link. -/// Returns [`None`] otherwise. -pub fn parse_zed_link<'a>(link: &'a str, cx: &App) -> Option<&'a str> { +/// Returns a [`Some`] containing the parsed link if the link is a recognized Zed link +/// that should be handled internally by the application. +/// Returns [`None`] for links that should be opened in the browser. +pub fn parse_zed_link(link: &str, cx: &App) -> Option { let server_url = &ClientSettings::get_global(cx).server_url; - if let Some(stripped) = link + let path = link .strip_prefix(server_url) .and_then(|result| result.strip_prefix('/')) - { - return Some(stripped); + .or_else(|| { + link.strip_prefix(ZED_URL_SCHEME) + .and_then(|result| result.strip_prefix("://")) + })?; + + let mut parts = path.split('/'); + + if parts.next() != Some("channel") { + return None; } - if let Some(stripped) = link - .strip_prefix(ZED_URL_SCHEME) - .and_then(|result| result.strip_prefix("://")) - { - return Some(stripped); + + let slug = parts.next()?; + let id_str = slug.split('-').next_back()?; + let channel_id = id_str.parse::().ok()?; + + let Some(next) = parts.next() else { + return Some(ZedLink::Channel { channel_id }); + }; + + if let Some(heading) = next.strip_prefix("notes#") { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: Some(heading.to_string()), + }); + } + + if next == "notes" { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: None, + }); } None diff --git a/crates/collab/src/api/contributors.rs b/crates/collab/src/api/contributors.rs index 574667c723dce62b905e3d2a0b34de1ca4c88c8e..e09ac4f8b7355cf143b221308204742139308133 100644 --- a/crates/collab/src/api/contributors.rs +++ b/crates/collab/src/api/contributors.rs @@ -54,6 +54,26 @@ async fn check_is_contributor( ) -> Result> { let params = params.into_contributor_selector()?; + if CopilotSweAgentBot::is_copilot_bot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + CopilotSweAgentBot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + + if Dependabot::is_dependabot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + Dependabot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + if RenovateBot::is_renovate_bot(¶ms) { return Ok(Json(CheckIsContributorResponse { signed_at: Some( @@ -83,6 +103,66 @@ async fn check_is_contributor( })) } +/// The Copilot bot GitHub user (`copilot-swe-agent[bot]`). +/// +/// https://api.github.com/users/copilot-swe-agent[bot] +struct CopilotSweAgentBot; + +impl CopilotSweAgentBot { + const LOGIN: &'static str = "copilot-swe-agent[bot]"; + const USER_ID: i32 = 198982749; + + /// Returns the `created_at` timestamp for the Dependabot bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2025-02-12T20:26:08Z") + .expect("failed to parse 'created_at' for 'copilot-swe-agent[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Copilot bot user. + fn is_copilot_bot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + +/// The Dependabot bot GitHub user (`dependabot[bot]`). +/// +/// https://api.github.com/users/dependabot[bot] +struct Dependabot; + +impl Dependabot { + const LOGIN: &'static str = "dependabot[bot]"; + const USER_ID: i32 = 49699333; + + /// Returns the `created_at` timestamp for the Dependabot bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2019-04-16T22:34:25Z") + .expect("failed to parse 'created_at' for 'dependabot[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Dependabot bot user. + fn is_dependabot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + /// The Renovate bot GitHub user (`renovate[bot]`). /// /// https://api.github.com/users/renovate[bot] diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 459abda17573d66287e2c8ca0b995292acaf163b..3a1706a7a679fbc14eafbeac953d842cda9f65c8 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -52,6 +52,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true itertools.workspace = true +url.workspace = true [target.'cfg(windows)'.dependencies] async-std = { version = "1.12.0", features = ["unstable"] } diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index 961154dbeecad007f026f25eeac25de95d751d9e..0e0cfe6cdca78d2a8b382269ce1ca9a340d1e69c 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -269,6 +269,7 @@ fn common_prefix, T2: Iterator>(a: T1, b: #[cfg(test)] mod tests { use super::*; + use edit_prediction_types::EditPredictionGranularity; use editor::{ Editor, ExcerptRange, MultiBuffer, MultiBufferOffset, SelectionEffects, test::editor_lsp_test_context::EditorLspTestContext, @@ -581,13 +582,15 @@ mod tests { assert!(editor.has_active_edit_prediction()); // Accepting the first word of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); // Accepting next word should accept the non-word and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); @@ -623,7 +626,7 @@ mod tests { assert!(editor.has_active_edit_prediction()); // Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n"); assert_eq!( @@ -632,7 +635,7 @@ mod tests { ); // Accepting next word should accept the next word and copilot suggestion should still exist - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n"); assert_eq!( @@ -641,7 +644,7 @@ mod tests { ); // Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n"); assert_eq!( diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs index 0bcb11e18be1994ea92703973ad1278c5d5aa4f8..20e31525a8fdb09fce04934d3445d51ba4226a2e 100644 --- a/crates/copilot/src/sign_in.rs +++ b/crates/copilot/src/sign_in.rs @@ -6,6 +6,7 @@ use gpui::{ Subscription, Window, WindowBounds, WindowOptions, div, point, }; use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*}; +use url::Url; use util::ResultExt as _; use workspace::{Toast, Workspace, notifications::NotificationId}; @@ -152,6 +153,7 @@ pub struct CopilotCodeVerification { focus_handle: FocusHandle, copilot: Entity, _subscription: Subscription, + sign_up_url: Option, } impl Focusable for CopilotCodeVerification { @@ -183,11 +185,22 @@ impl CopilotCodeVerification { .detach(); let status = copilot.read(cx).status(); + // Determine sign-up URL based on verification_uri domain if available + let sign_up_url = if let Status::SigningIn { + prompt: Some(ref prompt), + } = status + { + // Extract domain from verification_uri to construct sign-up URL + Self::get_sign_up_url_from_verification(&prompt.verification_uri) + } else { + None + }; Self { status, connect_clicked: false, focus_handle: cx.focus_handle(), copilot: copilot.clone(), + sign_up_url, _subscription: cx.observe(copilot, |this, copilot, cx| { let status = copilot.read(cx).status(); match status { @@ -201,10 +214,30 @@ impl CopilotCodeVerification { } pub fn set_status(&mut self, status: Status, cx: &mut Context) { + // Update sign-up URL if we have a new verification URI + if let Status::SigningIn { + prompt: Some(ref prompt), + } = status + { + self.sign_up_url = Self::get_sign_up_url_from_verification(&prompt.verification_uri); + } self.status = status; cx.notify(); } + fn get_sign_up_url_from_verification(verification_uri: &str) -> Option { + // Extract domain from verification URI using url crate + if let Ok(url) = Url::parse(verification_uri) + && let Some(host) = url.host_str() + && !host.contains("github.com") + { + // For GHE, construct URL from domain + Some(format!("https://{}/features/copilot", host)) + } else { + None + } + } + fn render_device_code(data: &PromptUserDeviceFlow, cx: &mut Context) -> impl IntoElement { let copied = cx .read_from_clipboard() @@ -302,7 +335,12 @@ impl CopilotCodeVerification { ) } - fn render_unauthorized_modal(cx: &mut Context) -> impl Element { + fn render_unauthorized_modal(&self, cx: &mut Context) -> impl Element { + let sign_up_url = self + .sign_up_url + .as_deref() + .unwrap_or(COPILOT_SIGN_UP_URL) + .to_owned(); let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription."; v_flex() @@ -319,7 +357,7 @@ impl CopilotCodeVerification { .full_width() .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)), + .on_click(move |_, _, cx| cx.open_url(&sign_up_url)), ) .child( Button::new("copilot-subscribe-cancel-button", "Cancel") @@ -374,7 +412,7 @@ impl Render for CopilotCodeVerification { } => Self::render_prompting_modal(self.connect_clicked, prompt, cx).into_any_element(), Status::Unauthorized => { self.connect_clicked = false; - Self::render_unauthorized_modal(cx).into_any_element() + self.render_unauthorized_modal(cx).into_any_element() } Status::Authorized => { self.connect_clicked = false; diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index 0fd8783dd514f8da3c53d41dcb6f8e9004ae501c..ca28f2805adca78846a66e7b1f4d9f3fc57bb557 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -6,7 +6,7 @@ use crate::{ use anyhow::Result; use collections::HashMap; use editor::{ - Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, multibuffer_context_lines, }; @@ -701,8 +701,12 @@ impl Item for BufferDiagnosticsEditor { }); } - fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + if EditorSettings::get_global(cx).toolbar.breadcrumbs { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } } fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 76edf4f9b438aca1c47393c9c14c6321d0013eb8..0999bebdb6aa9ca744e3a5121670a1b7357411a9 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -12,7 +12,7 @@ use buffer_diagnostics::BufferDiagnosticsEditor; use collections::{BTreeSet, HashMap, HashSet}; use diagnostic_renderer::DiagnosticBlock; use editor::{ - Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, multibuffer_context_lines, }; @@ -894,8 +894,12 @@ impl Item for ProjectDiagnosticsEditor { Some(Box::new(self.editor.clone())) } - fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + if EditorSettings::get_global(cx).toolbar.breadcrumbs { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } } fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index ff15d04cc1c0f8e7bbeb7f2a29b520a8ec32097a..f5ea7590fcba97ee916af985824e21cdf4ea725f 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -19,6 +19,7 @@ use futures::{ select_biased, }; use gpui::BackgroundExecutor; +use gpui::http_client::Url; use gpui::{ App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, http_client::{self, AsyncBody, Method}, @@ -127,15 +128,6 @@ static EDIT_PREDICTIONS_MODEL_ID: LazyLock = LazyLock::new(|| { } .to_string() }); -static PREDICT_EDITS_URL: LazyLock> = LazyLock::new(|| { - env::var("ZED_PREDICT_EDITS_URL").ok().or_else(|| { - if *USE_OLLAMA { - Some("http://localhost:11434/v1/chat/completions".into()) - } else { - None - } - }) -}); pub struct Zeta2FeatureFlag; @@ -170,6 +162,7 @@ pub struct EditPredictionStore { reject_predictions_tx: mpsc::UnboundedSender, shown_predictions: VecDeque, rated_predictions: HashSet, + custom_predict_edits_url: Option>, } #[derive(Copy, Clone, Default, PartialEq, Eq)] @@ -568,6 +561,20 @@ impl EditPredictionStore { reject_predictions_tx: reject_tx, rated_predictions: Default::default(), shown_predictions: Default::default(), + custom_predict_edits_url: match env::var("ZED_PREDICT_EDITS_URL") { + Ok(custom_url) => Url::parse(&custom_url).log_err().map(Into::into), + Err(_) => { + if *USE_OLLAMA { + Some( + Url::parse("http://localhost:11434/v1/chat/completions") + .unwrap() + .into(), + ) + } else { + None + } + } + }, }; this.configure_context_retrieval(cx); @@ -586,6 +593,11 @@ impl EditPredictionStore { this } + #[cfg(test)] + pub fn set_custom_predict_edits_url(&mut self, url: Url) { + self.custom_predict_edits_url = Some(url.into()); + } + pub fn set_edit_prediction_model(&mut self, model: EditPredictionModel) { self.edit_prediction_model = model; } @@ -1015,8 +1027,13 @@ impl EditPredictionStore { } fn accept_current_prediction(&mut self, project: &Entity, cx: &mut Context) { + let custom_accept_url = env::var("ZED_ACCEPT_PREDICTION_URL").ok(); match self.edit_prediction_model { - EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => {} + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() && custom_accept_url.is_none() { + return; + } + } EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, } @@ -1036,12 +1053,15 @@ impl EditPredictionStore { let llm_token = self.llm_token.clone(); let app_version = AppVersion::global(cx); cx.spawn(async move |this, cx| { - let url = if let Ok(predict_edits_url) = env::var("ZED_ACCEPT_PREDICTION_URL") { - http_client::Url::parse(&predict_edits_url)? + let (url, require_auth) = if let Some(accept_edits_url) = custom_accept_url { + (http_client::Url::parse(&accept_edits_url)?, false) } else { - client - .http_client() - .build_zed_llm_url("/predict_edits/accept", &[])? + ( + client + .http_client() + .build_zed_llm_url("/predict_edits/accept", &[])?, + true, + ) }; let response = cx @@ -1058,6 +1078,7 @@ impl EditPredictionStore { client, llm_token, app_version, + require_auth, )) .await; @@ -1116,6 +1137,7 @@ impl EditPredictionStore { client.clone(), llm_token.clone(), app_version.clone(), + true, ) .await; @@ -1161,7 +1183,11 @@ impl EditPredictionStore { was_shown: bool, ) { match self.edit_prediction_model { - EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => {} + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() { + return; + } + } EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, } @@ -1671,13 +1697,9 @@ impl EditPredictionStore { #[cfg(feature = "cli-support")] eval_cache: Option>, #[cfg(feature = "cli-support")] eval_cache_kind: EvalCacheEntryKind, ) -> Result<(open_ai::Response, Option)> { - let url = if let Some(predict_edits_url) = PREDICT_EDITS_URL.as_ref() { - http_client::Url::parse(&predict_edits_url)? - } else { - client - .http_client() - .build_zed_llm_url("/predict_edits/raw", &[])? - }; + let url = client + .http_client() + .build_zed_llm_url("/predict_edits/raw", &[])?; #[cfg(feature = "cli-support")] let cache_key = if let Some(cache) = eval_cache { @@ -1710,6 +1732,7 @@ impl EditPredictionStore { client, llm_token, app_version, + true, ) .await?; @@ -1770,23 +1793,34 @@ impl EditPredictionStore { client: Arc, llm_token: LlmApiToken, app_version: Version, + require_auth: bool, ) -> Result<(Res, Option)> where Res: DeserializeOwned, { let http_client = client.http_client(); - let mut token = llm_token.acquire(&client).await?; + + let mut token = if require_auth { + Some(llm_token.acquire(&client).await?) + } else { + llm_token.acquire(&client).await.ok() + }; let mut did_retry = false; loop { let request_builder = http_client::Request::builder().method(Method::POST); - let request = build( - request_builder - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", token)) - .header(ZED_VERSION_HEADER_NAME, app_version.to_string()), - )?; + let mut request_builder = request_builder + .header("Content-Type", "application/json") + .header(ZED_VERSION_HEADER_NAME, app_version.to_string()); + + // Only add Authorization header if we have a token + if let Some(ref token_value) = token { + request_builder = + request_builder.header("Authorization", format!("Bearer {}", token_value)); + } + + let request = build(request_builder)?; let mut response = http_client.send(request).await?; @@ -1810,13 +1844,14 @@ impl EditPredictionStore { response.body_mut().read_to_end(&mut body).await?; return Ok((serde_json::from_slice(&body)?, usage)); } else if !did_retry + && token.is_some() && response .headers() .get(EXPIRED_LLM_TOKEN_HEADER_NAME) .is_some() { did_retry = true; - token = llm_token.refresh(&client).await?; + token = Some(llm_token.refresh(&client).await?); } else { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 5067aa0050d7a0831ca7668d17188fa6d41637b9..eee3f1f79e93b60ee3ea7c80bd987af22d613833 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1914,6 +1914,174 @@ fn from_completion_edits( .collect() } +#[gpui::test] +async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let http_client = FakeHttpClient::create(|_req| async move { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let result = completion_task.await; + assert!( + result.is_err(), + "Without authentication and without custom URL, prediction should fail" + ); +} + +#[gpui::test] +async fn test_unauthenticated_with_custom_url_allows_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let predict_called = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let predict_called_clone = predict_called.clone(); + + let http_client = FakeHttpClient::create({ + move |req| { + let uri = req.uri().path().to_string(); + let predict_called = predict_called_clone.clone(); + async move { + if uri.contains("predict") { + predict_called.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(gpui::http_client::Response::builder() + .body( + serde_json::to_string(&open_ai::Response { + id: "test-123".to_string(), + object: "chat.completion".to_string(), + created: 0, + model: "test".to_string(), + usage: open_ai::Usage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + choices: vec![open_ai::Choice { + index: 0, + message: open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain( + indoc! {" + ```main.rs + <|start_of_file|> + <|editable_region_start|> + fn main() { + println!(\"Hello, world!\"); + } + <|editable_region_end|> + ``` + "} + .to_string(), + )), + tool_calls: vec![], + }, + finish_reason: Some("stop".to_string()), + }], + }) + .unwrap() + .into(), + ) + .unwrap()) + } else { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + } + } + } + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_custom_predict_edits_url(Url::parse("http://test/predict").unwrap()); + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let _ = completion_task.await; + + assert!( + predict_called.load(std::sync::atomic::Ordering::SeqCst), + "With custom URL, predict endpoint should be called even without authentication" + ); +} + #[ctor::ctor] fn init_logger() { zlog::init_test(); diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index ac9f8f535572dddb56ffcfde9a5f2040a65cf168..b47bd2ad0374eba33e7b8db726c2fa13c0519465 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -309,3 +309,9 @@ pub fn mercury_api_token(cx: &mut App) -> Entity { }) .clone() } + +pub fn load_mercury_api_token(cx: &mut App) -> Task> { + mercury_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx) + }) +} diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index 7d020c219b47aa8bcf6fb89e516b7f8ff93da497..2ed24cd8ef728383ec800acbb2ab7c7b99f07c06 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -282,6 +282,12 @@ pub fn sweep_api_token(cx: &mut App) -> Entity { .clone() } +pub fn load_sweep_api_token(cx: &mut App) -> Task> { + sweep_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx) + }) +} + #[derive(Debug, Clone, Serialize)] struct AutocompleteRequest { pub debug_info: Arc, diff --git a/crates/edit_prediction/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs index ed531749cb39d10d71d18947990dd1972f23a986..01c26573307e66cd6ca3bf8ab748ba8d082ea688 100644 --- a/crates/edit_prediction/src/zeta1.rs +++ b/crates/edit_prediction/src/zeta1.rs @@ -78,6 +78,19 @@ pub(crate) fn request_prediction_with_zeta1( cx, ); + let (uri, require_auth) = match &store.custom_predict_edits_url { + Some(custom_url) => (custom_url.clone(), false), + None => { + match client + .http_client() + .build_zed_llm_url("/predict_edits/v2", &[]) + { + Ok(url) => (url.into(), true), + Err(err) => return Task::ready(Err(err)), + } + } + }; + cx.spawn(async move |this, cx| { let GatherContextOutput { mut body, @@ -102,25 +115,16 @@ pub(crate) fn request_prediction_with_zeta1( body.input_excerpt ); - let http_client = client.http_client(); - let response = EditPredictionStore::send_api_request::( |request| { - let uri = if let Ok(predict_edits_url) = std::env::var("ZED_PREDICT_EDITS_URL") { - predict_edits_url - } else { - http_client - .build_zed_llm_url("/predict_edits/v2", &[])? - .as_str() - .into() - }; Ok(request - .uri(uri) + .uri(uri.as_str()) .body(serde_json::to_string(&body)?.into())?) }, client, llm_token, app_version, + require_auth, ) .await; diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs index fbcb3c4c00edbc5fb77f04d1fcaaf4b6129c43db..945cfea4a168af4470d98ca844f311a79de9800a 100644 --- a/crates/edit_prediction_types/src/edit_prediction_types.rs +++ b/crates/edit_prediction_types/src/edit_prediction_types.rs @@ -249,6 +249,12 @@ where } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditPredictionGranularity { + Word, + Line, + Full, +} /// Returns edits updated based on user edits since the old snapshot. None is returned if any user /// edit is not a prefix of a predicted insertion. pub fn interpolate_edits( diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index bbf9f4677df278c014379964e7bdc714e6ce78d8..0dcea477200eef9d1eeb6adeff98f47332d751ca 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -487,6 +487,21 @@ impl EditPredictionButton { cx.observe_global::(move |_, cx| cx.notify()) .detach(); + cx.observe_global::(move |_, cx| cx.notify()) + .detach(); + + let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx); + let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx); + + cx.spawn(async move |this, cx| { + _ = futures::join!(sweep_api_token_task, mercury_api_token_task); + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }) + .detach(); + CodestralEditPredictionDelegate::ensure_api_key_loaded(client.http_client(), cx); Self { @@ -503,7 +518,7 @@ impl EditPredictionButton { } } - fn get_available_providers(&self, cx: &App) -> Vec { + fn get_available_providers(&self, cx: &mut App) -> Vec { let mut providers = Vec::new(); providers.push(EditPredictionProvider::Zed); @@ -532,12 +547,10 @@ impl EditPredictionButton { providers.push(EditPredictionProvider::Codestral); } - let ep_store = EditPredictionStore::try_global(cx); - if cx.has_flag::() - && ep_store - .as_ref() - .is_some_and(|ep_store| ep_store.read(cx).has_sweep_api_token(cx)) + && edit_prediction::sweep_ai::sweep_api_token(cx) + .read(cx) + .has_key() { providers.push(EditPredictionProvider::Experimental( EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, @@ -545,9 +558,9 @@ impl EditPredictionButton { } if cx.has_flag::() - && ep_store - .as_ref() - .is_some_and(|ep_store| ep_store.read(cx).has_mercury_api_token(cx)) + && edit_prediction::mercury::mercury_api_token(cx) + .read(cx) + .has_key() { providers.push(EditPredictionProvider::Experimental( EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index fb058eb8d7c5ad72a2b2656c3ce943871a623163..ba36f88f6380ade2a0d70f0f7ac3eb221446b781 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -370,7 +370,8 @@ actions!( AcceptEditPrediction, /// Accepts a partial edit prediction. #[action(deprecated_aliases = ["editor::AcceptPartialCopilotSuggestion"])] - AcceptPartialEditPrediction, + AcceptNextWordEditPrediction, + AcceptNextLineEditPrediction, /// Applies all diff hunks in the editor. ApplyAllDiffHunks, /// Applies the diff hunk at the current position. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index afa62e5ff31436ef178a94dc0ff8bedfc2691e60..f4a83f900da68d90803b82c0aec1287fcaa71cd3 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -92,7 +92,9 @@ use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; use dap::TelemetrySpawnLocation; use display_map::*; -use edit_prediction_types::{EditPredictionDelegate, EditPredictionDelegateHandle}; +use edit_prediction_types::{ + EditPredictionDelegate, EditPredictionDelegateHandle, EditPredictionGranularity, +}; use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings}; use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layout_line}; use futures::{ @@ -2778,21 +2780,24 @@ impl Editor { pub fn accept_edit_prediction_keybind( &self, - accept_partial: bool, + granularity: EditPredictionGranularity, window: &mut Window, cx: &mut App, ) -> AcceptEditPredictionBinding { let key_context = self.key_context_internal(true, window, cx); let in_conflict = self.edit_prediction_in_conflict(); - let bindings = if accept_partial { - window.bindings_for_action_in_context(&AcceptPartialEditPrediction, key_context) - } else { - window.bindings_for_action_in_context(&AcceptEditPrediction, key_context) - }; + let bindings = + match granularity { + EditPredictionGranularity::Word => window + .bindings_for_action_in_context(&AcceptNextWordEditPrediction, key_context), + EditPredictionGranularity::Line => window + .bindings_for_action_in_context(&AcceptNextLineEditPrediction, key_context), + EditPredictionGranularity::Full => { + window.bindings_for_action_in_context(&AcceptEditPrediction, key_context) + } + }; - // TODO: if the binding contains multiple keystrokes, display all of them, not - // just the first one. AcceptEditPredictionBinding(bindings.into_iter().rev().find(|binding| { !in_conflict || binding @@ -3422,7 +3427,8 @@ impl Editor { data.selections = inmemory_selections; }); - if WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None + if WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::EmptyTab && let Some(workspace_id) = self.workspace_serialization_id(cx) { let snapshot = self.buffer().read(cx).snapshot(cx); @@ -3462,7 +3468,8 @@ impl Editor { use text::ToPoint as _; if self.mode.is_minimap() - || WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None + || WorkspaceSettings::get(None, cx).restore_on_startup + == RestoreOnStartupBehavior::EmptyTab { return; } @@ -4399,10 +4406,50 @@ impl Editor { && bracket_pair.start.len() == 1 { let target = bracket_pair.start.chars().next().unwrap(); + let mut byte_offset = 0u32; let current_line_count = snapshot .reversed_chars_at(selection.start) .take_while(|&c| c != '\n') - .filter(|&c| c == target) + .filter(|c| { + byte_offset += c.len_utf8() as u32; + if *c != target { + return false; + } + + let point = Point::new( + selection.start.row, + selection.start.column.saturating_sub(byte_offset), + ); + + let is_enabled = snapshot + .language_scope_at(point) + .and_then(|scope| { + scope + .brackets() + .find(|(pair, _)| { + pair.start == bracket_pair.start + }) + .map(|(_, enabled)| enabled) + }) + .unwrap_or(true); + + let is_delimiter = snapshot + .language_scope_at(Point::new( + point.row, + point.column + 1, + )) + .and_then(|scope| { + scope + .brackets() + .find(|(pair, _)| { + pair.start == bracket_pair.start + }) + .map(|(_, enabled)| !enabled) + }) + .unwrap_or(false); + + is_enabled && !is_delimiter + }) .count(); current_line_count % 2 == 1 } else { @@ -7633,9 +7680,9 @@ impl Editor { } } - pub fn accept_edit_prediction( + pub fn accept_partial_edit_prediction( &mut self, - _: &AcceptEditPrediction, + granularity: EditPredictionGranularity, window: &mut Window, cx: &mut Context, ) { @@ -7647,47 +7694,59 @@ impl Editor { return; }; + if !matches!(granularity, EditPredictionGranularity::Full) && self.selections.count() != 1 { + return; + } + match &active_edit_prediction.completion { EditPrediction::MoveWithin { target, .. } => { let target = *target; - if let Some(position_map) = &self.last_position_map { - if position_map - .visible_row_range - .contains(&target.to_display_point(&position_map.snapshot).row()) - || !self.edit_prediction_requires_modifier() - { - self.unfold_ranges(&[target..target], true, false, cx); - // Note that this is also done in vim's handler of the Tab action. - self.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_anchor_ranges([target..target]); - }, - ); - self.clear_row_highlights::(); + if matches!(granularity, EditPredictionGranularity::Full) { + if let Some(position_map) = &self.last_position_map { + let target_row = target.to_display_point(&position_map.snapshot).row(); + let is_visible = position_map.visible_row_range.contains(&target_row); - self.edit_prediction_preview - .set_previous_scroll_position(None); - } else { - self.edit_prediction_preview - .set_previous_scroll_position(Some( - position_map.snapshot.scroll_anchor, - )); - - self.highlight_rows::( - target..target, - cx.theme().colors().editor_highlighted_line_background, - RowHighlightOptions { - autoscroll: true, - ..Default::default() - }, - cx, - ); - self.request_autoscroll(Autoscroll::fit(), cx); + if is_visible || !self.edit_prediction_requires_modifier() { + self.unfold_ranges(&[target..target], true, false, cx); + self.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_anchor_ranges([target..target]); + }, + ); + self.clear_row_highlights::(); + self.edit_prediction_preview + .set_previous_scroll_position(None); + } else { + // Highlight and request scroll + self.edit_prediction_preview + .set_previous_scroll_position(Some( + position_map.snapshot.scroll_anchor, + )); + self.highlight_rows::( + target..target, + cx.theme().colors().editor_highlighted_line_background, + RowHighlightOptions { + autoscroll: true, + ..Default::default() + }, + cx, + ); + self.request_autoscroll(Autoscroll::fit(), cx); + } } + } else { + self.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_anchor_ranges([target..target]); + }, + ); } } EditPrediction::MoveOutside { snapshot, target } => { @@ -7703,126 +7762,131 @@ impl Editor { cx, ); - if let Some(provider) = self.edit_prediction_provider() { - provider.accept(cx); - } + match granularity { + EditPredictionGranularity::Full => { + if let Some(provider) = self.edit_prediction_provider() { + provider.accept(cx); + } - // Store the transaction ID and selections before applying the edit - let transaction_id_prev = self.buffer.read(cx).last_transaction_id(cx); + let transaction_id_prev = self.buffer.read(cx).last_transaction_id(cx); + let snapshot = self.buffer.read(cx).snapshot(cx); + let last_edit_end = edits.last().unwrap().0.end.bias_right(&snapshot); - let snapshot = self.buffer.read(cx).snapshot(cx); - let last_edit_end = edits.last().unwrap().0.end.bias_right(&snapshot); + self.buffer.update(cx, |buffer, cx| { + buffer.edit(edits.iter().cloned(), None, cx) + }); - self.buffer.update(cx, |buffer, cx| { - buffer.edit(edits.iter().cloned(), None, cx) - }); + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_anchor_ranges([last_edit_end..last_edit_end]); + }); - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_anchor_ranges([last_edit_end..last_edit_end]); - }); + let selections = self.selections.disjoint_anchors_arc(); + if let Some(transaction_id_now) = + self.buffer.read(cx).last_transaction_id(cx) + { + if transaction_id_prev != Some(transaction_id_now) { + self.selection_history + .insert_transaction(transaction_id_now, selections); + } + } - let selections = self.selections.disjoint_anchors_arc(); - if let Some(transaction_id_now) = self.buffer.read(cx).last_transaction_id(cx) { - let has_new_transaction = transaction_id_prev != Some(transaction_id_now); - if has_new_transaction { - self.selection_history - .insert_transaction(transaction_id_now, selections); + self.update_visible_edit_prediction(window, cx); + if self.active_edit_prediction.is_none() { + self.refresh_edit_prediction(true, true, window, cx); + } + cx.notify(); } - } + _ => { + let snapshot = self.buffer.read(cx).snapshot(cx); + let cursor_offset = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); + + let insertion = edits.iter().find_map(|(range, text)| { + let range = range.to_offset(&snapshot); + if range.is_empty() && range.start == cursor_offset { + Some(text) + } else { + None + } + }); - self.update_visible_edit_prediction(window, cx); - if self.active_edit_prediction.is_none() { - self.refresh_edit_prediction(true, true, window, cx); - } + if let Some(text) = insertion { + let text_to_insert = match granularity { + EditPredictionGranularity::Word => { + let mut partial = text + .chars() + .by_ref() + .take_while(|c| c.is_alphabetic()) + .collect::(); + if partial.is_empty() { + partial = text + .chars() + .by_ref() + .take_while(|c| c.is_whitespace() || !c.is_alphabetic()) + .collect::(); + } + partial + } + EditPredictionGranularity::Line => { + if let Some(line) = text.split_inclusive('\n').next() { + line.to_string() + } else { + text.to_string() + } + } + EditPredictionGranularity::Full => unreachable!(), + }; - cx.notify(); + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: None, + text: text_to_insert.clone().into(), + }); + + self.insert_with_autoindent_mode(&text_to_insert, None, window, cx); + self.refresh_edit_prediction(true, true, window, cx); + cx.notify(); + } else { + self.accept_partial_edit_prediction( + EditPredictionGranularity::Full, + window, + cx, + ); + } + } + } } } self.edit_prediction_requires_modifier_in_indent_conflict = false; } - pub fn accept_partial_edit_prediction( + pub fn accept_next_word_edit_prediction( &mut self, - _: &AcceptPartialEditPrediction, + _: &AcceptNextWordEditPrediction, window: &mut Window, cx: &mut Context, ) { - let Some(active_edit_prediction) = self.active_edit_prediction.as_ref() else { - return; - }; - if self.selections.count() != 1 { - return; - } - - match &active_edit_prediction.completion { - EditPrediction::MoveWithin { target, .. } => { - let target = *target; - self.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_anchor_ranges([target..target]); - }, - ); - } - EditPrediction::MoveOutside { snapshot, target } => { - if let Some(workspace) = self.workspace() { - Self::open_editor_at_anchor(snapshot, *target, &workspace, window, cx) - .detach_and_log_err(cx); - } - } - EditPrediction::Edit { edits, .. } => { - self.report_edit_prediction_event( - active_edit_prediction.completion_id.clone(), - true, - cx, - ); - - // Find an insertion that starts at the cursor position. - let snapshot = self.buffer.read(cx).snapshot(cx); - let cursor_offset = self - .selections - .newest::(&self.display_snapshot(cx)) - .head(); - let insertion = edits.iter().find_map(|(range, text)| { - let range = range.to_offset(&snapshot); - if range.is_empty() && range.start == cursor_offset { - Some(text) - } else { - None - } - }); - - if let Some(text) = insertion { - let mut partial_completion = text - .chars() - .by_ref() - .take_while(|c| c.is_alphabetic()) - .collect::(); - if partial_completion.is_empty() { - partial_completion = text - .chars() - .by_ref() - .take_while(|c| c.is_whitespace() || !c.is_alphabetic()) - .collect::(); - } - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: None, - text: partial_completion.clone().into(), - }); + self.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + } - self.insert_with_autoindent_mode(&partial_completion, None, window, cx); + pub fn accept_next_line_edit_prediction( + &mut self, + _: &AcceptNextLineEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + self.accept_partial_edit_prediction(EditPredictionGranularity::Line, window, cx); + } - self.refresh_edit_prediction(true, true, window, cx); - cx.notify(); - } else { - self.accept_edit_prediction(&Default::default(), window, cx); - } - } - } + pub fn accept_edit_prediction( + &mut self, + _: &AcceptEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + self.accept_partial_edit_prediction(EditPredictionGranularity::Full, window, cx); } fn discard_edit_prediction( @@ -8042,21 +8106,23 @@ impl Editor { cx: &mut Context, ) { let mut modifiers_held = false; - if let Some(accept_keystroke) = self - .accept_edit_prediction_keybind(false, window, cx) - .keystroke() - { - modifiers_held = modifiers_held - || (accept_keystroke.modifiers() == modifiers - && accept_keystroke.modifiers().modified()); - }; - if let Some(accept_partial_keystroke) = self - .accept_edit_prediction_keybind(true, window, cx) - .keystroke() - { - modifiers_held = modifiers_held - || (accept_partial_keystroke.modifiers() == modifiers - && accept_partial_keystroke.modifiers().modified()); + + // Check bindings for all granularities. + // If the user holds the key for Word, Line, or Full, we want to show the preview. + let granularities = [ + EditPredictionGranularity::Full, + EditPredictionGranularity::Line, + EditPredictionGranularity::Word, + ]; + + for granularity in granularities { + if let Some(keystroke) = self + .accept_edit_prediction_keybind(granularity, window, cx) + .keystroke() + { + modifiers_held = modifiers_held + || (keystroke.modifiers() == modifiers && keystroke.modifiers().modified()); + } } if modifiers_held { @@ -9476,7 +9542,8 @@ impl Editor { window: &mut Window, cx: &mut App, ) -> Option { - let accept_binding = self.accept_edit_prediction_keybind(false, window, cx); + let accept_binding = + self.accept_edit_prediction_keybind(EditPredictionGranularity::Full, window, cx); let accept_keystroke = accept_binding.keystroke()?; let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; @@ -17412,7 +17479,14 @@ impl Editor { // If there is one url or file, open it directly match first_url_or_file { Some(Either::Left(url)) => { - cx.update(|_, cx| cx.open_url(&url))?; + cx.update(|window, cx| { + if parse_zed_link(&url, cx).is_some() { + window + .dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx); + } else { + cx.open_url(&url); + } + })?; Ok(Navigated::Yes) } Some(Either::Right(path)) => { @@ -23091,7 +23165,8 @@ impl Editor { ) { if self.buffer_kind(cx) == ItemBufferKind::Singleton && !self.mode.is_minimap() - && WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None + && WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::EmptyTab { let buffer_snapshot = OnceCell::new(); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 89131e8bc39fc03e54e19c9d8b1f79a7f2d66cb9..dfc8fd7f901bf1f45352511e3b7e69f7f4d4b367 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -7750,10 +7750,12 @@ fn test_select_line(cx: &mut TestAppContext) { ]) }); editor.select_line(&SelectLine, window, cx); + // Adjacent line selections should NOT merge (only overlapping ones do) assert_eq!( display_ranges(editor, cx), vec![ - DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(2), 0), + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(2), 0), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 0), ] ); @@ -7772,9 +7774,13 @@ fn test_select_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.select_line(&SelectLine, window, cx); + // Adjacent but not overlapping, so they stay separate assert_eq!( display_ranges(editor, cx), - vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(5), 5)] + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(4), 0), + DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5), + ] ); }); } @@ -10863,6 +10869,115 @@ async fn test_autoclose_with_overrides(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_autoclose_quotes_with_scope_awareness(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Double quote inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['"', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"', "ˇ"] + "#}); + + // Two double quotes inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['""', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['""', "ˇ"] + "#}); + + // Single quote inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["'", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["'", 'ˇ'] + "#}); + + // Two single quotes inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["''", 'ˇ'] + "#}); + + // Mixed quotes on same line + cx.set_state(indoc! {r#" + def main(): + items = ['"""', "'''''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "ˇ"] + "#}); + cx.update_editor(|editor, window, cx| { + editor.move_right(&MoveRight, window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(", ", window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "", 'ˇ'] + "#}); +} + +#[gpui::test] +async fn test_autoclose_quotes_with_multibyte_characters(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state(indoc! {r#" + def main(): + items = ["🎉", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["🎉", "ˇ"] + "#}); +} + #[gpui::test] async fn test_surround_with_pair(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -16196,7 +16311,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { «b(); - c(); + ˇ»«c(); ˇ» d(); } "}); @@ -16208,8 +16323,8 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { // «b(); - // c(); - ˇ»// d(); + ˇ»// «c(); + ˇ» // d(); } "}); @@ -16218,7 +16333,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { fn a() { // b(); «// c(); - ˇ» // d(); + ˇ» // d(); } "}); @@ -16228,7 +16343,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { fn a() { // b(); «c(); - ˇ» // d(); + ˇ» // d(); } "}); @@ -22118,6 +22233,40 @@ async fn test_toggle_deletion_hunk_at_start_of_file( cx.assert_state_with_diff(hunk_expanded); } +#[gpui::test] +async fn test_expand_first_line_diff_hunk_keeps_deleted_lines_visible( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇnew\nsecond\nthird\n"); + cx.set_head_text("old\nsecond\nthird\n"); + cx.update_editor(|editor, window, cx| { + editor.scroll(gpui::Point { x: 0., y: 0. }, None, window, cx); + }); + executor.run_until_parked(); + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); + + // Expanding a diff hunk at the first line inserts deleted lines above the first buffer line. + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; + let hunks = editor + .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) + .collect::>(); + assert_eq!(hunks.len(), 1); + let hunk_range = Anchor::range_in_buffer(excerpt_id, hunks[0].buffer_range.clone()); + editor.toggle_single_diff_hunk(hunk_range, cx) + }); + executor.run_until_parked(); + cx.assert_state_with_diff("- old\n+ ˇnew\n second\n third\n".to_string()); + + // Keep the editor scrolled to the top so the full hunk remains visible. + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); +} + #[gpui::test] async fn test_display_diff_hunks(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 3b16fa1be173ab1a5edbc9bbaad20a3d6b1493e7..8de660275ba9b455aec610568c41347888654495 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -62,6 +62,7 @@ use multi_buffer::{ MultiBufferRow, RowInfo, }; +use edit_prediction_types::EditPredictionGranularity; use project::{ Entry, ProjectPath, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, @@ -603,7 +604,8 @@ impl EditorElement { register_action(editor, window, Editor::display_cursor_names); register_action(editor, window, Editor::unique_lines_case_insensitive); register_action(editor, window, Editor::unique_lines_case_sensitive); - register_action(editor, window, Editor::accept_partial_edit_prediction); + register_action(editor, window, Editor::accept_next_word_edit_prediction); + register_action(editor, window, Editor::accept_next_line_edit_prediction); register_action(editor, window, Editor::accept_edit_prediction); register_action(editor, window, Editor::restore_file); register_action(editor, window, Editor::git_restore); @@ -4900,8 +4902,11 @@ impl EditorElement { let edit_prediction = if edit_prediction_popover_visible { self.editor.update(cx, move |editor, cx| { - let accept_binding = - editor.accept_edit_prediction_keybind(false, window, cx); + let accept_binding = editor.accept_edit_prediction_keybind( + EditPredictionGranularity::Full, + window, + cx, + ); let mut element = editor.render_edit_prediction_cursor_popover( min_width, max_width, diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 67df69aadab43a45c2941703e10bb81af2b8dd78..031795ff2dbfceb96f950db18101b37fd3cdcf84 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -1,7 +1,7 @@ use crate::Editor; -use anyhow::Result; +use anyhow::{Context as _, Result}; use collections::HashMap; -use futures::StreamExt; + use git::{ GitHostingProviderRegistry, GitRemote, Oid, blame::{Blame, BlameEntry, ParsedCommitMessage}, @@ -494,84 +494,102 @@ impl GitBlame { self.changed_while_blurred = true; return; } - let blame = self.project.update(cx, |project, cx| { - let Some(multi_buffer) = self.multi_buffer.upgrade() else { - return Vec::new(); - }; - multi_buffer - .read(cx) - .all_buffer_ids() - .into_iter() - .filter_map(|id| { - let buffer = multi_buffer.read(cx).buffer(id)?; - let snapshot = buffer.read(cx).snapshot(); - let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); - - let blame_buffer = project.blame_buffer(&buffer, None, cx); - let remote_url = project - .git_store() - .read(cx) - .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) - .and_then(|(repo, _)| { - repo.read(cx) - .remote_upstream_url - .clone() - .or(repo.read(cx).remote_origin_url.clone()) - }); - Some( - async move { (id, snapshot, buffer_edits, blame_buffer.await, remote_url) }, - ) - }) - .collect::>() - }); - let provider_registry = GitHostingProviderRegistry::default_global(cx); + let buffers_to_blame = self + .multi_buffer + .update(cx, |multi_buffer, _| { + multi_buffer + .all_buffer_ids() + .into_iter() + .filter_map(|id| Some(multi_buffer.buffer(id)?.downgrade())) + .collect::>() + }) + .unwrap_or_default(); + let project = self.project.downgrade(); self.task = cx.spawn(async move |this, cx| { - let (result, errors) = cx - .background_spawn({ - async move { - let blame = futures::stream::iter(blame) - .buffered(4) - .collect::>() - .await; - let mut res = vec![]; - let mut errors = vec![]; - for (id, snapshot, buffer_edits, blame, remote_url) in blame { - match blame { - Ok(Some(Blame { entries, messages })) => { - let entries = build_blame_entry_sum_tree( - entries, - snapshot.max_point().row, - ); - let commit_details = parse_commit_messages( - messages, - remote_url, - provider_registry.clone(), - ) - .await; - - res.push(( + let mut all_results = Vec::new(); + let mut all_errors = Vec::new(); + + for buffers in buffers_to_blame.chunks(4) { + let blame = cx.update(|cx| { + buffers + .iter() + .map(|buffer| { + let buffer = buffer.upgrade().context("buffer was dropped")?; + let project = project.upgrade().context("project was dropped")?; + let id = buffer.read(cx).remote_id(); + let snapshot = buffer.read(cx).snapshot(); + let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); + let remote_url = project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) + .and_then(|(repo, _)| { + repo.read(cx) + .remote_upstream_url + .clone() + .or(repo.read(cx).remote_origin_url.clone()) + }); + let blame_buffer = project + .update(cx, |project, cx| project.blame_buffer(&buffer, None, cx)); + Ok(async move { + (id, snapshot, buffer_edits, blame_buffer.await, remote_url) + }) + }) + .collect::>>() + })??; + let provider_registry = + cx.update(|cx| GitHostingProviderRegistry::default_global(cx))?; + let (results, errors) = cx + .background_spawn({ + async move { + let blame = futures::future::join_all(blame).await; + let mut res = vec![]; + let mut errors = vec![]; + for (id, snapshot, buffer_edits, blame, remote_url) in blame { + match blame { + Ok(Some(Blame { entries, messages })) => { + let entries = build_blame_entry_sum_tree( + entries, + snapshot.max_point().row, + ); + let commit_details = parse_commit_messages( + messages, + remote_url, + provider_registry.clone(), + ) + .await; + + res.push(( + id, + snapshot, + buffer_edits, + Some(entries), + commit_details, + )); + } + Ok(None) => res.push(( id, snapshot, buffer_edits, - Some(entries), - commit_details, - )); - } - Ok(None) => { - res.push((id, snapshot, buffer_edits, None, Default::default())) + None, + Default::default(), + )), + Err(e) => errors.push(e), } - Err(e) => errors.push(e), } + (res, errors) } - (res, errors) - } - }) - .await; + }) + .await; + all_results.extend(results); + all_errors.extend(errors) + } this.update(cx, |this, cx| { this.buffers.clear(); - for (id, snapshot, buffer_edits, entries, commit_details) in result { + for (id, snapshot, buffer_edits, entries, commit_details) in all_results { let Some(entries) = entries else { continue; }; @@ -586,11 +604,11 @@ impl GitBlame { ); } cx.notify(); - if !errors.is_empty() { + if !all_errors.is_empty() { this.project.update(cx, |_, cx| { if this.user_triggered { - log::error!("failed to get git blame data: {errors:?}"); - let notification = errors + log::error!("failed to get git blame data: {all_errors:?}"); + let notification = all_errors .into_iter() .format_with(",", |e, f| f(&format_args!("{:#}", e))) .to_string(); @@ -601,7 +619,7 @@ impl GitBlame { } else { // If we weren't triggered by a user, we just log errors in the background, instead of sending // notifications. - log::debug!("failed to get git blame data: {errors:?}"); + log::debug!("failed to get git blame data: {all_errors:?}"); } }) } diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index ba361aa04dee3bfa3a819c8afb7061c238681b77..d7e4169a721765e0f93805bf0c157033bf0cafab 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -9,8 +9,10 @@ use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; use project::{InlayId, LocationLink, Project, ResolvedPath}; +use regex::Regex; use settings::Settings; -use std::ops::Range; +use std::{ops::Range, sync::LazyLock}; +use text::OffsetRangeExt; use theme::ActiveTheme as _; use util::{ResultExt, TryFutureExt as _, maybe}; @@ -595,7 +597,8 @@ pub(crate) async fn find_file( let project = project?; let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()).ok()?; let scope = snapshot.language_scope_at(position); - let (range, candidate_file_path) = surrounding_filename(snapshot, position)?; + let (range, candidate_file_path) = surrounding_filename(&snapshot, position)?; + let candidate_len = candidate_file_path.len(); async fn check_path( candidate_file_path: &str, @@ -612,29 +615,66 @@ pub(crate) async fn find_file( .filter(|s| s.is_file()) } - if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await { - return Some((range, existing_path)); + let pattern_candidates = link_pattern_file_candidates(&candidate_file_path); + + for (pattern_candidate, pattern_range) in &pattern_candidates { + if let Some(existing_path) = check_path(&pattern_candidate, &project, buffer, cx).await { + let offset_range = range.to_offset(&snapshot); + let actual_start = offset_range.start + pattern_range.start; + let actual_end = offset_range.end - (candidate_len - pattern_range.end); + return Some(( + snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), + existing_path, + )); + } } - if let Some(scope) = scope { - for suffix in scope.path_suffixes() { - if candidate_file_path.ends_with(format!(".{suffix}").as_str()) { - continue; - } + for (pattern_candidate, pattern_range) in pattern_candidates { + for suffix in scope.path_suffixes() { + if pattern_candidate.ends_with(format!(".{suffix}").as_str()) { + continue; + } - let suffixed_candidate = format!("{candidate_file_path}.{suffix}"); - if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await - { - return Some((range, existing_path)); + let suffixed_candidate = format!("{pattern_candidate}.{suffix}"); + if let Some(existing_path) = + check_path(&suffixed_candidate, &project, buffer, cx).await + { + let offset_range = range.to_offset(&snapshot); + let actual_start = offset_range.start + pattern_range.start; + let actual_end = offset_range.end - (candidate_len - pattern_range.end); + return Some(( + snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), + existing_path, + )); + } } } } - None } +// Tries to capture potentially inlined links, like those found in markdown, +// e.g. [LinkTitle](link_file.txt) +// Since files can have parens, we should always return the full string +// (literally, [LinkTitle](link_file.txt)) as a candidate. +fn link_pattern_file_candidates(candidate: &str) -> Vec<(String, Range)> { + static MD_LINK_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\(([^)]*)\)").expect("Failed to create REGEX")); + + let candidate_len = candidate.len(); + + let mut candidates = vec![(candidate.to_string(), 0..candidate_len)]; + + if let Some(captures) = MD_LINK_REGEX.captures(candidate) { + if let Some(link) = captures.get(1) { + candidates.push((link.as_str().to_string(), link.range())); + } + } + candidates +} + fn surrounding_filename( - snapshot: language::BufferSnapshot, + snapshot: &language::BufferSnapshot, position: text::Anchor, ) -> Option<(Range, String)> { const LIMIT: usize = 2048; @@ -1316,6 +1356,58 @@ mod tests { assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into())); } + #[test] + fn test_link_pattern_file_candidates() { + let candidates: Vec = link_pattern_file_candidates("[LinkTitle](link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + assert_eq!( + candidates, + vec!["[LinkTitle](link_file.txt)", "link_file.txt",] + ); + // Link title with spaces in it + let candidates: Vec = link_pattern_file_candidates("LinkTitle](link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + assert_eq!( + candidates, + vec!["LinkTitle](link_file.txt)", "link_file.txt",] + ); + + // Link with spaces + let candidates: Vec = link_pattern_file_candidates("LinkTitle](link\\ _file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!( + candidates, + vec!["LinkTitle](link\\ _file.txt)", "link\\ _file.txt",] + ); + // + // Square brackets not strictly necessary + let candidates: Vec = link_pattern_file_candidates("(link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!(candidates, vec!["(link_file.txt)", "link_file.txt",]); + + // No nesting + let candidates: Vec = + link_pattern_file_candidates("LinkTitle](link_(link_file)file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!( + candidates, + vec!["LinkTitle](link_(link_file)file.txt)", "link_(link_file",] + ) + } + #[gpui::test] async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -1374,7 +1466,7 @@ mod tests { (positions, snapshot) }); - let result = surrounding_filename(snapshot, position); + let result = surrounding_filename(&snapshot, position); if let Some(expected) = expected { assert!(result.is_some(), "Failed to find file path: {}", input); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 7c3e41e8c2edf721fbcae729069eecb640e2246c..64415005ec61b1ce942e4fbedaabc70919f5e61d 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -656,6 +656,7 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { .text_base() .mt(rems(1.)) .mb_0(), + table_columns_min_size: true, ..Default::default() } } @@ -709,6 +710,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { .font_weight(FontWeight::BOLD) .text_base() .mb_0(), + table_columns_min_size: true, ..Default::default() } } diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index a92735d18617057ddd10f049e5a22525827e1874..422be9a54e7cfcc40484e4093eeab6c94ce7d8ee 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -251,7 +251,11 @@ impl ScrollManager { Bias::Left, ) .to_point(map); - let top_anchor = map.buffer_snapshot().anchor_after(scroll_top_buffer_point); + // Anchor the scroll position to the *left* of the first visible buffer point. + // + // This prevents the viewport from shifting down when blocks (e.g. expanded diff hunk + // deletions) are inserted *above* the first buffer character in the file. + let top_anchor = map.buffer_snapshot().anchor_before(scroll_top_buffer_point); self.set_anchor( ScrollAnchor { diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 6f744e11334fc32e7985ee77e25866ef0c6cfe4c..54bb7ceec1d035fbefb0c229c4e537e8277b67cd 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -136,7 +136,13 @@ impl SelectionsCollection { iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { - if pending.start <= next_selection.end && pending.end >= next_selection.start { + if should_merge( + pending.start, + pending.end, + next_selection.start, + next_selection.end, + false, + ) { let next_selection = disjoint.next().unwrap(); if next_selection.start < pending.start { pending.start = next_selection.start; @@ -236,7 +242,13 @@ impl SelectionsCollection { iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { - if pending.start <= next_selection.end && pending.end >= next_selection.start { + if should_merge( + pending.start, + pending.end, + next_selection.start, + next_selection.end, + false, + ) { let next_selection = disjoint.next().unwrap(); if next_selection.start < pending.start { pending.start = next_selection.start; @@ -666,10 +678,13 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { }) .collect::>(); selections.sort_unstable_by_key(|s| s.start); - // Merge overlapping selections. + let mut i = 1; while i < selections.len() { - if selections[i].start <= selections[i - 1].end { + let prev = &selections[i - 1]; + let current = &selections[i]; + + if should_merge(prev.start, prev.end, current.start, current.end, true) { let removed = selections.remove(i); if removed.start < selections[i - 1].start { selections[i - 1].start = removed.start; @@ -1139,7 +1154,13 @@ fn coalesce_selections( iter::from_fn(move || { let mut selection = selections.next()?; while let Some(next_selection) = selections.peek() { - if selection.end >= next_selection.start { + if should_merge( + selection.start, + selection.end, + next_selection.start, + next_selection.end, + true, + ) { if selection.reversed == next_selection.reversed { selection.end = cmp::max(selection.end, next_selection.end); selections.next(); @@ -1161,3 +1182,35 @@ fn coalesce_selections( Some(selection) }) } + +/// Determines whether two selections should be merged into one. +/// +/// Two selections should be merged when: +/// 1. They overlap: the selections share at least one position +/// 2. They have the same start position: one contains or equals the other +/// 3. A cursor touches a selection boundary: a zero-width selection (cursor) at the +/// start or end of another selection should be absorbed into it +/// +/// Note: two selections that merely touch (one ends exactly where the other begins) +/// but don't share any positions remain separate, see: https://github.com/zed-industries/zed/issues/24748 +fn should_merge(a_start: T, a_end: T, b_start: T, b_end: T, sorted: bool) -> bool { + let is_overlapping = if sorted { + // When sorted, `a` starts before or at `b`, so overlap means `b` starts before `a` ends + b_start < a_end + } else { + a_start < b_end && b_start < a_end + }; + + // Selections starting at the same position should always merge (one contains the other) + let same_start = a_start == b_start; + + // A cursor (zero-width selection) touching another selection's boundary should merge. + // This handles cases like a cursor at position X merging with a selection that + // starts or ends at X. + let is_cursor_a = a_start == a_end; + let is_cursor_b = b_start == b_end; + let cursor_at_boundary = (is_cursor_a && (a_start == b_start || a_end == b_end)) + || (is_cursor_b && (b_start == a_start || b_end == a_end)); + + is_overlapping || same_start || cursor_at_boundary +} diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index 1af705cd4bfdb4419c767feb41d1428181866c08..4c71a5a82b3946a9cc6e22ced378ebaabeec5256 100644 --- a/crates/eval/src/instance.rs +++ b/crates/eval/src/instance.rs @@ -625,6 +625,15 @@ impl agent::TerminalHandle for EvalTerminalHandle { self.terminal .read_with(cx, |term, cx| term.current_output(cx)) } + + fn kill(&self, cx: &AsyncApp) -> Result<()> { + cx.update(|cx| { + self.terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + })?; + Ok(()) + } } impl agent::ThreadEnvironment for EvalThreadEnvironment { diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index e8357e359696bfcfbc7cfd829f84222c1303402a..e6f69a14593a0246ae8ccb4aa4673f4e1f5a1e8e 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -641,6 +641,8 @@ impl Fs for RealFs { use objc::{class, msg_send, sel, sel_impl}; unsafe { + /// Allow NSString::alloc use here because it sets autorelease + #[allow(clippy::disallowed_methods)] unsafe fn ns_string(string: &str) -> id { unsafe { NSString::alloc(nil).init_str(string).autorelease() } } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 6747daa09d2801ad8c05c17fb04cb3ab235cdbff..c88244a036767be0ef862e74faa2113d54125443 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -43,6 +43,7 @@ notifications.workspace = true panel.workspace = true picker.workspace = true project.workspace = true +prompt_store.workspace = true recent_projects.workspace = true remote.workspace = true schemars.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index cf588d6b0448c2a7c8e7feb50d34c6e405845116..362423b79fed0e8f3428d6784dd6f15b47708247 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -57,6 +57,7 @@ use project::{ git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op}, project_settings::{GitPathStyle, ProjectSettings}, }; +use prompt_store::RULES_FILE_NAMES; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore, StatusStyle}; use std::future::Future; @@ -71,7 +72,7 @@ use ui::{ prelude::*, }; use util::paths::PathStyle; -use util::{ResultExt, TryFutureExt, maybe}; +use util::{ResultExt, TryFutureExt, maybe, rel_path::RelPath}; use workspace::SERIALIZATION_THROTTLE_TIME; use workspace::{ Workspace, @@ -319,9 +320,7 @@ impl TreeViewState { &mut self, section: Section, mut entries: Vec, - repo: &Repository, seen_directories: &mut HashSet, - optimistic_staging: &HashMap, ) -> Vec<(GitListEntry, bool)> { if entries.is_empty() { return Vec::new(); @@ -365,14 +364,7 @@ impl TreeViewState { } } - let (flattened, _) = self.flatten_tree( - &root, - section, - 0, - repo, - seen_directories, - optimistic_staging, - ); + let (flattened, _) = self.flatten_tree(&root, section, 0, seen_directories); flattened } @@ -381,9 +373,7 @@ impl TreeViewState { node: &TreeNode, section: Section, depth: usize, - repo: &Repository, seen_directories: &mut HashSet, - optimistic_staging: &HashMap, ) -> (Vec<(GitListEntry, bool)>, Vec) { let mut all_statuses = Vec::new(); let mut flattened = Vec::new(); @@ -393,26 +383,13 @@ impl TreeViewState { let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else { continue; }; - let (child_flattened, mut child_statuses) = self.flatten_tree( - terminal, - section, - depth + 1, - repo, - seen_directories, - optimistic_staging, - ); + let (child_flattened, mut child_statuses) = + self.flatten_tree(terminal, section, depth + 1, seen_directories); let key = TreeKey { section, path }; let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true); self.expanded_dirs.entry(key.clone()).or_insert(true); seen_directories.insert(key.clone()); - let staged_count = child_statuses - .iter() - .filter(|entry| Self::is_entry_staged(entry, repo, optimistic_staging)) - .count(); - let staged_state = - GitPanel::toggle_state_for_counts(staged_count, child_statuses.len()); - self.directory_descendants .insert(key.clone(), child_statuses.clone()); @@ -421,7 +398,6 @@ impl TreeViewState { key, name, depth, - staged_state, expanded, }), true, @@ -465,23 +441,6 @@ impl TreeViewState { let name = parts.join("/"); (node, SharedString::from(name)) } - - fn is_entry_staged( - entry: &GitStatusEntry, - repo: &Repository, - optimistic_staging: &HashMap, - ) -> bool { - if let Some(optimistic) = optimistic_staging.get(&entry.repo_path) { - return *optimistic; - } - repo.pending_ops_for_path(&entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .or_else(|| { - repo.status_for_path(&entry.repo_path) - .and_then(|status| status.status.staging().as_bool()) - }) - .unwrap_or(entry.staging.has_staged()) - } } #[derive(Debug, PartialEq, Eq, Clone)] @@ -501,7 +460,7 @@ struct GitTreeDirEntry { key: TreeKey, name: SharedString, depth: usize, - staged_state: ToggleState, + // staged_state: ToggleState, expanded: bool, } @@ -638,7 +597,6 @@ pub struct GitPanel { local_committer_task: Option>, bulk_staging: Option, stash_entries: GitStash, - optimistic_staging: HashMap, _settings_subscription: Subscription, } @@ -808,7 +766,6 @@ impl GitPanel { entry_count: 0, bulk_staging: None, stash_entries: Default::default(), - optimistic_staging: HashMap::default(), _settings_subscription, }; @@ -1555,7 +1512,7 @@ impl GitPanel { .detach(); } - fn is_entry_staged(&self, entry: &GitStatusEntry, repo: &Repository) -> bool { + fn stage_status_for_entry(entry: &GitStatusEntry, repo: &Repository) -> StageStatus { // Checking for current staged/unstaged file status is a chained operation: // 1. first, we check for any pending operation recorded in repository // 2. if there are no pending ops either running or finished, we then ask the repository @@ -1564,25 +1521,59 @@ impl GitPanel { // the checkbox's state (or flickering) which is undesirable. // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded // in `entry` arg. - if let Some(optimistic) = self.optimistic_staging.get(&entry.repo_path) { - return *optimistic; - } repo.pending_ops_for_path(&entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) + .map(|ops| { + if ops.staging() || ops.staged() { + StageStatus::Staged + } else { + StageStatus::Unstaged + } + }) .or_else(|| { repo.status_for_path(&entry.repo_path) - .and_then(|status| status.status.staging().as_bool()) + .map(|status| status.status.staging()) }) - .unwrap_or(entry.staging.has_staged()) + .unwrap_or(entry.staging) } - fn toggle_state_for_counts(staged_count: usize, total: usize) -> ToggleState { - if staged_count == 0 || total == 0 { - ToggleState::Unselected - } else if staged_count == total { - ToggleState::Selected + fn stage_status_for_directory( + &self, + entry: &GitTreeDirEntry, + repo: &Repository, + ) -> StageStatus { + let GitPanelViewMode::Tree(tree_state) = &self.view_mode else { + util::debug_panic!("We should never render a directory entry while in flat view mode"); + return StageStatus::Unstaged; + }; + + let Some(descendants) = tree_state.directory_descendants.get(&entry.key) else { + return StageStatus::Unstaged; + }; + + let mut fully_staged_count = 0usize; + let mut any_staged_or_partially_staged = false; + + for descendant in descendants { + match GitPanel::stage_status_for_entry(descendant, repo) { + StageStatus::Staged => { + fully_staged_count += 1; + any_staged_or_partially_staged = true; + } + StageStatus::PartiallyStaged => { + any_staged_or_partially_staged = true; + } + StageStatus::Unstaged => {} + } + } + + if descendants.is_empty() { + StageStatus::Unstaged + } else if fully_staged_count == descendants.len() { + StageStatus::Staged + } else if any_staged_or_partially_staged { + StageStatus::PartiallyStaged } else { - ToggleState::Indeterminate + StageStatus::Unstaged } } @@ -1611,31 +1602,37 @@ impl GitPanel { match entry { GitListEntry::Status(status_entry) => { let repo_paths = vec![status_entry.clone()]; - let stage = if self.is_entry_staged(status_entry, &repo) { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.repo_path - { - clear_anchor = Some(op.anchor); + let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) { + StageStatus::Staged => { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } + StageStatus::Unstaged | StageStatus::PartiallyStaged => { + set_anchor = Some(status_entry.repo_path.clone()); + true } - false - } else { - set_anchor = Some(status_entry.repo_path.clone()); - true }; (stage, repo_paths) } GitListEntry::TreeStatus(status_entry) => { let repo_paths = vec![status_entry.entry.clone()]; - let stage = if self.is_entry_staged(&status_entry.entry, &repo) { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.entry.repo_path - { - clear_anchor = Some(op.anchor); + let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) { + StageStatus::Staged => { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } + StageStatus::Unstaged | StageStatus::PartiallyStaged => { + set_anchor = Some(status_entry.entry.repo_path.clone()); + true } - false - } else { - set_anchor = Some(status_entry.entry.repo_path.clone()); - true }; (stage, repo_paths) } @@ -1647,7 +1644,8 @@ impl GitPanel { .filter_map(|entry| entry.status_entry()) .filter(|status_entry| { section.contains(status_entry, &repo) - && status_entry.staging.as_bool() != Some(goal_staged_state) + && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool() + != Some(goal_staged_state) }) .cloned() .collect::>(); @@ -1655,7 +1653,12 @@ impl GitPanel { (goal_staged_state, entries) } GitListEntry::Directory(entry) => { - let goal_staged_state = entry.staged_state != ToggleState::Selected; + let goal_staged_state = match self.stage_status_for_directory(entry, repo) { + StageStatus::Staged => StageStatus::Unstaged, + StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged, + }; + let goal_stage = goal_staged_state == StageStatus::Staged; + let entries = self .view_mode .tree_state() @@ -1664,10 +1667,11 @@ impl GitPanel { .unwrap_or_default() .into_iter() .filter(|status_entry| { - self.is_entry_staged(status_entry, &repo) != goal_staged_state + GitPanel::stage_status_for_entry(status_entry, &repo) + != goal_staged_state }) .collect::>(); - (goal_staged_state, entries) + (goal_stage, entries) } } }; @@ -1682,10 +1686,6 @@ impl GitPanel { self.set_bulk_staging_anchor(anchor, cx); } - let repo = active_repository.read(cx); - self.apply_optimistic_stage(&repo_paths, stage, &repo); - cx.notify(); - self.change_file_stage(stage, repo_paths, cx); } @@ -1730,81 +1730,6 @@ impl GitPanel { .detach(); } - fn apply_optimistic_stage( - &mut self, - entries: &[GitStatusEntry], - stage: bool, - repo: &Repository, - ) { - // This “optimistic” pass keeps all checkboxes—files, folders, and section headers—visually in sync the moment you click, - // even though `change_file_stage` is still talking to the repository in the background. - // Before, the UI would wait for Git, causing checkbox flicker or stale parent states; - // Now, users see instant feedback and accurate parent/child tri-states while the async staging operation completes. - // - // Description: - // It records the desired state in `self.optimistic_staging` (a map from path → bool), - // walks the rendered entries, and swaps their `staging` flags based on that map. - // In tree view it also recomputes every directory’s tri-state checkbox using the updated child data, - // so parent folders flip between selected/indeterminate/empty in the same frame. - let new_stage = if stage { - StageStatus::Staged - } else { - StageStatus::Unstaged - }; - - self.optimistic_staging - .extend(entries.iter().map(|entry| (entry.repo_path.clone(), stage))); - - let staged_states: HashMap = self - .view_mode - .tree_state() - .map(|state| state.directory_descendants.iter()) - .into_iter() - .flatten() - .map(|(key, descendants)| { - let staged_count = descendants - .iter() - .filter(|entry| self.is_entry_staged(entry, repo)) - .count(); - ( - key.clone(), - Self::toggle_state_for_counts(staged_count, descendants.len()), - ) - }) - .collect(); - - for list_entry in &mut self.entries { - match list_entry { - GitListEntry::Status(status) => { - if self - .optimistic_staging - .get(&status.repo_path) - .is_some_and(|s| *s == stage) - { - status.staging = new_stage; - } - } - GitListEntry::TreeStatus(status) => { - if self - .optimistic_staging - .get(&status.entry.repo_path) - .is_some_and(|s| *s == stage) - { - status.entry.staging = new_stage; - } - } - GitListEntry::Directory(dir) => { - if let Some(state) = staged_states.get(&dir.key) { - dir.staged_state = *state; - } - } - _ => {} - } - } - - self.update_counts(repo); - } - pub fn total_staged_count(&self) -> usize { self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count } @@ -2401,6 +2326,56 @@ impl GitPanel { compressed } + async fn load_project_rules( + project: &Entity, + repo_work_dir: &Arc, + cx: &mut AsyncApp, + ) -> Option { + let rules_path = cx + .update(|cx| { + for worktree in project.read(cx).worktrees(cx) { + let worktree_abs_path = worktree.read(cx).abs_path(); + if !worktree_abs_path.starts_with(&repo_work_dir) { + continue; + } + + let worktree_snapshot = worktree.read(cx).snapshot(); + for rules_name in RULES_FILE_NAMES { + if let Ok(rel_path) = RelPath::unix(rules_name) { + if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) { + if entry.is_file() { + return Some(ProjectPath { + worktree_id: worktree.read(cx).id(), + path: entry.path.clone(), + }); + } + } + } + } + } + None + }) + .ok()??; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(rules_path, cx)) + .ok()? + .await + .ok()?; + + let content = buffer + .read_with(cx, |buffer, _| buffer.text()) + .ok()? + .trim() + .to_string(); + + if content.is_empty() { + None + } else { + Some(content) + } + } + /// Generates a commit message using an LLM. pub fn generate_commit_message(&mut self, cx: &mut Context) { if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) { @@ -2428,8 +2403,10 @@ impl GitPanel { }); let temperature = AgentSettings::temperature_for_model(&model, cx); + let project = self.project.clone(); + let repo_work_dir = repo.read(cx).work_directory_abs_path.clone(); - self.generate_commit_message_task = Some(cx.spawn(async move |this, cx| { + self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| { async move { let _defer = cx.on_drop(&this, |this, _cx| { this.generate_commit_message_task.take(); @@ -2462,19 +2439,33 @@ impl GitPanel { const MAX_DIFF_BYTES: usize = 20_000; diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES); + let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await; + let subject = this.update(cx, |this, cx| { this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default() })?; let text_empty = subject.trim().is_empty(); - let content = if text_empty { - format!("{PROMPT}\nHere are the changes in this commit:\n{diff_text}") + const PROMPT: &str = include_str!("commit_message_prompt.txt"); + + let rules_section = match &rules_content { + Some(rules) => format!( + "\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\ + \n{rules}\n\n" + ), + None => String::new(), + }; + + let subject_section = if text_empty { + String::new() } else { - format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n") + format!("\nHere is the user's subject line:\n{subject}") }; - const PROMPT: &str = include_str!("commit_message_prompt.txt"); + let content = format!( + "{PROMPT}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}" + ); let request = LanguageModelRequest { thread_id: None, @@ -3394,13 +3385,9 @@ impl GitPanel { Some(&mut tree_state.logical_indices), ); - for (entry, is_visible) in tree_state.build_tree_entries( - section, - entries, - &repo, - &mut seen_directories, - &self.optimistic_staging, - ) { + for (entry, is_visible) in + tree_state.build_tree_entries(section, entries, &mut seen_directories) + { push_entry( self, entry, @@ -3440,13 +3427,6 @@ impl GitPanel { self.max_width_item_index = max_width_item_index; self.update_counts(repo); - let visible_paths: HashSet = self - .entries - .iter() - .filter_map(|entry| entry.status_entry().map(|e| e.repo_path.clone())) - .collect(); - self.optimistic_staging - .retain(|path, _| visible_paths.contains(path)); let bulk_staging_anchor_new_index = bulk_staging .as_ref() @@ -3456,7 +3436,9 @@ impl GitPanel { && let Some(index) = bulk_staging_anchor_new_index && let Some(entry) = self.entries.get(index) && let Some(entry) = entry.status_entry() - && self.is_entry_staged(entry, &repo) + && GitPanel::stage_status_for_entry(entry, &repo) + .as_bool() + .unwrap_or(false) { self.bulk_staging = bulk_staging; } @@ -3500,7 +3482,9 @@ impl GitPanel { for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) { self.entry_count += 1; - let is_staging_or_staged = self.is_entry_staged(status_entry, repo); + let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo) + .as_bool() + .unwrap_or(false); if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) { self.conflicted_count += 1; @@ -4700,10 +4684,13 @@ impl GitPanel { let has_conflict = status.is_conflicted(); let is_modified = status.is_modified(); let is_deleted = status.is_deleted(); + let is_created = status.is_created(); let label_color = if status_style == StatusStyle::LabelColor { if has_conflict { Color::VersionControlConflict + } else if is_created { + Color::VersionControlAdded } else if is_modified { Color::VersionControlModified } else if is_deleted { @@ -4734,8 +4721,12 @@ impl GitPanel { .active_repository(cx) .expect("active repository must be set"); let repo = active_repo.read(cx); - let is_staging_or_staged = self.is_entry_staged(entry, &repo); - let mut is_staged: ToggleState = is_staging_or_staged.into(); + let stage_status = GitPanel::stage_status_for_entry(entry, &repo); + let mut is_staged: ToggleState = match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + }; if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() { is_staged = ToggleState::Selected; } @@ -4892,12 +4883,9 @@ impl GitPanel { } }) .tooltip(move |_window, cx| { - // If is_staging_or_staged is None, this implies the file was partially staged, and so - // we allow the user to stage it in full by displaying `Stage` in the tooltip. - let action = if is_staging_or_staged { - "Unstage" - } else { - "Stage" + let action = match stage_status { + StageStatus::Staged => "Unstage", + StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", }; let tooltip_name = action.to_string(); @@ -4957,7 +4945,21 @@ impl GitPanel { } else { IconName::Folder }; - let staged_state = entry.staged_state; + + let stage_status = if let Some(repo) = &self.active_repository { + self.stage_status_for_directory(entry, repo.read(cx)) + } else { + util::debug_panic!( + "Won't have entries to render without an active repository in Git Panel" + ); + StageStatus::PartiallyStaged + }; + + let toggle_state: ToggleState = match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + }; let name_row = h_flex() .items_center() @@ -5003,7 +5005,7 @@ impl GitPanel { .occlude() .cursor_pointer() .child( - Checkbox::new(checkbox_id, staged_state) + Checkbox::new(checkbox_id, toggle_state) .disabled(!has_write_access) .fill() .elevation(ElevationIndex::Surface) @@ -5026,10 +5028,9 @@ impl GitPanel { } }) .tooltip(move |_window, cx| { - let action = if staged_state.selected() { - "Unstage" - } else { - "Stage" + let action = match stage_status { + StageStatus::Staged => "Unstage", + StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", }; Tooltip::simple(format!("{action} folder"), cx) }), diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index 76d636b457517da64cf66988325652ddea56c5d3..aa056846e6bc56e53d95c41a44444dbb89a16237 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -135,6 +135,8 @@ unsafe impl objc::Encode for NSRange { } } +/// Allow NSString::alloc use here because it sets autorelease +#[allow(clippy::disallowed_methods)] unsafe fn ns_string(string: &str) -> id { unsafe { NSString::alloc(nil).init_str(string).autorelease() } } diff --git a/crates/gpui/src/platform/mac/attributed_string.rs b/crates/gpui/src/platform/mac/attributed_string.rs index 5f313ac699d6e1a096c4bcf807fd6c080d0064da..42fe1e5bf7a396a4eaa8ade26977a207d43b49b5 100644 --- a/crates/gpui/src/platform/mac/attributed_string.rs +++ b/crates/gpui/src/platform/mac/attributed_string.rs @@ -50,10 +50,12 @@ impl NSMutableAttributedString for id {} #[cfg(test)] mod tests { + use crate::platform::mac::ns_string; + use super::*; use cocoa::appkit::NSImage; use cocoa::base::nil; - use cocoa::foundation::NSString; + use cocoa::foundation::NSAutoreleasePool; #[test] #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348 fn test_nsattributed_string() { @@ -68,26 +70,34 @@ mod tests { impl NSTextAttachment for id {} unsafe { - let image: id = msg_send![class!(NSImage), alloc]; - image.initWithContentsOfFile_(NSString::alloc(nil).init_str("test.jpeg")); + let image: id = { + let img: id = msg_send![class!(NSImage), alloc]; + let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")]; + let img: id = msg_send![img, autorelease]; + img + }; let _size = image.size(); - let string = NSString::alloc(nil).init_str("Test String"); - let attr_string = NSMutableAttributedString::alloc(nil).init_attributed_string(string); - let hello_string = NSString::alloc(nil).init_str("Hello World"); - let hello_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(hello_string); + let string = ns_string("Test String"); + let attr_string = NSMutableAttributedString::alloc(nil) + .init_attributed_string(string) + .autorelease(); + let hello_string = ns_string("Hello World"); + let hello_attr_string = NSAttributedString::alloc(nil) + .init_attributed_string(hello_string) + .autorelease(); attr_string.appendAttributedString_(hello_attr_string); - let attachment = NSTextAttachment::alloc(nil); + let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease]; let _: () = msg_send![attachment, setImage: image]; let image_attr_string = msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment]; attr_string.appendAttributedString_(image_attr_string); - let another_string = NSString::alloc(nil).init_str("Another String"); - let another_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(another_string); + let another_string = ns_string("Another String"); + let another_attr_string = NSAttributedString::alloc(nil) + .init_attributed_string(another_string) + .autorelease(); attr_string.appendAttributedString_(another_attr_string); let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length]; diff --git a/crates/gpui/src/platform/mac/display.rs b/crates/gpui/src/platform/mac/display.rs index fe5aaba8dbb9eab4db8c02f94aea1319c2b7535c..94791620e8a394f67a38c257c95c575398cee0b7 100644 --- a/crates/gpui/src/platform/mac/display.rs +++ b/crates/gpui/src/platform/mac/display.rs @@ -1,9 +1,10 @@ +use super::ns_string; use crate::{Bounds, DisplayId, Pixels, PlatformDisplay, point, px, size}; use anyhow::Result; use cocoa::{ appkit::NSScreen, base::{id, nil}, - foundation::{NSArray, NSDictionary, NSString}, + foundation::{NSArray, NSDictionary}, }; use core_foundation::uuid::{CFUUIDGetUUIDBytes, CFUUIDRef}; use core_graphics::display::{CGDirectDisplayID, CGDisplayBounds, CGGetActiveDisplayList}; @@ -35,7 +36,7 @@ impl MacDisplay { let screens = NSScreen::screens(nil); let screen = cocoa::foundation::NSArray::objectAtIndex(screens, 0); let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: CGDirectDisplayID = msg_send![screen_number, unsignedIntegerValue]; Self(screen_number) @@ -150,7 +151,7 @@ impl MacDisplay { unsafe fn get_nsscreen(&self) -> id { let screens = unsafe { NSScreen::screens(nil) }; let count = unsafe { NSArray::count(screens) }; - let screen_number_key: id = unsafe { NSString::alloc(nil).init_str("NSScreenNumber") }; + let screen_number_key: id = unsafe { ns_string("NSScreenNumber") }; for i in 0..count { let screen = unsafe { NSArray::objectAtIndex(screens, i) }; diff --git a/crates/gpui/src/platform/mac/metal_atlas.rs b/crates/gpui/src/platform/mac/metal_atlas.rs index 8282530c5efdc13ca95a1f04c0f6ef1a23c8366c..9b43efe361a0816e32e858a44cafec66c42e7f85 100644 --- a/crates/gpui/src/platform/mac/metal_atlas.rs +++ b/crates/gpui/src/platform/mac/metal_atlas.rs @@ -15,6 +15,9 @@ pub(crate) struct MetalAtlas(Mutex); impl MetalAtlas { pub(crate) fn new(device: Device) -> Self { MetalAtlas(Mutex::new(MetalAtlasState { + // Shared memory can be used only if CPU and GPU share the same memory space. + // https://developer.apple.com/documentation/metal/setting-resource-storage-modes + unified_memory: device.has_unified_memory(), device: AssertSend(device), monochrome_textures: Default::default(), polychrome_textures: Default::default(), @@ -29,6 +32,7 @@ impl MetalAtlas { struct MetalAtlasState { device: AssertSend, + unified_memory: bool, monochrome_textures: AtlasTextureList, polychrome_textures: AtlasTextureList, tiles_by_key: FxHashMap, @@ -146,6 +150,11 @@ impl MetalAtlasState { } texture_descriptor.set_pixel_format(pixel_format); texture_descriptor.set_usage(usage); + texture_descriptor.set_storage_mode(if self.unified_memory { + metal::MTLStorageMode::Shared + } else { + metal::MTLStorageMode::Managed + }); let metal_texture = self.device.new_texture(&texture_descriptor); let texture_list = match kind { diff --git a/crates/gpui/src/platform/mac/metal_renderer.rs b/crates/gpui/src/platform/mac/metal_renderer.rs index 550041a0ccb4cd39bc7a86317d9540e806af2a28..6d7b82507fb581ec1f124e153e5bb91d3eaf9d25 100644 --- a/crates/gpui/src/platform/mac/metal_renderer.rs +++ b/crates/gpui/src/platform/mac/metal_renderer.rs @@ -76,12 +76,22 @@ impl InstanceBufferPool { self.buffers.clear(); } - pub(crate) fn acquire(&mut self, device: &metal::Device) -> InstanceBuffer { + pub(crate) fn acquire( + &mut self, + device: &metal::Device, + unified_memory: bool, + ) -> InstanceBuffer { let buffer = self.buffers.pop().unwrap_or_else(|| { - device.new_buffer( - self.buffer_size as u64, - MTLResourceOptions::StorageModeManaged, - ) + let options = if unified_memory { + MTLResourceOptions::StorageModeShared + // Buffers are write only which can benefit from the combined cache + // https://developer.apple.com/documentation/metal/mtlresourceoptions/cpucachemodewritecombined + | MTLResourceOptions::CPUCacheModeWriteCombined + } else { + MTLResourceOptions::StorageModeManaged + }; + + device.new_buffer(self.buffer_size as u64, options) }); InstanceBuffer { metal_buffer: buffer, @@ -99,6 +109,7 @@ impl InstanceBufferPool { pub(crate) struct MetalRenderer { device: metal::Device, layer: metal::MetalLayer, + unified_memory: bool, presents_with_transaction: bool, command_queue: CommandQueue, paths_rasterization_pipeline_state: metal::RenderPipelineState, @@ -179,6 +190,10 @@ impl MetalRenderer { output } + // Shared memory can be used only if CPU and GPU share the same memory space. + // https://developer.apple.com/documentation/metal/setting-resource-storage-modes + let unified_memory = device.has_unified_memory(); + let unit_vertices = [ to_float2_bits(point(0., 0.)), to_float2_bits(point(1., 0.)), @@ -190,7 +205,12 @@ impl MetalRenderer { let unit_vertices = device.new_buffer_with_data( unit_vertices.as_ptr() as *const c_void, mem::size_of_val(&unit_vertices) as u64, - MTLResourceOptions::StorageModeManaged, + if unified_memory { + MTLResourceOptions::StorageModeShared + | MTLResourceOptions::CPUCacheModeWriteCombined + } else { + MTLResourceOptions::StorageModeManaged + }, ); let paths_rasterization_pipeline_state = build_path_rasterization_pipeline_state( @@ -268,6 +288,7 @@ impl MetalRenderer { device, layer, presents_with_transaction: false, + unified_memory, command_queue, paths_rasterization_pipeline_state, path_sprites_pipeline_state, @@ -337,14 +358,23 @@ impl MetalRenderer { texture_descriptor.set_width(size.width.0 as u64); texture_descriptor.set_height(size.height.0 as u64); texture_descriptor.set_pixel_format(metal::MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); texture_descriptor .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); self.path_intermediate_texture = Some(self.device.new_texture(&texture_descriptor)); if self.path_sample_count > 1 { + // https://developer.apple.com/documentation/metal/choosing-a-resource-storage-mode-for-apple-gpus + // Rendering MSAA textures are done in a single pass, so we can use memory-less storage on Apple Silicon + let storage_mode = if self.unified_memory { + metal::MTLStorageMode::Memoryless + } else { + metal::MTLStorageMode::Private + }; + let mut msaa_descriptor = texture_descriptor; msaa_descriptor.set_texture_type(metal::MTLTextureType::D2Multisample); - msaa_descriptor.set_storage_mode(metal::MTLStorageMode::Private); + msaa_descriptor.set_storage_mode(storage_mode); msaa_descriptor.set_sample_count(self.path_sample_count as _); self.path_intermediate_msaa_texture = Some(self.device.new_texture(&msaa_descriptor)); } else { @@ -378,7 +408,10 @@ impl MetalRenderer { }; loop { - let mut instance_buffer = self.instance_buffer_pool.lock().acquire(&self.device); + let mut instance_buffer = self + .instance_buffer_pool + .lock() + .acquire(&self.device, self.unified_memory); let command_buffer = self.draw_primitives(scene, &mut instance_buffer, drawable, viewport_size); @@ -550,10 +583,14 @@ impl MetalRenderer { command_encoder.end_encoding(); - instance_buffer.metal_buffer.did_modify_range(NSRange { - location: 0, - length: instance_offset as NSUInteger, - }); + if !self.unified_memory { + // Sync the instance buffer to the GPU + instance_buffer.metal_buffer.did_modify_range(NSRange { + location: 0, + length: instance_offset as NSUInteger, + }); + } + Ok(command_buffer.to_owned()) } diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index c2363afe270f973513c8ba696bf5d3f99fb92cad..ee67f465e34bd8109246f68b311e225aa8f9fd0a 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -2,7 +2,7 @@ use super::{ BoolExt, MacKeyboardLayout, MacKeyboardMapper, attributed_string::{NSAttributedString, NSMutableAttributedString}, events::key_to_native, - renderer, + ns_string, renderer, }; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, @@ -1061,13 +1061,15 @@ impl Platform for MacPlatform { let attributed_string = { let mut buf = NSMutableAttributedString::alloc(nil) // TODO can we skip this? Or at least part of it? - .init_attributed_string(NSString::alloc(nil).init_str("")); + .init_attributed_string(ns_string("")) + .autorelease(); for entry in item.entries { if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry { let to_append = NSAttributedString::alloc(nil) - .init_attributed_string(NSString::alloc(nil).init_str(&text)); + .init_attributed_string(ns_string(&text)) + .autorelease(); buf.appendAttributedString_(to_append); } @@ -1543,10 +1545,6 @@ extern "C" fn handle_dock_menu(this: &mut Object, _: Sel, _: id) -> id { } } -unsafe fn ns_string(string: &str) -> id { - unsafe { NSString::alloc(nil).init_str(string).autorelease() } -} - unsafe fn ns_url_to_path(url: id) -> Result { let path: *mut c_char = msg_send![url, fileSystemRepresentation]; anyhow::ensure!(!path.is_null(), "url is not a file path: {}", unsafe { diff --git a/crates/gpui/src/platform/mac/screen_capture.rs b/crates/gpui/src/platform/mac/screen_capture.rs index 4d4ffa6896520e465dfeb7b1ccc06e1149f9e25d..2f2c1eae335c8bcb366879661534c46dacfd47b4 100644 --- a/crates/gpui/src/platform/mac/screen_capture.rs +++ b/crates/gpui/src/platform/mac/screen_capture.rs @@ -1,3 +1,4 @@ +use super::ns_string; use crate::{ DevicePixels, ForegroundExecutor, SharedString, SourceMetadata, platform::{ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream}, @@ -7,7 +8,7 @@ use anyhow::{Result, anyhow}; use block::ConcreteBlock; use cocoa::{ base::{YES, id, nil}, - foundation::{NSArray, NSString}, + foundation::NSArray, }; use collections::HashMap; use core_foundation::base::TCFType; @@ -195,7 +196,7 @@ unsafe fn screen_id_to_human_label() -> HashMap { let screens: id = msg_send![class!(NSScreen), screens]; let count: usize = msg_send![screens, count]; let mut map = HashMap::default(); - let screen_number_key = unsafe { NSString::alloc(nil).init_str("NSScreenNumber") }; + let screen_number_key = unsafe { ns_string("NSScreenNumber") }; for i in 0..count { let screen: id = msg_send![screens, objectAtIndex: i]; let device_desc: id = msg_send![screen, deviceDescription]; diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 53207fb77d16f2e1956f6914889b29ae3ea7bb35..19ad1777570da9494148e01161e156748cd9bcfc 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -785,7 +785,7 @@ impl MacWindow { native_window.setAcceptsMouseMovedEvents_(YES); if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -908,8 +908,8 @@ impl MacWindow { pub fn get_user_tabbing_preference() -> Option { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleWindowTabbingMode"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let value: id = if !dict.is_null() { @@ -1037,7 +1037,7 @@ impl PlatformWindow for MacWindow { } if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -1063,10 +1063,8 @@ impl PlatformWindow for MacWindow { return None; } let device_description: id = msg_send![screen, deviceDescription]; - let screen_number: id = NSDictionary::valueForKey_( - device_description, - NSString::alloc(nil).init_str("NSScreenNumber"), - ); + let screen_number: id = + NSDictionary::valueForKey_(device_description, ns_string("NSScreenNumber")); let screen_number: u32 = msg_send![screen_number, unsignedIntValue]; @@ -1509,8 +1507,8 @@ impl PlatformWindow for MacWindow { .spawn(async move { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleActionOnDoubleClick"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleActionOnDoubleClick"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let action: id = if !dict.is_null() { @@ -2512,7 +2510,7 @@ where unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID { unsafe { let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: NSUInteger = msg_send![screen_number, unsignedIntegerValue]; screen_number as CGDirectDisplayID @@ -2558,7 +2556,7 @@ unsafe fn remove_layer_background(layer: id) { // `description` reflects its name and some parameters. Currently `NSVisualEffectView` // uses a `CAFilter` named "colorSaturate". If one day they switch to `CIFilter`, the // `description` will still contain "Saturat" ("... inputSaturation = ..."). - let test_string: id = NSString::alloc(nil).init_str("Saturat").autorelease(); + let test_string: id = ns_string("Saturat"); let count = NSArray::count(filters); for i in 0..count { let description: id = msg_send![filters.objectAtIndex(i), description]; diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 446c3ad2a325681a39689577a261ed1ffdde6d5b..4d6e6f490d81d967692a3e9d8316af75a7a4d306 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -265,6 +265,10 @@ pub struct Style { /// Equivalent to the Tailwind `grid-cols-` pub grid_cols: Option, + /// The grid columns with min-content minimum sizing. + /// Unlike grid_cols, it won't shrink to width 0 in AvailableSpace::MinContent constraints. + pub grid_cols_min_content: Option, + /// The row span of this element /// Equivalent to the Tailwind `grid-rows-` pub grid_rows: Option, @@ -772,6 +776,7 @@ impl Default for Style { opacity: None, grid_rows: None, grid_cols: None, + grid_cols_min_content: None, grid_location: None, #[cfg(debug_assertions)] diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index e01649be481e27f89643db2ffb3a9ccd294b9b73..e8088a84d7fc141d0a320988c6399afe2b93ce07 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -637,6 +637,13 @@ pub trait Styled: Sized { self } + /// Sets the grid columns with min-content minimum sizing. + /// Unlike grid_cols, it won't shrink to width 0 in AvailableSpace::MinContent constraints. + fn grid_cols_min_content(mut self, cols: u16) -> Self { + self.style().grid_cols_min_content = Some(cols); + self + } + /// Sets the grid rows of this element. fn grid_rows(mut self, rows: u16) -> Self { self.style().grid_rows = Some(rows); diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 11cb0872861321c3c06c3f8a5bf79fdd30eb2275..99a50b87c8aa9f40a7694f1c2084b10f6d0a9315 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -8,6 +8,7 @@ use std::{fmt::Debug, ops::Range}; use taffy::{ TaffyTree, TraversePartialTree as _, geometry::{Point as TaffyPoint, Rect as TaffyRect, Size as TaffySize}, + prelude::min_content, style::AvailableSpace as TaffyAvailableSpace, tree::NodeId, }; @@ -314,6 +315,14 @@ impl ToTaffy for Style { .unwrap_or_default() } + fn to_grid_repeat_min_content( + unit: &Option, + ) -> Vec> { + // grid-template-columns: repeat(, minmax(min-content, 1fr)); + unit.map(|count| vec![repeat(count, vec![minmax(min_content(), fr(1.0))])]) + .unwrap_or_default() + } + taffy::style::Style { display: self.display.into(), overflow: self.overflow.into(), @@ -338,7 +347,11 @@ impl ToTaffy for Style { flex_grow: self.flex_grow, flex_shrink: self.flex_shrink, grid_template_rows: to_grid_repeat(&self.grid_rows), - grid_template_columns: to_grid_repeat(&self.grid_cols), + grid_template_columns: if self.grid_cols_min_content.is_some() { + to_grid_repeat_min_content(&self.grid_cols_min_content) + } else { + to_grid_repeat(&self.grid_cols) + }, grid_row: self .grid_location .as_ref() diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index e81b1077c70d4eb3828715a6bcd28dfe564ab188..9e243d32151e3caeec2b8c51c7889d2ebe93f29b 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -81,50 +81,61 @@ pub fn init(cx: &mut App) { let keymap_event_channel = KeymapEventChannel::new(); cx.set_global(keymap_event_channel); - fn common(filter: Option, cx: &mut App) { - workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| { - workspace - .with_local_workspace(window, cx, move |workspace, window, cx| { - let existing = workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()); - - let keymap_editor = if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - existing - } else { - let keymap_editor = - cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); - workspace.add_item_to_active_pane( - Box::new(keymap_editor.clone()), - None, - true, - window, - cx, - ); - keymap_editor - }; - - if let Some(filter) = filter { - keymap_editor.update(cx, |editor, cx| { - editor.filter_editor.update(cx, |editor, cx| { - editor.clear(window, cx); - editor.insert(&filter, window, cx); - }); - if !editor.has_binding_for(&filter) { - open_binding_modal_after_loading(cx) - } - }) - } - }) - .detach(); - }) + fn open_keymap_editor( + filter: Option, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + workspace + .with_local_workspace(window, cx, |workspace, window, cx| { + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); + + let keymap_editor = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let keymap_editor = + cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); + workspace.add_item_to_active_pane( + Box::new(keymap_editor.clone()), + None, + true, + window, + cx, + ); + keymap_editor + }; + + if let Some(filter) = filter { + keymap_editor.update(cx, |editor, cx| { + editor.filter_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.insert(&filter, window, cx); + }); + if !editor.has_binding_for(&filter) { + open_binding_modal_after_loading(cx) + } + }) + } + }) + .detach_and_log_err(cx); } - cx.on_action(|_: &OpenKeymap, cx| common(None, cx)) - .on_action(|action: &ChangeKeybinding, cx| common(Some(action.action.clone()), cx)); + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace + .register_action(|workspace, _: &OpenKeymap, window, cx| { + open_keymap_editor(None, workspace, window, cx); + }) + .register_action(|workspace, action: &ChangeKeybinding, window, cx| { + open_keymap_editor(Some(action.action.clone()), workspace, window, cx); + }); + }) + .detach(); register_serializable_item::(cx); } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 22fcbf5ee85c0f42de8097526df4a5fdc383ac35..59795c375ab9b663339dbbebccc60062058c6ef9 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -4317,14 +4317,12 @@ impl BufferSnapshot { for chunk in self .tree_sitter_data .chunks - .applicable_chunks(&[self.anchor_before(range.start)..self.anchor_after(range.end)]) + .applicable_chunks(&[range.to_point(self)]) { if known_chunks.is_some_and(|chunks| chunks.contains(&chunk.row_range())) { continue; } - let Some(chunk_range) = self.tree_sitter_data.chunks.chunk_range(chunk) else { - continue; - }; + let chunk_range = chunk.anchor_range(); let chunk_range = chunk_range.to_offset(&self); if let Some(cached_brackets) = diff --git a/crates/language/src/buffer/row_chunk.rs b/crates/language/src/buffer/row_chunk.rs index e4ef5227e690a9912257ea00edc2b5f722326ae3..0f3c0b5afb1cc1a2d60a2a568fe00403733ef5c6 100644 --- a/crates/language/src/buffer/row_chunk.rs +++ b/crates/language/src/buffer/row_chunk.rs @@ -3,7 +3,6 @@ use std::{ops::Range, sync::Arc}; -use clock::Global; use text::{Anchor, OffsetRangeExt as _, Point}; use util::RangeExt; @@ -19,14 +18,13 @@ use crate::BufferRow; /// #[derive(Clone)] pub struct RowChunks { - snapshot: text::BufferSnapshot, chunks: Arc<[RowChunk]>, + version: clock::Global, } impl std::fmt::Debug for RowChunks { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RowChunks") - .field("version", self.snapshot.version()) .field("chunks", &self.chunks) .finish() } @@ -38,34 +36,45 @@ impl RowChunks { let last_row = buffer_point_range.end.row; let chunks = (buffer_point_range.start.row..=last_row) .step_by(max_rows_per_chunk as usize) + .collect::>(); + let last_chunk_id = chunks.len() - 1; + let chunks = chunks + .into_iter() .enumerate() - .map(|(id, chunk_start)| RowChunk { - id, - start: chunk_start, - end_exclusive: (chunk_start + max_rows_per_chunk).min(last_row), + .map(|(id, chunk_start)| { + let start = Point::new(chunk_start, 0); + let end_exclusive = (chunk_start + max_rows_per_chunk).min(last_row); + let end = if id == last_chunk_id { + Point::new(end_exclusive, snapshot.line_len(end_exclusive)) + } else { + Point::new(end_exclusive, 0) + }; + RowChunk { + id, + start: chunk_start, + end_exclusive, + start_anchor: snapshot.anchor_before(start), + end_anchor: snapshot.anchor_after(end), + } }) .collect::>(); Self { - snapshot, chunks: Arc::from(chunks), + version: snapshot.version().clone(), } } - pub fn version(&self) -> &Global { - self.snapshot.version() + pub fn version(&self) -> &clock::Global { + &self.version } pub fn len(&self) -> usize { self.chunks.len() } - pub fn applicable_chunks( - &self, - ranges: &[Range], - ) -> impl Iterator { + pub fn applicable_chunks(&self, ranges: &[Range]) -> impl Iterator { let row_ranges = ranges .iter() - .map(|range| range.to_point(&self.snapshot)) // Be lenient and yield multiple chunks if they "touch" the exclusive part of the range. // This will result in LSP hints [re-]queried for more ranges, but also more hints already visible when scrolling around. .map(|point_range| point_range.start.row..point_range.end.row + 1) @@ -81,23 +90,6 @@ impl RowChunks { .copied() } - pub fn chunk_range(&self, chunk: RowChunk) -> Option> { - if !self.chunks.contains(&chunk) { - return None; - } - - let start = Point::new(chunk.start, 0); - let end = if self.chunks.last() == Some(&chunk) { - Point::new( - chunk.end_exclusive, - self.snapshot.line_len(chunk.end_exclusive), - ) - } else { - Point::new(chunk.end_exclusive, 0) - }; - Some(self.snapshot.anchor_before(start)..self.snapshot.anchor_after(end)) - } - pub fn previous_chunk(&self, chunk: RowChunk) -> Option { if chunk.id == 0 { None @@ -112,10 +104,16 @@ pub struct RowChunk { pub id: usize, pub start: BufferRow, pub end_exclusive: BufferRow, + pub start_anchor: Anchor, + pub end_anchor: Anchor, } impl RowChunk { pub fn row_range(&self) -> Range { self.start..self.end_exclusive } + + pub fn anchor_range(&self) -> Range { + self.start_anchor..self.end_anchor + } } diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index d97d87bdc95c443aeaf3f2b5578bf7f0c1ef322a..5e99cca4f9d6e61672c541cb90a3a1ca7da91203 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -19,7 +19,8 @@ use crate::{LanguageModelToolUse, LanguageModelToolUseId}; pub struct LanguageModelImage { /// A base64-encoded PNG image. pub source: SharedString, - pub size: Size, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option>, } impl LanguageModelImage { @@ -61,7 +62,7 @@ impl LanguageModelImage { } Some(Self { - size: size(DevicePixels(width?), DevicePixels(height?)), + size: Some(size(DevicePixels(width?), DevicePixels(height?))), source: SharedString::from(source.to_string()), }) } @@ -83,7 +84,7 @@ impl LanguageModelImage { pub fn empty() -> Self { Self { source: "".into(), - size: size(DevicePixels(0), DevicePixels(0)), + size: None, } } @@ -139,15 +140,18 @@ impl LanguageModelImage { let source = unsafe { String::from_utf8_unchecked(base64_image) }; Some(LanguageModelImage { - size: image_size, + size: Some(image_size), source: source.into(), }) }) } pub fn estimate_tokens(&self) -> usize { - let width = self.size.width.0.unsigned_abs() as usize; - let height = self.size.height.0.unsigned_abs() as usize; + let Some(size) = self.size.as_ref() else { + return 0; + }; + let width = size.width.0.unsigned_abs() as usize; + let height = size.height.0.unsigned_abs() as usize; // From: https://docs.anthropic.com/en/docs/build-with-claude/vision#calculate-image-costs // Note that are a lot of conditions on Anthropic's API, and OpenAI doesn't use this, @@ -463,8 +467,9 @@ mod tests { match result { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "base64encodedimagedata"); - assert_eq!(image.size.width.0, 100); - assert_eq!(image.size.height.0, 200); + let size = image.size.expect("size"); + assert_eq!(size.width.0, 100); + assert_eq!(size.height.0, 200); } _ => panic!("Expected Image variant"), } @@ -483,8 +488,9 @@ mod tests { match result { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "wrappedimagedata"); - assert_eq!(image.size.width.0, 50); - assert_eq!(image.size.height.0, 75); + let size = image.size.expect("size"); + assert_eq!(size.width.0, 50); + assert_eq!(size.height.0, 75); } _ => panic!("Expected Image variant"), } @@ -503,8 +509,9 @@ mod tests { match result { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "caseinsensitive"); - assert_eq!(image.size.width.0, 30); - assert_eq!(image.size.height.0, 40); + let size = image.size.expect("size"); + assert_eq!(size.width.0, 30); + assert_eq!(size.height.0, 40); } _ => panic!("Expected Image variant"), } @@ -541,8 +548,9 @@ mod tests { match result { LanguageModelToolResultContent::Image(image) => { assert_eq!(image.source.as_ref(), "directimage"); - assert_eq!(image.size.width.0, 200); - assert_eq!(image.size.height.0, 300); + let size = image.size.expect("size"); + assert_eq!(size.width.0, 200); + assert_eq!(size.height.0, 300); } _ => panic!("Expected Image variant"), } diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 3e99f32be8224bb2b9973feccb0ce973b58eaaed..64f3999e3aa96b2611e265a6eaf5df8063332c2a 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -927,7 +927,7 @@ mod tests { MessageContent::Text("What's in this image?".into()), MessageContent::Image(LanguageModelImage { source: "base64data".into(), - size: Default::default(), + size: None, }), ], cache: false, diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index c961001e65be662e0023b3199f68dfbf4989e604..6f3c49f8669885bfd02e5b11b81a091b1248227c 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -43,6 +43,7 @@ static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); #[derive(Default, Debug, Clone, PartialEq)] pub struct OllamaSettings { pub api_url: String, + pub auto_discover: bool, pub available_models: Vec, } @@ -238,10 +239,13 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { fn provided_models(&self, cx: &App) -> Vec> { let mut models: HashMap = HashMap::new(); + let settings = OllamaLanguageModelProvider::settings(cx); // Add models from the Ollama API - for model in self.state.read(cx).fetched_models.iter() { - models.insert(model.name.clone(), model.clone()); + if settings.auto_discover { + for model in self.state.read(cx).fetched_models.iter() { + models.insert(model.name.clone(), model.clone()); + } } // Override with available models from settings diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 43a8e7334a744c84d6edfae3ffc97115eb8f51b2..62f0025c755e10ea1bdae605d9dcc752298bb5f1 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -78,6 +78,7 @@ impl settings::Settings for AllLanguageModelSettings { }, ollama: OllamaSettings { api_url: ollama.api_url.unwrap(), + auto_discover: ollama.auto_discover.unwrap_or(true), available_models: ollama.available_models.unwrap_or_default(), }, open_router: OpenRouterSettings { diff --git a/crates/languages/src/javascript/injections.scm b/crates/languages/src/javascript/injections.scm index f79cd788d78964f61f611023d0645c95c88aaf17..244e025a6f5d62f1d3500fc35fc480b1baa2471e 100644 --- a/crates/languages/src/javascript/injections.scm +++ b/crates/languages/src/javascript/injections.scm @@ -83,3 +83,46 @@ arguments: (arguments (template_string (string_fragment) @injection.content (#set! injection.language "isograph"))) ) + +; Parse the contents of strings and tagged template +; literals with leading ECMAScript comments: +; '/* html */' or '/*html*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*html\\s*\\*\\/") + (#set! injection.language "html") +) + +; '/* sql */' or '/*sql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*sql\\s*\\*\\/") + (#set! injection.language "sql") +) + +; '/* gql */' or '/*gql*/' +; '/* graphql */' or '/*graphql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(gql|graphql)\\s*\\*\\/") + (#set! injection.language "graphql") +) + +; '/* css */' or '/*css*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") + (#set! injection.language "css") +) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 730470d17958f4db02f1ac8c570ffeb83109112c..fbdeb59b7f15a22d4f4097a3b0e60b4aeb9bf202 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1131,6 +1131,18 @@ fn wr_distance( } } +fn micromamba_shell_name(kind: ShellKind) -> &'static str { + match kind { + ShellKind::Csh => "csh", + ShellKind::Fish => "fish", + ShellKind::Nushell => "nu", + ShellKind::PowerShell => "powershell", + ShellKind::Cmd => "cmd.exe", + // default / catch-all: + _ => "posix", + } +} + #[async_trait] impl ToolchainLister for PythonToolchainProvider { async fn list( @@ -1297,24 +1309,28 @@ impl ToolchainLister for PythonToolchainProvider { .as_option() .map(|venv| venv.conda_manager) .unwrap_or(settings::CondaManager::Auto); - let manager = match conda_manager { settings::CondaManager::Conda => "conda", settings::CondaManager::Mamba => "mamba", settings::CondaManager::Micromamba => "micromamba", - settings::CondaManager::Auto => { - // When auto, prefer the detected manager or fall back to conda - toolchain - .environment - .manager - .as_ref() - .and_then(|m| m.executable.file_name()) - .and_then(|name| name.to_str()) - .filter(|name| matches!(*name, "conda" | "mamba" | "micromamba")) - .unwrap_or("conda") - } + settings::CondaManager::Auto => toolchain + .environment + .manager + .as_ref() + .and_then(|m| m.executable.file_name()) + .and_then(|name| name.to_str()) + .filter(|name| matches!(*name, "conda" | "mamba" | "micromamba")) + .unwrap_or("conda"), }; + // Activate micromamba shell in the child shell + // [required for micromamba] + if manager == "micromamba" { + let shell = micromamba_shell_name(shell); + activation_script + .push(format!(r#"eval "$({manager} shell hook --shell {shell})""#)); + } + if let Some(name) = &toolchain.environment.name { activation_script.push(format!("{manager} activate {name}")); } else { diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index aadf882b8eb038f49b5ad602ba074a91e20ed78d..ee64954196f58fe03f53a9e83fbbbea3f636449a 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -1126,9 +1126,11 @@ fn package_name_from_pkgid(pkgid: &str) -> Option<&str> { } async fn get_cached_server_binary(container_dir: PathBuf) -> Option { - maybe!(async { + let binary_result = maybe!(async { let mut last = None; - let mut entries = fs::read_dir(&container_dir).await?; + let mut entries = fs::read_dir(&container_dir) + .await + .with_context(|| format!("listing {container_dir:?}"))?; while let Some(entry) = entries.next().await { let path = entry?.path(); if path.extension().is_some_and(|ext| ext == "metadata") { @@ -1137,20 +1139,34 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option last, + None => return Ok(None), + }; let path = match RustLspAdapter::GITHUB_ASSET_KIND { AssetKind::TarGz | AssetKind::Gz => path, // Tar and gzip extract in place. AssetKind::Zip => path.join("rust-analyzer.exe"), // zip contains a .exe }; - anyhow::Ok(LanguageServerBinary { + anyhow::Ok(Some(LanguageServerBinary { path, env: None, - arguments: Default::default(), - }) + arguments: Vec::new(), + })) }) - .await - .log_err() + .await; + + match binary_result { + Ok(Some(binary)) => Some(binary), + Ok(None) => { + log::info!("No cached rust-analyzer binary found"); + None + } + Err(e) => { + log::error!("Failed to look up cached rust-analyzer binary: {e:#}"); + None + } + } } fn test_fragment(variables: &TaskVariables, path: &Path, stem: &str) -> String { diff --git a/crates/languages/src/tsx/injections.scm b/crates/languages/src/tsx/injections.scm index 3cca9e8e81c31d3565554595456fa62be89bc81f..2cf3ea69ca2fd95402eba6fadb85f3505c5562b7 100644 --- a/crates/languages/src/tsx/injections.scm +++ b/crates/languages/src/tsx/injections.scm @@ -83,3 +83,46 @@ arguments: (arguments (template_string (string_fragment) @injection.content (#set! injection.language "isograph"))) ) + +; Parse the contents of strings and tagged template +; literals with leading ECMAScript comments: +; '/* html */' or '/*html*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*html\\s*\\*\\/") + (#set! injection.language "html") +) + +; '/* sql */' or '/*sql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*sql\\s*\\*\\/") + (#set! injection.language "sql") +) + +; '/* gql */' or '/*gql*/' +; '/* graphql */' or '/*graphql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(gql|graphql)\\s*\\*\\/") + (#set! injection.language "graphql") +) + +; '/* css */' or '/*css*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") + (#set! injection.language "css") +) diff --git a/crates/languages/src/typescript/injections.scm b/crates/languages/src/typescript/injections.scm index 5321e606c118a41df127c8aa37c7c2811dc8bd23..91880407900e7407e46982a54dbeaa3e30277bdd 100644 --- a/crates/languages/src/typescript/injections.scm +++ b/crates/languages/src/typescript/injections.scm @@ -124,3 +124,46 @@ ] ))) (#set! injection.language "css")) + +; Parse the contents of strings and tagged template +; literals with leading ECMAScript comments: +; '/* html */' or '/*html*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*html\\s*\\*\\/") + (#set! injection.language "html") +) + +; '/* sql */' or '/*sql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*sql\\s*\\*\\/") + (#set! injection.language "sql") +) + +; '/* gql */' or '/*gql*/' +; '/* graphql */' or '/*graphql*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(gql|graphql)\\s*\\*\\/") + (#set! injection.language "graphql") +) + +; '/* css */' or '/*css*/' +( + ((comment) @_ecma_comment [ + (string (string_fragment) @injection.content) + (template_string (string_fragment) @injection.content) + ]) + (#match? @_ecma_comment "^\\/\\*\\s*(css)\\s*\\*\\/") + (#set! injection.language "css") +) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index d6ba3babecf3b6b43155780e569bdc4515762d40..536d9fd6a2439e9b23b9f99d20a4aff425eda956 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -70,6 +70,7 @@ pub struct MarkdownStyle { pub heading_level_styles: Option, pub height_is_multiple_of_line_height: bool, pub prevent_mouse_interaction: bool, + pub table_columns_min_size: bool, } impl Default for MarkdownStyle { @@ -91,6 +92,7 @@ impl Default for MarkdownStyle { heading_level_styles: None, height_is_multiple_of_line_height: false, prevent_mouse_interaction: false, + table_columns_min_size: false, } } } @@ -149,8 +151,6 @@ actions!( [ /// Copies the selected text to the clipboard. Copy, - /// Copies the selected text as markdown to the clipboard. - CopyAsMarkdown ] ); @@ -295,14 +295,6 @@ impl Markdown { cx.write_to_clipboard(ClipboardItem::new_string(text)); } - fn copy_as_markdown(&self, _: &mut Window, cx: &mut Context) { - if self.selection.end <= self.selection.start { - return; - } - let text = self.source[self.selection.start..self.selection.end].to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(text)); - } - fn parse(&mut self, cx: &mut Context) { if self.source.is_empty() { return; @@ -422,28 +414,72 @@ impl Focusable for Markdown { } } -#[derive(Copy, Clone, Default, Debug)] +#[derive(Debug, Default, Clone)] +enum SelectMode { + #[default] + Character, + Word(Range), + Line(Range), + All, +} + +#[derive(Clone, Default)] struct Selection { start: usize, end: usize, reversed: bool, pending: bool, + mode: SelectMode, } impl Selection { - fn set_head(&mut self, head: usize) { - if head < self.tail() { - if !self.reversed { - self.end = self.start; - self.reversed = true; + fn set_head(&mut self, head: usize, rendered_text: &RenderedText) { + match &self.mode { + SelectMode::Character => { + if head < self.tail() { + if !self.reversed { + self.end = self.start; + self.reversed = true; + } + self.start = head; + } else { + if self.reversed { + self.start = self.end; + self.reversed = false; + } + self.end = head; + } } - self.start = head; - } else { - if self.reversed { - self.start = self.end; + SelectMode::Word(original_range) | SelectMode::Line(original_range) => { + let head_range = if matches!(self.mode, SelectMode::Word(_)) { + rendered_text.surrounding_word_range(head) + } else { + rendered_text.surrounding_line_range(head) + }; + + if head < original_range.start { + self.start = head_range.start; + self.end = original_range.end; + self.reversed = true; + } else if head >= original_range.end { + self.start = original_range.start; + self.end = head_range.end; + self.reversed = false; + } else { + self.start = original_range.start; + self.end = original_range.end; + self.reversed = false; + } + } + SelectMode::All => { + self.start = 0; + self.end = rendered_text + .lines + .last() + .map(|line| line.source_end) + .unwrap_or(0); self.reversed = false; } - self.end = head; } } @@ -532,7 +568,7 @@ impl MarkdownElement { window: &mut Window, cx: &mut App, ) { - let selection = self.markdown.read(cx).selection; + let selection = self.markdown.read(cx).selection.clone(); let selection_start = rendered_text.position_for_source_index(selection.start); let selection_end = rendered_text.position_for_source_index(selection.end); if let Some(((start_position, start_line_height), (end_position, end_line_height))) = @@ -632,18 +668,34 @@ impl MarkdownElement { match rendered_text.source_index_for_position(event.position) { Ok(ix) | Err(ix) => ix, }; - let range = if event.click_count == 2 { - rendered_text.surrounding_word_range(source_index) - } else if event.click_count == 3 { - rendered_text.surrounding_line_range(source_index) - } else { - source_index..source_index + let (range, mode) = match event.click_count { + 1 => { + let range = source_index..source_index; + (range, SelectMode::Character) + } + 2 => { + let range = rendered_text.surrounding_word_range(source_index); + (range.clone(), SelectMode::Word(range)) + } + 3 => { + let range = rendered_text.surrounding_line_range(source_index); + (range.clone(), SelectMode::Line(range)) + } + _ => { + let range = 0..rendered_text + .lines + .last() + .map(|line| line.source_end) + .unwrap_or(0); + (range, SelectMode::All) + } }; markdown.selection = Selection { start: range.start, end: range.end, reversed: false, pending: true, + mode, }; window.focus(&markdown.focus_handle); } @@ -672,7 +724,7 @@ impl MarkdownElement { { Ok(ix) | Err(ix) => ix, }; - markdown.selection.set_head(source_index); + markdown.selection.set_head(source_index, &rendered_text); markdown.autoscroll_request = Some(source_index); cx.notify(); } else { @@ -1011,15 +1063,23 @@ impl Element for MarkdownElement { } MarkdownTag::MetadataBlock(_) => {} MarkdownTag::Table(alignments) => { - builder.table_alignments = alignments.clone(); + builder.table.start(alignments.clone()); + let column_count = alignments.len(); builder.push_div( div() .id(("table", range.start)) - .min_w_0() + .grid() + .grid_cols(column_count as u16) + .when(self.style.table_columns_min_size, |this| { + this.grid_cols_min_content(column_count as u16) + }) + .when(!self.style.table_columns_min_size, |this| { + this.grid_cols(column_count as u16) + }) .size_full() .mb_2() - .border_1() + .border(px(1.5)) .border_color(cx.theme().colors().border) .rounded_sm() .overflow_hidden(), @@ -1028,38 +1088,33 @@ impl Element for MarkdownElement { ); } MarkdownTag::TableHead => { - let column_count = builder.table_alignments.len(); - - builder.push_div( - div() - .grid() - .grid_cols(column_count as u16) - .bg(cx.theme().colors().title_bar_background), - range, - markdown_end, - ); + builder.table.start_head(); builder.push_text_style(TextStyleRefinement { font_weight: Some(FontWeight::SEMIBOLD), ..Default::default() }); } MarkdownTag::TableRow => { - let column_count = builder.table_alignments.len(); - - builder.push_div( - div().grid().grid_cols(column_count as u16), - range, - markdown_end, - ); + builder.table.start_row(); } MarkdownTag::TableCell => { + let is_header = builder.table.in_head; + let row_index = builder.table.row_index; + let col_index = builder.table.col_index; + builder.push_div( div() - .min_w_0() - .border(px(0.5)) + .when(col_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) .border_color(cx.theme().colors().border) .px_1() - .py_0p5(), + .py_0p5() + .when(is_header, |this| { + this.bg(cx.theme().colors().title_bar_background) + }) + .when(!is_header && row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }), range, markdown_end, ); @@ -1173,17 +1228,18 @@ impl Element for MarkdownElement { } MarkdownTagEnd::Table => { builder.pop_div(); - builder.table_alignments.clear(); + builder.table.end(); } MarkdownTagEnd::TableHead => { - builder.pop_div(); builder.pop_text_style(); + builder.table.end_head(); } MarkdownTagEnd::TableRow => { - builder.pop_div(); + builder.table.end_row(); } MarkdownTagEnd::TableCell => { builder.pop_div(); + builder.table.end_cell(); } _ => log::debug!("unsupported markdown tag end: {:?}", tag), }, @@ -1300,14 +1356,6 @@ impl Element for MarkdownElement { } } }); - window.on_action(std::any::TypeId::of::(), { - let entity = self.markdown.clone(); - move |_, phase, window, cx| { - if phase == DispatchPhase::Bubble { - entity.update(cx, move |this, cx| this.copy_as_markdown(window, cx)) - } - } - }); self.paint_mouse_listeners(hitbox, &rendered_markdown.text, window, cx); rendered_markdown.element.paint(window, cx); @@ -1446,6 +1494,50 @@ impl ParentElement for AnyDiv { } } +#[derive(Default)] +struct TableState { + alignments: Vec, + in_head: bool, + row_index: usize, + col_index: usize, +} + +impl TableState { + fn start(&mut self, alignments: Vec) { + self.alignments = alignments; + self.in_head = false; + self.row_index = 0; + self.col_index = 0; + } + + fn end(&mut self) { + self.alignments.clear(); + self.in_head = false; + self.row_index = 0; + self.col_index = 0; + } + + fn start_head(&mut self) { + self.in_head = true; + } + + fn end_head(&mut self) { + self.in_head = false; + } + + fn start_row(&mut self) { + self.col_index = 0; + } + + fn end_row(&mut self) { + self.row_index += 1; + } + + fn end_cell(&mut self) { + self.col_index += 1; + } +} + struct MarkdownElementBuilder { div_stack: Vec, rendered_lines: Vec, @@ -1457,7 +1549,7 @@ struct MarkdownElementBuilder { text_style_stack: Vec, code_block_stack: Vec>>, list_stack: Vec, - table_alignments: Vec, + table: TableState, syntax_theme: Arc, } @@ -1493,7 +1585,7 @@ impl MarkdownElementBuilder { text_style_stack: Vec::new(), code_block_stack: Vec::new(), list_stack: Vec::new(), - table_alignments: Vec::new(), + table: TableState::default(), syntax_theme, } } @@ -1941,6 +2033,178 @@ mod tests { rendered.text } + #[gpui::test] + fn test_surrounding_word_range(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world tesεζ", cx); + + // Test word selection for "Hello" + let word_range = rendered.surrounding_word_range(2); // Simulate click on 'l' in "Hello" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "Hello"); + + // Test word selection for "world" + let word_range = rendered.surrounding_word_range(7); // Simulate click on 'o' in "world" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "world"); + + // Test word selection for "tesεζ" + let word_range = rendered.surrounding_word_range(14); // Simulate click on 's' in "tesεζ" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "tesεζ"); + + // Test word selection at word boundary (space) + let word_range = rendered.surrounding_word_range(5); // Simulate click on space between "Hello" and "world", expect highlighting word to the left + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "Hello"); + } + + #[gpui::test] + fn test_surrounding_line_range(cx: &mut TestAppContext) { + let rendered = render_markdown("First line\n\nSecond line\n\nThird lineεζ", cx); + + // Test getting line range for first line + let line_range = rendered.surrounding_line_range(5); // Simulate click somewhere in first line + let selected_text = rendered.text_for_range(line_range); + assert_eq!(selected_text, "First line"); + + // Test getting line range for second line + let line_range = rendered.surrounding_line_range(13); // Simulate click at beginning in second line + let selected_text = rendered.text_for_range(line_range); + assert_eq!(selected_text, "Second line"); + + // Test getting line range for third line + let line_range = rendered.surrounding_line_range(37); // Simulate click at end of third line with multi-byte chars + let selected_text = rendered.text_for_range(line_range); + assert_eq!(selected_text, "Third lineεζ"); + } + + #[gpui::test] + fn test_selection_head_movement(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world test", cx); + + let mut selection = Selection { + start: 5, + end: 5, + reversed: false, + pending: false, + mode: SelectMode::Character, + }; + + // Test forward selection + selection.set_head(10, &rendered); + assert_eq!(selection.start, 5); + assert_eq!(selection.end, 10); + assert!(!selection.reversed); + assert_eq!(selection.tail(), 5); + + // Test backward selection + selection.set_head(2, &rendered); + assert_eq!(selection.start, 2); + assert_eq!(selection.end, 5); + assert!(selection.reversed); + assert_eq!(selection.tail(), 5); + + // Test forward selection again from reversed state + selection.set_head(15, &rendered); + assert_eq!(selection.start, 5); + assert_eq!(selection.end, 15); + assert!(!selection.reversed); + assert_eq!(selection.tail(), 5); + } + + #[gpui::test] + fn test_word_selection_drag(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world test", cx); + + // Start with a simulated double-click on "world" (index 6-10) + let word_range = rendered.surrounding_word_range(7); // Click on 'o' in "world" + let mut selection = Selection { + start: word_range.start, + end: word_range.end, + reversed: false, + pending: true, + mode: SelectMode::Word(word_range), + }; + + // Drag forward to "test" - should expand selection to include "test" + selection.set_head(13, &rendered); // Index in "test" + assert_eq!(selection.start, 6); // Start of "world" + assert_eq!(selection.end, 16); // End of "test" + assert!(!selection.reversed); + let selected_text = rendered.text_for_range(selection.start..selection.end); + assert_eq!(selected_text, "world test"); + + // Drag backward to "Hello" - should expand selection to include "Hello" + selection.set_head(2, &rendered); // Index in "Hello" + assert_eq!(selection.start, 0); // Start of "Hello" + assert_eq!(selection.end, 11); // End of "world" (original selection) + assert!(selection.reversed); + let selected_text = rendered.text_for_range(selection.start..selection.end); + assert_eq!(selected_text, "Hello world"); + + // Drag back within original word - should revert to original selection + selection.set_head(8, &rendered); // Back within "world" + assert_eq!(selection.start, 6); // Start of "world" + assert_eq!(selection.end, 11); // End of "world" + assert!(!selection.reversed); + let selected_text = rendered.text_for_range(selection.start..selection.end); + assert_eq!(selected_text, "world"); + } + + #[gpui::test] + fn test_selection_with_markdown_formatting(cx: &mut TestAppContext) { + let rendered = render_markdown( + "This is **bold** text, this is *italic* text, use `code` here", + cx, + ); + let word_range = rendered.surrounding_word_range(10); // Inside "bold" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "bold"); + + let word_range = rendered.surrounding_word_range(32); // Inside "italic" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "italic"); + + let word_range = rendered.surrounding_word_range(51); // Inside "code" + let selected_text = rendered.text_for_range(word_range); + assert_eq!(selected_text, "code"); + } + + #[gpui::test] + fn test_all_selection(cx: &mut TestAppContext) { + let rendered = render_markdown("Hello world\n\nThis is a test\n\nwith multiple lines", cx); + + let total_length = rendered + .lines + .last() + .map(|line| line.source_end) + .unwrap_or(0); + + let mut selection = Selection { + start: 0, + end: total_length, + reversed: false, + pending: true, + mode: SelectMode::All, + }; + + selection.set_head(5, &rendered); // Try to set head in middle + assert_eq!(selection.start, 0); + assert_eq!(selection.end, total_length); + assert!(!selection.reversed); + + selection.set_head(25, &rendered); // Try to set head near end + assert_eq!(selection.start, 0); + assert_eq!(selection.end, total_length); + assert!(!selection.reversed); + + let selected_text = rendered.text_for_range(selection.start..selection.end); + assert_eq!( + selected_text, + "Hello world\nThis is a test\nwith multiple lines" + ); + } + #[test] fn test_escape() { assert_eq!(Markdown::escape("hello `world`"), "hello \\`world\\`"); diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 336f1cacfd2e3d7c25e19aeaf328b1c10db10b30..d4c810245c0fcf874160957cff1b029c4c4c1702 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -9,7 +9,7 @@ use gpui::{ AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, - WeakEntity, Window, div, img, rems, + WeakEntity, Window, div, img, px, rems, }; use settings::Settings; use std::{ @@ -521,7 +521,8 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) - .children(render_markdown_text(&cell.children, cx)) .px_2() .py_1() - .border_1() + .when(col_idx > 0, |this| this.border_l_1()) + .when(row_idx > 0, |this| this.border_t_1()) .border_color(cx.border_color) .when(cell.is_header, |this| { this.bg(cx.title_bar_background_color) @@ -551,7 +552,8 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) - } let empty_cell = div() - .border_1() + .when(col_idx > 0, |this| this.border_l_1()) + .when(row_idx > 0, |this| this.border_t_1()) .border_color(cx.border_color) .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); @@ -568,8 +570,10 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) - div() .grid() .grid_cols(max_column_count as u16) - .border_1() + .border(px(1.5)) .border_color(cx.border_color) + .rounded_sm() + .overflow_hidden() .children(cells), ) .into_any() diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 398d5aaf9405d34e8d8a4e93d5c9b9045ee49118..f3fdb8f36c70d1bfde474f842a7bcbeff2668b50 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -159,3 +159,15 @@ pub(crate) mod m_2025_12_01 { pub(crate) use settings::SETTINGS_PATTERNS; } + +pub(crate) mod m_2025_12_08 { + mod keymap; + + pub(crate) use keymap::KEYMAP_PATTERNS; +} + +pub(crate) mod m_2025_12_15 { + mod settings; + + pub(crate) use settings::SETTINGS_PATTERNS; +} diff --git a/crates/migrator/src/migrations/m_2025_12_08/keymap.rs b/crates/migrator/src/migrations/m_2025_12_08/keymap.rs new file mode 100644 index 0000000000000000000000000000000000000000..70acf4e453486526a30540bf2a15c34d6537411c --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_12_08/keymap.rs @@ -0,0 +1,33 @@ +use collections::HashMap; +use std::{ops::Range, sync::LazyLock}; +use tree_sitter::{Query, QueryMatch}; + +use crate::MigrationPatterns; +use crate::patterns::KEYMAP_ACTION_STRING_PATTERN; + +pub const KEYMAP_PATTERNS: MigrationPatterns = + &[(KEYMAP_ACTION_STRING_PATTERN, replace_string_action)]; + +fn replace_string_action( + contents: &str, + mat: &QueryMatch, + query: &Query, +) -> Option<(Range, String)> { + let action_name_ix = query.capture_index_for_name("action_name")?; + let action_name_node = mat.nodes_for_capture_index(action_name_ix).next()?; + let action_name_range = action_name_node.byte_range(); + let action_name = contents.get(action_name_range.clone())?; + + if let Some(new_action_name) = STRING_REPLACE.get(&action_name) { + return Some((action_name_range, new_action_name.to_string())); + } + + None +} + +static STRING_REPLACE: LazyLock> = LazyLock::new(|| { + HashMap::from_iter([( + "editor::AcceptPartialEditPrediction", + "editor::AcceptNextWordEditPrediction", + )]) +}); diff --git a/crates/migrator/src/migrations/m_2025_12_15/settings.rs b/crates/migrator/src/migrations/m_2025_12_15/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..c875bdfdddffc62a58912bdc53bcf3e496e4eeab --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_12_15/settings.rs @@ -0,0 +1,52 @@ +use std::ops::Range; +use tree_sitter::{Query, QueryMatch}; + +use crate::MigrationPatterns; +use crate::patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN; + +pub const SETTINGS_PATTERNS: MigrationPatterns = &[( + SETTINGS_NESTED_KEY_VALUE_PATTERN, + rename_restore_on_startup_values, +)]; + +fn rename_restore_on_startup_values( + contents: &str, + mat: &QueryMatch, + query: &Query, +) -> Option<(Range, String)> { + if !is_restore_on_startup_setting(contents, mat, query) { + return None; + } + + let setting_value_ix = query.capture_index_for_name("setting_value")?; + let setting_value_range = mat + .nodes_for_capture_index(setting_value_ix) + .next()? + .byte_range(); + let setting_value = contents.get(setting_value_range.clone())?; + + // The value includes quotes, so we check for the quoted string + let new_value = match setting_value.trim() { + "\"none\"" => "\"empty_tab\"", + "\"welcome\"" => "\"launchpad\"", + _ => return None, + }; + + Some((setting_value_range, new_value.to_string())) +} + +fn is_restore_on_startup_setting(contents: &str, mat: &QueryMatch, query: &Query) -> bool { + // Check that the parent key is "workspace" (since restore_on_startup is under workspace settings) + // Actually, restore_on_startup can be at the root level too, so we need to handle both cases + // The SETTINGS_NESTED_KEY_VALUE_PATTERN captures parent_key and setting_name + + let setting_name_ix = match query.capture_index_for_name("setting_name") { + Some(ix) => ix, + None => return false, + }; + let setting_name_range = match mat.nodes_for_capture_index(setting_name_ix).next() { + Some(node) => node.byte_range(), + None => return false, + }; + contents.get(setting_name_range) == Some("restore_on_startup") +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 9fb6d8a1151719f350ea7877bfe2492d6b443c23..8329d635ce321c1b6280f06cdabe105879cc03a0 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -139,6 +139,10 @@ pub fn migrate_keymap(text: &str) -> Result> { migrations::m_2025_04_15::KEYMAP_PATTERNS, &KEYMAP_QUERY_2025_04_15, ), + MigrationType::TreeSitter( + migrations::m_2025_12_08::KEYMAP_PATTERNS, + &KEYMAP_QUERY_2025_12_08, + ), ]; run_migrations(text, migrations) } @@ -228,6 +232,10 @@ pub fn migrate_settings(text: &str) -> Result> { &SETTINGS_QUERY_2025_11_20, ), MigrationType::Json(migrations::m_2025_11_25::remove_context_server_source), + MigrationType::TreeSitter( + migrations::m_2025_12_15::SETTINGS_PATTERNS, + &SETTINGS_QUERY_2025_12_15, + ), ]; run_migrations(text, migrations) } @@ -358,6 +366,14 @@ define_query!( SETTINGS_QUERY_2025_11_20, migrations::m_2025_11_20::SETTINGS_PATTERNS ); +define_query!( + KEYMAP_QUERY_2025_12_08, + migrations::m_2025_12_08::KEYMAP_PATTERNS +); +define_query!( + SETTINGS_QUERY_2025_12_15, + migrations::m_2025_12_15::SETTINGS_PATTERNS +); // custom query static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock = LazyLock::new(|| { diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index 2ff3467c4804f7c0a50488a2c4a1e283ea571292..e5e5b5cac93aa4021f8933bd38f8711d53b89902 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -22,7 +22,6 @@ db.workspace = true documented.workspace = true fs.workspace = true fuzzy.workspace = true -git.workspace = true gpui.workspace = true menu.workspace = true notifications.workspace = true diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 94581e142339cde9d4f1f01a3fb361ae810c1efa..66402f33d31c6e9ce5894c56872c8d92d2c4c36c 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -1,5 +1,4 @@ -pub use crate::welcome::ShowWelcome; -use crate::{multibuffer_hint::MultibufferHint, welcome::WelcomePage}; +use crate::multibuffer_hint::MultibufferHint; use client::{Client, UserStore, zed_urls}; use db::kvp::KEY_VALUE_STORE; use fs::Fs; @@ -17,6 +16,8 @@ use ui::{ Divider, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName, WithScrollbar as _, prelude::*, rems_from_px, }; +pub use workspace::welcome::ShowWelcome; +use workspace::welcome::WelcomePage; use workspace::{ AppState, Workspace, WorkspaceId, dock::DockPosition, @@ -24,12 +25,12 @@ use workspace::{ notifications::NotifyResultExt as _, open_new, register_serializable_item, with_active_or_new_workspace, }; +use zed_actions::OpenOnboarding; mod base_keymap_picker; mod basics_page; pub mod multibuffer_hint; mod theme_preview; -mod welcome; /// Imports settings from Visual Studio Code. #[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)] @@ -52,14 +53,6 @@ pub struct ImportCursorSettings { pub const FIRST_OPEN: &str = "first_open"; pub const DOCS_URL: &str = "https://zed.dev/docs/"; -actions!( - zed, - [ - /// Opens the onboarding view. - OpenOnboarding - ] -); - actions!( onboarding, [ @@ -121,7 +114,8 @@ pub fn init(cx: &mut App) { if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); } else { - let settings_page = WelcomePage::new(window, cx); + let settings_page = cx + .new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx)); workspace.add_item_to_active_pane( Box::new(settings_page), None, @@ -427,7 +421,9 @@ fn go_to_welcome_page(cx: &mut App) { if let Some(idx) = idx { pane.activate_item(idx, true, true, window, cx); } else { - let item = Box::new(WelcomePage::new(window, cx)); + let item = Box::new( + cx.new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx)), + ); pane.add_item(item, true, true, Some(onboarding_idx), window, cx); } diff --git a/crates/onboarding/src/welcome.rs b/crates/onboarding/src/welcome.rs deleted file mode 100644 index b2711cd52d61a51711bd8ec90581b981d7bcf784..0000000000000000000000000000000000000000 --- a/crates/onboarding/src/welcome.rs +++ /dev/null @@ -1,443 +0,0 @@ -use gpui::{ - Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - ParentElement, Render, Styled, Task, Window, actions, -}; -use menu::{SelectNext, SelectPrevious}; -use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; -use workspace::{ - NewFile, Open, - item::{Item, ItemEvent}, - with_active_or_new_workspace, -}; -use zed_actions::{Extensions, OpenSettings, agent, command_palette}; - -use crate::{Onboarding, OpenOnboarding}; - -actions!( - zed, - [ - /// Show the Zed welcome screen - ShowWelcome - ] -); - -const CONTENT: (Section<4>, Section<3>) = ( - Section { - title: "Get Started", - entries: [ - SectionEntry { - icon: IconName::Plus, - title: "New File", - action: &NewFile, - }, - SectionEntry { - icon: IconName::FolderOpen, - title: "Open Project", - action: &Open, - }, - SectionEntry { - icon: IconName::CloudDownload, - title: "Clone Repository", - action: &git::Clone, - }, - SectionEntry { - icon: IconName::ListCollapse, - title: "Open Command Palette", - action: &command_palette::Toggle, - }, - ], - }, - Section { - title: "Configure", - entries: [ - SectionEntry { - icon: IconName::Settings, - title: "Open Settings", - action: &OpenSettings, - }, - SectionEntry { - icon: IconName::ZedAssistant, - title: "View AI Settings", - action: &agent::OpenSettings, - }, - SectionEntry { - icon: IconName::Blocks, - title: "Explore Extensions", - action: &Extensions { - category_filter: None, - id: None, - }, - }, - ], - }, -); - -struct Section { - title: &'static str, - entries: [SectionEntry; COLS], -} - -impl Section { - fn render(self, index_offset: usize, focus: &FocusHandle, cx: &mut App) -> impl IntoElement { - v_flex() - .min_w_full() - .child( - h_flex() - .px_1() - .mb_2() - .gap_2() - .child( - Label::new(self.title.to_ascii_uppercase()) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - .child(Divider::horizontal().color(DividerColor::BorderVariant)), - ) - .children( - self.entries - .iter() - .enumerate() - .map(|(index, entry)| entry.render(index_offset + index, focus, cx)), - ) - } -} - -struct SectionEntry { - icon: IconName, - title: &'static str, - action: &'static dyn Action, -} - -impl SectionEntry { - fn render(&self, button_index: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement { - ButtonLike::new(("onboarding-button-id", button_index)) - .tab_index(button_index as isize) - .full_width() - .size(ButtonSize::Medium) - .child( - h_flex() - .w_full() - .justify_between() - .child( - h_flex() - .gap_2() - .child( - Icon::new(self.icon) - .color(Color::Muted) - .size(IconSize::XSmall), - ) - .child(Label::new(self.title)), - ) - .child( - KeyBinding::for_action_in(self.action, focus, cx).size(rems_from_px(12.)), - ), - ) - .on_click(|_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx)) - } -} - -pub struct WelcomePage { - focus_handle: FocusHandle, -} - -impl WelcomePage { - fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { - window.focus_next(); - cx.notify(); - } - - fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { - window.focus_prev(); - cx.notify(); - } -} - -impl Render for WelcomePage { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let (first_section, second_section) = CONTENT; - let first_section_entries = first_section.entries.len(); - let last_index = first_section_entries + second_section.entries.len(); - - h_flex() - .size_full() - .justify_center() - .overflow_hidden() - .bg(cx.theme().colors().editor_background) - .key_context("Welcome") - .track_focus(&self.focus_handle(cx)) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_next)) - .child( - h_flex() - .px_12() - .py_40() - .size_full() - .relative() - .max_w(px(1100.)) - .child( - div() - .size_full() - .max_w_128() - .mx_auto() - .child( - h_flex() - .w_full() - .justify_center() - .gap_4() - .child(Vector::square(VectorName::ZedLogo, rems(2.))) - .child( - div().child(Headline::new("Welcome to Zed")).child( - Label::new("The editor for what's next") - .size(LabelSize::Small) - .color(Color::Muted) - .italic(), - ), - ), - ) - .child( - v_flex() - .mt_10() - .gap_6() - .child(first_section.render( - Default::default(), - &self.focus_handle, - cx, - )) - .child(second_section.render( - first_section_entries, - &self.focus_handle, - cx, - )) - .child( - h_flex() - .w_full() - .pt_4() - .justify_center() - // We call this a hack - .rounded_b_xs() - .border_t_1() - .border_color(cx.theme().colors().border.opacity(0.6)) - .border_dashed() - .child( - Button::new("welcome-exit", "Return to Setup") - .tab_index(last_index as isize) - .full_width() - .label_size(LabelSize::XSmall) - .on_click(|_, window, cx| { - window.dispatch_action( - OpenOnboarding.boxed_clone(), - cx, - ); - - with_active_or_new_workspace(cx, |workspace, window, cx| { - let Some((welcome_id, welcome_idx)) = workspace - .active_pane() - .read(cx) - .items() - .enumerate() - .find_map(|(idx, item)| { - let _ = item.downcast::()?; - Some((item.item_id(), idx)) - }) - else { - return; - }; - - workspace.active_pane().update(cx, |pane, cx| { - // Get the index here to get around the borrow checker - let idx = pane.items().enumerate().find_map( - |(idx, item)| { - let _ = - item.downcast::()?; - Some(idx) - }, - ); - - if let Some(idx) = idx { - pane.activate_item( - idx, true, true, window, cx, - ); - } else { - let item = - Box::new(Onboarding::new(workspace, cx)); - pane.add_item( - item, - true, - true, - Some(welcome_idx), - window, - cx, - ); - } - - pane.remove_item( - welcome_id, - false, - false, - window, - cx, - ); - }); - }); - }), - ), - ), - ), - ), - ) - } -} - -impl WelcomePage { - pub fn new(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| { - let focus_handle = cx.focus_handle(); - cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify()) - .detach(); - - WelcomePage { focus_handle } - }) - } -} - -impl EventEmitter for WelcomePage {} - -impl Focusable for WelcomePage { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Item for WelcomePage { - type Event = ItemEvent; - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Welcome".into() - } - - fn telemetry_event_text(&self) -> Option<&'static str> { - Some("New Welcome Page Opened") - } - - fn show_toolbar(&self) -> bool { - false - } - - fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { - f(*event) - } -} - -impl workspace::SerializableItem for WelcomePage { - fn serialized_item_kind() -> &'static str { - "WelcomePage" - } - - fn cleanup( - workspace_id: workspace::WorkspaceId, - alive_items: Vec, - _window: &mut Window, - cx: &mut App, - ) -> Task> { - workspace::delete_unloaded_items( - alive_items, - workspace_id, - "welcome_pages", - &persistence::WELCOME_PAGES, - cx, - ) - } - - fn deserialize( - _project: Entity, - _workspace: gpui::WeakEntity, - workspace_id: workspace::WorkspaceId, - item_id: workspace::ItemId, - window: &mut Window, - cx: &mut App, - ) -> Task>> { - if persistence::WELCOME_PAGES - .get_welcome_page(item_id, workspace_id) - .ok() - .is_some_and(|is_open| is_open) - { - window.spawn(cx, async move |cx| cx.update(WelcomePage::new)) - } else { - Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize"))) - } - } - - fn serialize( - &mut self, - workspace: &mut workspace::Workspace, - item_id: workspace::ItemId, - _closing: bool, - _window: &mut Window, - cx: &mut Context, - ) -> Option>> { - let workspace_id = workspace.database_id()?; - Some(cx.background_spawn(async move { - persistence::WELCOME_PAGES - .save_welcome_page(item_id, workspace_id, true) - .await - })) - } - - fn should_serialize(&self, event: &Self::Event) -> bool { - event == &ItemEvent::UpdateTab - } -} - -mod persistence { - use db::{ - query, - sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, - sqlez_macros::sql, - }; - use workspace::WorkspaceDb; - - pub struct WelcomePagesDb(ThreadSafeConnection); - - impl Domain for WelcomePagesDb { - const NAME: &str = stringify!(WelcomePagesDb); - - const MIGRATIONS: &[&str] = (&[sql!( - CREATE TABLE welcome_pages ( - workspace_id INTEGER, - item_id INTEGER UNIQUE, - is_open INTEGER DEFAULT FALSE, - - PRIMARY KEY(workspace_id, item_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ) STRICT; - )]); - } - - db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]); - - impl WelcomePagesDb { - query! { - pub async fn save_welcome_page( - item_id: workspace::ItemId, - workspace_id: workspace::WorkspaceId, - is_open: bool - ) -> Result<()> { - INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open) - VALUES (?, ?, ?) - } - } - - query! { - pub fn get_welcome_page( - item_id: workspace::ItemId, - workspace_id: workspace::WorkspaceId - ) -> Result { - SELECT is_open - FROM welcome_pages - WHERE item_id = ? AND workspace_id = ? - } - } - } -} diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index a8c639fe5930bf8c71d8bca5f2455364826c3514..b107be8b9ff32ef078d92700b46210a3c35c2845 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -6849,9 +6849,15 @@ impl LspStore { ranges: &[Range], cx: &mut Context, ) -> Vec> { + let buffer_snapshot = buffer.read(cx).snapshot(); + let ranges = ranges + .iter() + .map(|range| range.to_point(&buffer_snapshot)) + .collect::>(); + self.latest_lsp_data(buffer, cx) .inlay_hints - .applicable_chunks(ranges) + .applicable_chunks(ranges.as_slice()) .map(|chunk| chunk.row_range()) .collect() } @@ -6898,6 +6904,12 @@ impl LspStore { .map(|(_, known_chunks)| known_chunks) .unwrap_or_default(); + let buffer_snapshot = buffer.read(cx).snapshot(); + let ranges = ranges + .iter() + .map(|range| range.to_point(&buffer_snapshot)) + .collect::>(); + let mut hint_fetch_tasks = Vec::new(); let mut cached_inlay_hints = None; let mut ranges_to_query = None; @@ -6922,9 +6934,7 @@ impl LspStore { .cloned(), ) { (None, None) => { - let Some(chunk_range) = existing_inlay_hints.chunk_range(row_chunk) else { - continue; - }; + let chunk_range = row_chunk.anchor_range(); ranges_to_query .get_or_insert_with(Vec::new) .push((row_chunk, chunk_range)); @@ -12726,10 +12736,11 @@ impl LspStore { .update(cx, |buffer, _| buffer.wait_for_version(version))? .await?; lsp_store.update(cx, |lsp_store, cx| { + let buffer_snapshot = buffer.read(cx).snapshot(); let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); let chunks_queried_for = lsp_data .inlay_hints - .applicable_chunks(&[range]) + .applicable_chunks(&[range.to_point(&buffer_snapshot)]) .collect::>(); match chunks_queried_for.as_slice() { &[chunk] => { diff --git a/crates/project/src/lsp_store/inlay_hint_cache.rs b/crates/project/src/lsp_store/inlay_hint_cache.rs index 804552b52cee9f31799e12f3c42e0614291eeab9..0cd9698e74bbfa4c53ad58569ebf59db99b5decd 100644 --- a/crates/project/src/lsp_store/inlay_hint_cache.rs +++ b/crates/project/src/lsp_store/inlay_hint_cache.rs @@ -8,7 +8,7 @@ use language::{ row_chunk::{RowChunk, RowChunks}, }; use lsp::LanguageServerId; -use text::Anchor; +use text::Point; use crate::{InlayHint, InlayId}; @@ -90,10 +90,7 @@ impl BufferInlayHints { } } - pub fn applicable_chunks( - &self, - ranges: &[Range], - ) -> impl Iterator { + pub fn applicable_chunks(&self, ranges: &[Range]) -> impl Iterator { self.chunks.applicable_chunks(ranges) } @@ -226,8 +223,4 @@ impl BufferInlayHints { } } } - - pub fn chunk_range(&self, chunk: RowChunk) -> Option> { - self.chunks.chunk_range(chunk) - } } diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index b7dadc52f74f4800741f5cf537ac9f52c09643e3..8494eac5b33e7e1f231f9c62010c49aec345229f 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -792,13 +792,20 @@ impl SettingsObserver { event: &WorktreeStoreEvent, cx: &mut Context, ) { - if let WorktreeStoreEvent::WorktreeAdded(worktree) = event { - cx.subscribe(worktree, |this, worktree, event, cx| { - if let worktree::Event::UpdatedEntries(changes) = event { - this.update_local_worktree_settings(&worktree, changes, cx) - } - }) - .detach() + match event { + WorktreeStoreEvent::WorktreeAdded(worktree) => cx + .subscribe(worktree, |this, worktree, event, cx| { + if let worktree::Event::UpdatedEntries(changes) = event { + this.update_local_worktree_settings(&worktree, changes, cx) + } + }) + .detach(), + WorktreeStoreEvent::WorktreeRemoved(_, worktree_id) => { + cx.update_global::(|store, cx| { + store.clear_local_settings(*worktree_id, cx).log_err(); + }); + } + _ => {} } } diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index 847e45742db17fe194d002c26a67380390b68f06..674d4869e9825fd700dde3db510fbf68c6b4d5cc 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -20,6 +20,18 @@ use util::{ use crate::UserPromptId; +pub const RULES_FILE_NAMES: &[&str] = &[ + ".rules", + ".cursorrules", + ".windsurfrules", + ".clinerules", + ".github/copilot-instructions.md", + "CLAUDE.md", + "AGENT.md", + "AGENTS.md", + "GEMINI.md", +]; + #[derive(Default, Debug, Clone, Serialize)] pub struct ProjectContext { pub worktrees: Vec, diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 1df3abbeaee41532abcf12f5939db050429c73da..c960a2b1a9af9e11730240c24483a673b77e0fb5 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1525,6 +1525,7 @@ impl RemoteServerProjects { args: connection_options.args.unwrap_or_default(), upload_binary_over_ssh: None, port_forwards: connection_options.port_forwards, + connection_timeout: connection_options.connection_timeout, }) }); } diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 9412549f20d68e999889ed0062397d85abe99d6e..c445c0565837d33dc044087fc53e6573e06ee54c 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -55,6 +55,7 @@ pub struct SshConnectionOptions { pub password: Option, pub args: Option>, pub port_forwards: Option>, + pub connection_timeout: Option, pub nickname: Option, pub upload_binary_over_ssh: bool, @@ -71,6 +72,7 @@ impl From for SshConnectionOptions { nickname: val.nickname, upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(), port_forwards: val.port_forwards, + connection_timeout: val.connection_timeout, } } } @@ -670,7 +672,12 @@ impl SshRemoteConnection { delegate.set_status(Some("Downloading remote development server on host"), cx); - const CONNECT_TIMEOUT_SECS: &str = "10"; + let connection_timeout = self + .socket + .connection_options + .connection_timeout + .unwrap_or(10) + .to_string(); match self .socket @@ -681,7 +688,7 @@ impl SshRemoteConnection { "-f", "-L", "--connect-timeout", - CONNECT_TIMEOUT_SECS, + &connection_timeout, url, "-o", &tmp_path_gz.display(self.path_style()), @@ -709,7 +716,7 @@ impl SshRemoteConnection { "wget", &[ "--connect-timeout", - CONNECT_TIMEOUT_SECS, + &connection_timeout, "--tries", "1", url, @@ -1226,6 +1233,7 @@ impl SshConnectionOptions { password: None, nickname: None, upload_binary_over_ssh: false, + connection_timeout: None, }) } @@ -1252,6 +1260,10 @@ impl SshConnectionOptions { pub fn additional_args(&self) -> Vec { let mut args = self.additional_args_for_scp(); + if let Some(timeout) = self.connection_timeout { + args.extend(["-o".to_string(), format!("ConnectTimeout={}", timeout)]); + } + if let Some(forwards) = &self.port_forwards { args.extend(forwards.iter().map(|pf| { let local_host = match &pf.local_host { diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 2ef1dfc5385592b9757eff5ec631af818ae1869c..146fc371b14cb5cba428d3a7beec11cc3008e7dd 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -303,19 +303,21 @@ impl KeymapFile { if errors.is_empty() { KeymapFileLoadResult::Success { key_bindings } } else { - let mut error_message = "Errors in user keymap file.\n".to_owned(); + let mut error_message = "Errors in user keymap file.".to_owned(); + for (context, section_errors) in errors { if context.is_empty() { - let _ = write!(error_message, "\n\nIn section without context predicate:"); + let _ = write!(error_message, "\nIn section without context predicate:"); } else { let _ = write!( error_message, - "\n\nIn section with {}:", + "\nIn section with {}:", MarkdownInlineCode(&format!("context = \"{}\"", context)) ); } let _ = write!(error_message, "{section_errors}"); } + KeymapFileLoadResult::SomeFailedToLoad { key_bindings, error_message: MarkdownString(error_message), diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs index ba349b865bf2ac4dfd9d19b22c5693307ebae20a..3d7e6b5948b1db4d375814d6969ddabe95fc3e58 100644 --- a/crates/settings/src/settings_content.rs +++ b/crates/settings/src/settings_content.rs @@ -930,6 +930,9 @@ pub struct SshConnection { pub upload_binary_over_ssh: Option, pub port_forwards: Option>, + /// Timeout in seconds for SSH connection and downloading the remote server binary. + /// Defaults to 10 seconds if not specified. + pub connection_timeout: Option, } #[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom, Debug)] diff --git a/crates/settings/src/settings_content/language_model.rs b/crates/settings/src/settings_content/language_model.rs index 48f5a463a4b8d896885d9ba5b7d804d16ecb5b6b..b106f3d9925cb4afe058cff44649f998c8b73d8a 100644 --- a/crates/settings/src/settings_content/language_model.rs +++ b/crates/settings/src/settings_content/language_model.rs @@ -92,6 +92,7 @@ pub enum BedrockAuthMethodContent { #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct OllamaSettingsContent { pub api_url: Option, + pub auto_discover: Option, pub available_models: Option>, } diff --git a/crates/settings/src/settings_content/workspace.rs b/crates/settings/src/settings_content/workspace.rs index b809a8fa85a9b27da3f3af5242e99b280466a4bb..832f6ec409c8594c55beab1fd6f327c1215f8bdc 100644 --- a/crates/settings/src/settings_content/workspace.rs +++ b/crates/settings/src/settings_content/workspace.rs @@ -42,7 +42,7 @@ pub struct WorkspaceSettingsContent { /// Default: off pub autosave: Option, /// Controls previous session restoration in freshly launched Zed instance. - /// Values: none, last_workspace, last_session + /// Values: empty_tab, last_workspace, last_session, launchpad /// Default: last_session pub restore_on_startup: Option, /// Whether to attempt to restore previous file's state when opening it again. @@ -382,13 +382,16 @@ impl CloseWindowWhenNoItems { )] #[serde(rename_all = "snake_case")] pub enum RestoreOnStartupBehavior { - /// Always start with an empty editor - None, + /// Always start with an empty editor tab + #[serde(alias = "none")] + EmptyTab, /// Restore the workspace that was closed last. LastWorkspace, /// Restore all workspaces that were open when quitting Zed. #[default] LastSession, + /// Show the launchpad with recent projects (no tabs). + Launchpad, } #[with_fallible_options] diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 72e2d3ef099659c5ad27e7f1aaafaee24354d4a9..abd45a141647f6ba13708c549188a22988c78069 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -247,6 +247,7 @@ pub trait AnySettingValue: 'static + Send + Sync { fn all_local_values(&self) -> Vec<(WorktreeId, Arc, &dyn Any)>; fn set_global_value(&mut self, value: Box); fn set_local_value(&mut self, root_id: WorktreeId, path: Arc, value: Box); + fn clear_local_values(&mut self, root_id: WorktreeId); } /// Parameters that are used when generating some JSON schemas at runtime. @@ -971,6 +972,11 @@ impl SettingsStore { pub fn clear_local_settings(&mut self, root_id: WorktreeId, cx: &mut App) -> Result<()> { self.local_settings .retain(|(worktree_id, _), _| worktree_id != &root_id); + self.raw_editorconfig_settings + .retain(|(worktree_id, _), _| worktree_id != &root_id); + for setting_value in self.setting_values.values_mut() { + setting_value.clear_local_values(root_id); + } self.recompute_values(Some((root_id, RelPath::empty())), cx); Ok(()) } @@ -1338,6 +1344,11 @@ impl AnySettingValue for SettingValue { Err(ix) => self.local_values.insert(ix, (root_id, path, value)), } } + + fn clear_local_values(&mut self, root_id: WorktreeId) { + self.local_values + .retain(|(worktree_id, _, _)| *worktree_id != root_id); + } } #[cfg(test)] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 4b9b4c2fd0ec1b546c3683961c1da1336942ad69..0682f0815cdfb13ff7bc649402e47445223c4bbc 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1,12 +1,12 @@ -use gpui::App; +use gpui::{Action as _, App}; use settings::{LanguageSettingsContent, SettingsContent}; use std::sync::Arc; use strum::IntoDiscriminant as _; use ui::{IntoElement, SharedString}; use crate::{ - DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata, SettingsPage, - SettingsPageItem, SubPageLink, USER, all_language_names, sub_page_stack, + ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata, + SettingsPage, SettingsPageItem, SubPageLink, USER, all_language_names, sub_page_stack, }; const DEFAULT_STRING: String = String::new(); @@ -1054,6 +1054,25 @@ pub(crate) fn settings_data(cx: &App) -> Vec { SettingsPage { title: "Keymap", items: vec![ + SettingsPageItem::SectionHeader("Keybindings"), + SettingsPageItem::ActionLink(ActionLink { + title: "Edit Keybindings".into(), + description: Some("Customize keybindings in the keymap editor.".into()), + button_text: "Open Keymap".into(), + on_click: Arc::new(|settings_window, window, cx| { + let Some(original_window) = settings_window.original_window else { + return; + }; + original_window + .update(cx, |_workspace, original_window, cx| { + original_window + .dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx); + original_window.activate_window(); + }) + .ok(); + window.remove_window(); + }), + }), SettingsPageItem::SectionHeader("Base Keymap"), SettingsPageItem::SettingItem(SettingItem { title: "Base Keymap", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index bfc60cc1ea21525effa5347431d90ee219064d24..40678f6cf8d1c6773ccf1168e065cb318ae9f14f 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -731,6 +731,7 @@ enum SettingsPageItem { SettingItem(SettingItem), SubPageLink(SubPageLink), DynamicItem(DynamicItem), + ActionLink(ActionLink), } impl std::fmt::Debug for SettingsPageItem { @@ -746,6 +747,9 @@ impl std::fmt::Debug for SettingsPageItem { SettingsPageItem::DynamicItem(dynamic_item) => { write!(f, "DynamicItem({})", dynamic_item.discriminant.title) } + SettingsPageItem::ActionLink(action_link) => { + write!(f, "ActionLink({})", action_link.title) + } } } } @@ -973,6 +977,55 @@ impl SettingsPageItem { return content.into_any_element(); } + SettingsPageItem::ActionLink(action_link) => v_flex() + .group("setting-item") + .px_8() + .child( + h_flex() + .id(action_link.title.clone()) + .w_full() + .min_w_0() + .justify_between() + .map(apply_padding) + .child( + v_flex() + .relative() + .w_full() + .max_w_1_2() + .child(Label::new(action_link.title.clone())) + .when_some( + action_link.description.as_ref(), + |this, description| { + this.child( + Label::new(description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }, + ), + ) + .child( + Button::new( + ("action-link".into(), action_link.title.clone()), + action_link.button_text.clone(), + ) + .icon(IconName::ArrowUpRight) + .tab_index(0_isize) + .icon_position(IconPosition::End) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) + .on_click({ + let on_click = action_link.on_click.clone(); + cx.listener(move |this, _, window, cx| { + on_click(this, window, cx); + }) + }), + ), + ) + .when(!is_last, |this| this.child(Divider::horizontal())) + .into_any_element(), } } } @@ -1207,6 +1260,20 @@ impl PartialEq for SubPageLink { } } +#[derive(Clone)] +struct ActionLink { + title: SharedString, + description: Option, + button_text: SharedString, + on_click: Arc, +} + +impl PartialEq for ActionLink { + fn eq(&self, other: &Self) -> bool { + self.title == other.title + } +} + fn all_language_names(cx: &App) -> Vec { workspace::AppState::global(cx) .upgrade() @@ -1626,6 +1693,9 @@ impl SettingsWindow { any_found_since_last_header = true; } } + SettingsPageItem::ActionLink(_) => { + any_found_since_last_header = true; + } } } if let Some(last_header) = page_filter.get_mut(header_index) @@ -1864,6 +1934,18 @@ impl SettingsWindow { sub_page_link.title.as_ref(), ); } + SettingsPageItem::ActionLink(action_link) => { + documents.push(bm25::Document { + id: key_index, + contents: [page.title, header_str, action_link.title.as_ref()] + .join("\n"), + }); + push_candidates( + &mut fuzzy_match_candidates, + key_index, + action_link.title.as_ref(), + ); + } } push_candidates(&mut fuzzy_match_candidates, key_index, page.title); push_candidates(&mut fuzzy_match_candidates, key_index, header_str); diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 85186ad504eb098264aae64ba3c2354d20d011a4..85bb5fbba6ad49f556ecca9a4863972adb8666ce 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -529,7 +529,9 @@ impl TabSwitcherDelegate { } if self.select_last { - return self.matches.len() - 1; + let item_index = self.matches.len() - 1; + self.set_selected_index(item_index, window, cx); + return item_index; } // This only runs when initially opening the picker diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index e6bb454fa296b65de60c25f326bba28f484450f0..601fa75044a648e7c40e84b32aabda8096856119 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -369,6 +369,7 @@ impl TerminalBuilder { last_content: Default::default(), last_mouse: None, matches: Vec::new(), + selection_head: None, breadcrumb_text: String::new(), scroll_px: px(0.), @@ -595,6 +596,7 @@ impl TerminalBuilder { last_content: Default::default(), last_mouse: None, matches: Vec::new(), + selection_head: None, breadcrumb_text: String::new(), scroll_px: px(0.), @@ -826,6 +828,7 @@ pub struct Terminal { pub matches: Vec>, pub last_content: TerminalContent, pub selection_head: Option, + pub breadcrumb_text: String, title_override: Option, scroll_px: Pixels, @@ -939,7 +942,7 @@ impl Terminal { AlacTermEvent::Bell => { cx.emit(Event::Bell); } - AlacTermEvent::Exit => self.register_task_finished(None, cx), + AlacTermEvent::Exit => self.register_task_finished(Some(9), cx), AlacTermEvent::MouseCursorDirty => { //NOOP, Handled in render } diff --git a/crates/terminal/src/terminal_hyperlinks.rs b/crates/terminal/src/terminal_hyperlinks.rs index cff27c4567cca84b2310723bf73bfda8d58c166d..8ff33895251f707c8bc9a7894bd74b0bb323ae6c 100644 --- a/crates/terminal/src/terminal_hyperlinks.rs +++ b/crates/terminal/src/terminal_hyperlinks.rs @@ -160,8 +160,8 @@ fn sanitize_url_punctuation( let mut sanitized_url = url; let mut chars_trimmed = 0; - // First, handle parentheses balancing using single traversal - let (open_parens, close_parens) = + // Count parentheses in the URL + let (open_parens, mut close_parens) = sanitized_url .chars() .fold((0, 0), |(opens, closes), c| match c { @@ -170,33 +170,27 @@ fn sanitize_url_punctuation( _ => (opens, closes), }); - // Trim unbalanced closing parentheses - if close_parens > open_parens { - let mut remaining_close = close_parens; - while sanitized_url.ends_with(')') && remaining_close > open_parens { - sanitized_url.pop(); - chars_trimmed += 1; - remaining_close -= 1; - } - } + // Remove trailing characters that shouldn't be at the end of URLs + while let Some(last_char) = sanitized_url.chars().last() { + let should_remove = match last_char { + // These may be part of a URL but not at the end. It's not that the spec + // doesn't allow them, but they are frequently used in plain text as delimiters + // where they're not meant to be part of the URL. + '.' | ',' | ':' | ';' => true, + '(' => true, + ')' if close_parens > open_parens => { + close_parens -= 1; + + true + } + _ => false, + }; - // Handle trailing periods - if sanitized_url.ends_with('.') { - let trailing_periods = sanitized_url - .chars() - .rev() - .take_while(|&c| c == '.') - .count(); - - if trailing_periods > 1 { - sanitized_url.truncate(sanitized_url.len() - trailing_periods); - chars_trimmed += trailing_periods; - } else if trailing_periods == 1 - && let Some(second_last_char) = sanitized_url.chars().rev().nth(1) - && (second_last_char.is_alphanumeric() || second_last_char == '/') - { + if should_remove { sanitized_url.pop(); chars_trimmed += 1; + } else { + break; } } @@ -413,6 +407,8 @@ mod tests { ("https://www.google.com/)", "https://www.google.com/"), ("https://example.com/path)", "https://example.com/path"), ("https://test.com/))", "https://test.com/"), + ("https://test.com/(((", "https://test.com/"), + ("https://test.com/(test)(", "https://test.com/(test)"), // Cases that should NOT be sanitized (balanced parentheses) ( "https://en.wikipedia.org/wiki/Example_(disambiguation)", @@ -443,10 +439,10 @@ mod tests { } #[test] - fn test_url_periods_sanitization() { - // Test URLs with trailing periods (sentence punctuation) + fn test_url_punctuation_sanitization() { + // Test URLs with trailing punctuation (sentence/text punctuation) + // The sanitize_url_punctuation function removes ., ,, :, ;, from the end let test_cases = vec![ - // Cases that should be sanitized (trailing periods likely punctuation) ("https://example.com.", "https://example.com"), ( "https://github.com/zed-industries/zed.", @@ -466,13 +462,36 @@ mod tests { "https://en.wikipedia.org/wiki/C.E.O.", "https://en.wikipedia.org/wiki/C.E.O", ), - // Cases that should NOT be sanitized (periods are part of URL structure) + ("https://example.com,", "https://example.com"), + ("https://example.com/path,", "https://example.com/path"), + ("https://example.com,,", "https://example.com"), + ("https://example.com:", "https://example.com"), + ("https://example.com/path:", "https://example.com/path"), + ("https://example.com::", "https://example.com"), + ("https://example.com;", "https://example.com"), + ("https://example.com/path;", "https://example.com/path"), + ("https://example.com;;", "https://example.com"), + ("https://example.com.,", "https://example.com"), + ("https://example.com.:;", "https://example.com"), + ("https://example.com!.", "https://example.com!"), + ("https://example.com/).", "https://example.com/"), + ("https://example.com/);", "https://example.com/"), + ("https://example.com/;)", "https://example.com/"), ( "https://example.com/v1.0/api", "https://example.com/v1.0/api", ), ("https://192.168.1.1", "https://192.168.1.1"), ("https://sub.domain.com", "https://sub.domain.com"), + ( + "https://example.com?query=value", + "https://example.com?query=value", + ), + ("https://example.com?a=1&b=2", "https://example.com?a=1&b=2"), + ( + "https://example.com/path:8080", + "https://example.com/path:8080", + ), ]; for (input, expected) in test_cases { @@ -484,7 +503,6 @@ mod tests { let end_point = AlacPoint::new(Line(0), Column(input.len())); let dummy_match = Match::new(start_point, end_point); - // This test should initially fail since we haven't implemented period sanitization yet let (result, _) = sanitize_url_punctuation(input.to_string(), dummy_match, &term); assert_eq!(result, expected, "Failed for input: {}", input); } diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 5bd47d02691c9a5c7fec968b5ea6e97265b956b2..4d7397a0bc82142245b86c11ffdf441a6b781ad8 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -479,7 +479,7 @@ impl TitleBar { let name = if let Some(name) = name { util::truncate_and_trailoff(&name, MAX_PROJECT_NAME_LENGTH) } else { - "Open recent project".to_string() + "Open Recent Project".to_string() }; Button::new("project_name_trigger", name) diff --git a/crates/ui/src/components/divider.rs b/crates/ui/src/components/divider.rs index cc7ad19875d2817d98076812bb7b9ea101341107..5ad2187cfae36f3cc45cbecb42f115f0742abed4 100644 --- a/crates/ui/src/components/divider.rs +++ b/crates/ui/src/components/divider.rs @@ -146,13 +146,11 @@ impl RenderOnce for Divider { let base = match self.direction { DividerDirection::Horizontal => div() .min_w_0() - .flex_none() .h_px() .w_full() .when(self.inset, |this| this.mx_1p5()), DividerDirection::Vertical => div() .min_w_0() - .flex_none() .w_px() .h_full() .when(self.inset, |this| this.my_1p5()), diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index fae2bda578c6844c33290d059248b895ebde4c3d..f902a8ff6e9f08475fb6ce8323a924730d3621d1 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1389,11 +1389,12 @@ mod test { Mode::HelixNormal, ); cx.simulate_keystrokes("x"); + // Adjacent line selections stay separate (not merged) cx.assert_state( indoc! {" «line one line two - line three + ˇ»«line three line four ˇ»line five"}, Mode::HelixNormal, diff --git a/crates/vim/src/normal/mark.rs b/crates/vim/src/normal/mark.rs index 3bb040511fdd7fa53dd97198ae02b492b0e7359d..a4d85e87b24fa6e2753f0dbcfcbb43be9488f41a 100644 --- a/crates/vim/src/normal/mark.rs +++ b/crates/vim/src/normal/mark.rs @@ -372,9 +372,12 @@ pub fn jump_motion( #[cfg(test)] mod test { + use crate::test::{NeovimBackedTestContext, VimTestContext}; + use editor::Editor; use gpui::TestAppContext; - - use crate::test::NeovimBackedTestContext; + use std::path::Path; + use util::path; + use workspace::{CloseActiveItem, OpenOptions}; #[gpui::test] async fn test_quote_mark(cx: &mut TestAppContext) { @@ -394,4 +397,69 @@ mod test { cx.simulate_shared_keystrokes("^ ` `").await; cx.shared_state().await.assert_eq("Hello, worldˇ!"); } + + #[gpui::test] + async fn test_global_mark_overwrite(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + let path = Path::new(path!("/first.rs")); + let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake().insert_file(path, "one".into()).await; + let path = Path::new(path!("/second.rs")); + fs.as_fake().insert_file(path, "two".into()).await; + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.open_abs_path( + path!("/first.rs").into(), + OpenOptions::default(), + window, + cx, + ) + }) + .await; + + cx.simulate_keystrokes("m A"); + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.open_abs_path( + path!("/second.rs").into(), + OpenOptions::default(), + window, + cx, + ) + }) + .await; + + cx.simulate_keystrokes("m A"); + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem::default(), window, cx) + }) + }) + .await; + + cx.simulate_keystrokes("m B"); + + cx.simulate_keystrokes("' A"); + + cx.workspace(|workspace, _, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + + let buffer = active_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap(); + + let file = buffer.read(cx).file().unwrap(); + let file_path = file.as_local().unwrap().abs_path(cx); + + assert_eq!(file_path.to_str().unwrap(), path!("/second.rs")); + }) + } } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index e96fd3a329e95311eeb73b87b53acbe76939f0cd..2a8aa91063be89ebd616a2f9601f90c912cee8b5 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -550,6 +550,10 @@ impl MarksState { let buffer = multibuffer.read(cx).as_singleton(); let abs_path = buffer.as_ref().and_then(|b| self.path_for_buffer(b, cx)); + if self.is_global_mark(&name) && self.global_marks.contains_key(&name) { + self.delete_mark(name.clone(), multibuffer, cx); + } + let Some(abs_path) = abs_path else { self.multibuffer_marks .entry(multibuffer.entity_id()) @@ -573,7 +577,7 @@ impl MarksState { let buffer_id = buffer.read(cx).remote_id(); self.buffer_marks.entry(buffer_id).or_default().insert( - name, + name.clone(), anchors .into_iter() .map(|anchor| anchor.text_anchor) @@ -582,6 +586,10 @@ impl MarksState { if !self.watched_buffers.contains_key(&buffer_id) { self.watch_buffer(MarkLocation::Path(abs_path.clone()), &buffer, cx) } + if self.is_global_mark(&name) { + self.global_marks + .insert(name, MarkLocation::Path(abs_path.clone())); + } self.serialize_buffer_marks(abs_path, &buffer, cx) } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index acf95df37f5d20da65b6e9fa4460ba09b2ea81e3..956d63580404da351d34af3b5cf5fd531d5a0011 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -38,12 +38,14 @@ db.workspace = true feature_flags.workspace = true fs.workspace = true futures.workspace = true +git.workspace = true gpui.workspace = true http_client.workspace = true itertools.workspace = true language.workspace = true log.workspace = true menu.workspace = true +markdown.workspace = true node_runtime.workspace = true parking_lot.workspace = true postage.workspace = true diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 42eb754c21347e7dced792f3e56cb9901bc70bd1..bb4b10fa63dc884b8cf0ab8eee8e3bc34880b2a5 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -883,8 +883,14 @@ impl ItemHandle for Entity { if let Some(item) = weak_item.upgrade() && item.workspace_settings(cx).autosave == AutosaveSetting::OnFocusChange { - Pane::autosave_item(&item, workspace.project.clone(), window, cx) - .detach_and_log_err(cx); + // Only trigger autosave if focus has truly left the item. + // If focus is still within the item's hierarchy (e.g., moved to a context menu), + // don't trigger autosave to avoid unwanted formatting and cursor jumps. + let focus_handle = item.item_focus_handle(cx); + if !focus_handle.contains_focused(window, cx) { + Pane::autosave_item(&item, workspace.project.clone(), window, cx) + .detach_and_log_err(cx); + } } }, ) diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 6d37ea4d2a50637ae7c2e0287ae8f371e3b47aba..3b126d329e7fafefa4043661c5039f1e17b09b54 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -3,9 +3,12 @@ use anyhow::Context as _; use gpui::{ AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, - Task, svg, + Task, TextStyleRefinement, UnderlineStyle, svg, }; +use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; +use settings::Settings; +use theme::ThemeSettings; use std::ops::Deref; use std::sync::{Arc, LazyLock}; @@ -216,6 +219,7 @@ pub struct LanguageServerPrompt { focus_handle: FocusHandle, request: Option, scroll_handle: ScrollHandle, + markdown: Entity, } impl Focusable for LanguageServerPrompt { @@ -228,10 +232,13 @@ impl Notification for LanguageServerPrompt {} impl LanguageServerPrompt { pub fn new(request: project::LanguageServerPromptRequest, cx: &mut App) -> Self { + let markdown = cx.new(|cx| Markdown::new(request.message.clone().into(), None, None, cx)); + Self { focus_handle: cx.focus_handle(), request: Some(request), scroll_handle: ScrollHandle::new(), + markdown, } } @@ -262,7 +269,7 @@ impl Render for LanguageServerPrompt { }; let (icon, color) = match request.level { - PromptLevel::Info => (IconName::Info, Color::Accent), + PromptLevel::Info => (IconName::Info, Color::Muted), PromptLevel::Warning => (IconName::Warning, Color::Warning), PromptLevel::Critical => (IconName::XCircle, Color::Error), }; @@ -291,16 +298,15 @@ impl Render for LanguageServerPrompt { .child( h_flex() .justify_between() - .items_start() .child( h_flex() .gap_2() - .child(Icon::new(icon).color(color)) + .child(Icon::new(icon).color(color).size(IconSize::Small)) .child(Label::new(request.lsp_name.clone())), ) .child( h_flex() - .gap_2() + .gap_1() .child( IconButton::new("copy", IconName::Copy) .on_click({ @@ -317,15 +323,17 @@ impl Render for LanguageServerPrompt { IconButton::new(close_id, close_icon) .tooltip(move |_window, cx| { if suppress { - Tooltip::for_action( - "Suppress.\nClose with click.", - &SuppressNotification, + Tooltip::with_meta( + "Suppress", + Some(&SuppressNotification), + "Click to close", cx, ) } else { - Tooltip::for_action( - "Close.\nSuppress with shift-click.", - &menu::Cancel, + Tooltip::with_meta( + "Close", + Some(&menu::Cancel), + "Suppress with shift-click", cx, ) } @@ -342,7 +350,16 @@ impl Render for LanguageServerPrompt { ), ), ) - .child(Label::new(request.message.to_string()).size(LabelSize::Small)) + .child( + MarkdownElement::new(self.markdown.clone(), markdown_style(window, cx)) + .text_size(TextSize::Small.rems(cx)) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) + .on_url_click(|link, _, cx| cx.open_url(&link)), + ) .children(request.actions.iter().enumerate().map(|(ix, action)| { let this_handle = cx.entity(); Button::new(ix, action.title.clone()) @@ -369,6 +386,42 @@ fn workspace_error_notification_id() -> NotificationId { NotificationId::unique::() } +fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let settings = ThemeSettings::get_global(cx); + let ui_font_family = settings.ui_font.family.clone(); + let ui_font_fallbacks = settings.ui_font.fallbacks.clone(); + let buffer_font_family = settings.buffer_font.family.clone(); + let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone(); + + let mut base_text_style = window.text_style(); + base_text_style.refine(&TextStyleRefinement { + font_family: Some(ui_font_family), + font_fallbacks: ui_font_fallbacks, + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + MarkdownStyle { + base_text_style, + selection_background_color: cx.theme().colors().element_selection_background, + inline_code: TextStyleRefinement { + background_color: Some(cx.theme().colors().editor_background.opacity(0.5)), + font_family: Some(buffer_font_family), + font_fallbacks: buffer_font_fallbacks, + ..Default::default() + }, + link: TextStyleRefinement { + underline: Some(UnderlineStyle { + thickness: px(1.), + color: Some(cx.theme().colors().text_accent), + wavy: false, + }), + ..Default::default() + }, + ..Default::default() + } +} + #[derive(Debug, Clone)] pub struct ErrorMessagePrompt { message: SharedString, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 50ba58926ece8818ac5a4f44103c3b86eb2b672d..036723c13755ff2a7b2b10e9684d822f239a8e0b 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1,7 +1,7 @@ use crate::{ CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible, SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace, - WorkspaceItemBuilder, + WorkspaceItemBuilder, ZoomIn, ZoomOut, invalid_item_view::InvalidItemView, item::{ ActivateOnClose, ClosePosition, Item, ItemBufferKind, ItemHandle, ItemSettings, @@ -47,10 +47,9 @@ use std::{ }; use theme::ThemeSettings; use ui::{ - ButtonSize, Color, ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButton, - IconButtonShape, IconDecoration, IconDecorationKind, IconName, IconSize, Indicator, Label, - PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, prelude::*, - right_click_menu, + ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButtonShape, IconDecoration, + IconDecorationKind, Indicator, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, + Tooltip, prelude::*, right_click_menu, }; use util::{ResultExt, debug_panic, maybe, paths::PathStyle, truncate_and_remove_front}; @@ -398,6 +397,7 @@ pub struct Pane { diagnostic_summary_update: Task<()>, /// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here. pub project_item_restoration_data: HashMap>, + welcome_page: Option>, pub in_center_group: bool, pub is_upper_left: bool, @@ -546,6 +546,7 @@ impl Pane { zoom_out_on_close: true, diagnostic_summary_update: Task::ready(()), project_item_restoration_data: HashMap::default(), + welcome_page: None, in_center_group: false, is_upper_left: false, is_upper_right: false, @@ -635,6 +636,10 @@ impl Pane { self.last_focus_handle_by_item .insert(active_item.item_id(), focused.downgrade()); } + } else if let Some(welcome_page) = self.welcome_page.as_ref() { + if self.focus_handle.is_focused(window) { + welcome_page.read(cx).focus_handle(cx).focus(window); + } } } @@ -1306,6 +1311,25 @@ impl Pane { } } + pub fn zoom_in(&mut self, _: &ZoomIn, window: &mut Window, cx: &mut Context) { + if !self.can_toggle_zoom { + cx.propagate(); + } else if !self.zoomed && !self.items.is_empty() { + if !self.focus_handle.contains_focused(window, cx) { + cx.focus_self(window); + } + cx.emit(Event::ZoomIn); + } + } + + pub fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context) { + if !self.can_toggle_zoom { + cx.propagate(); + } else if self.zoomed { + cx.emit(Event::ZoomOut); + } + } + pub fn activate_item( &mut self, index: usize, @@ -3900,6 +3924,8 @@ impl Render for Pane { cx.emit(Event::JoinAll); })) .on_action(cx.listener(Pane::toggle_zoom)) + .on_action(cx.listener(Pane::zoom_in)) + .on_action(cx.listener(Pane::zoom_out)) .on_action(cx.listener(Self::navigate_backward)) .on_action(cx.listener(Self::navigate_forward)) .on_action( @@ -4040,10 +4066,15 @@ impl Render for Pane { if has_worktrees { placeholder } else { - placeholder.child( - Label::new("Open a file or project to get started.") - .color(Color::Muted), - ) + if self.welcome_page.is_none() { + let workspace = self.workspace.clone(); + self.welcome_page = Some(cx.new(|cx| { + crate::welcome::WelcomePage::new( + workspace, true, window, cx, + ) + })); + } + placeholder.child(self.welcome_page.clone().unwrap()) } } }) diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index 3c009f613ea52906649b73bb9fd657bab6906c3b..564560274699ab6685d481340c5efd4b6336ed56 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -42,6 +42,11 @@ impl SharedScreen { }) .detach(); + cx.observe_release(&room, |_, _, cx| { + cx.emit(Event::Close); + }) + .detach(); + let view = cx.new(|cx| RemoteVideoTrackView::new(track.clone(), window, cx)); cx.subscribe(&view, |_, _, ev, cx| match ev { call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close), diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs new file mode 100644 index 0000000000000000000000000000000000000000..93ff1ea266ff9f40b64064ea03d9bd1b91161300 --- /dev/null +++ b/crates/workspace/src/welcome.rs @@ -0,0 +1,568 @@ +use crate::{ + NewFile, Open, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId, + item::{Item, ItemEvent}, +}; +use git::Clone as GitClone; +use gpui::WeakEntity; +use gpui::{ + Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + ParentElement, Render, Styled, Task, Window, actions, +}; +use menu::{SelectNext, SelectPrevious}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; +use util::ResultExt; +use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette}; + +#[derive(PartialEq, Clone, Debug, Deserialize, Serialize, JsonSchema, Action)] +#[action(namespace = welcome)] +#[serde(transparent)] +pub struct OpenRecentProject { + pub index: usize, +} + +actions!( + zed, + [ + /// Show the Zed welcome screen + ShowWelcome + ] +); + +#[derive(IntoElement)] +struct SectionHeader { + title: SharedString, +} + +impl SectionHeader { + fn new(title: impl Into) -> Self { + Self { + title: title.into(), + } + } +} + +impl RenderOnce for SectionHeader { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .px_1() + .mb_2() + .gap_2() + .child( + Label::new(self.title.to_ascii_uppercase()) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .child(Divider::horizontal().color(DividerColor::BorderVariant)) + } +} + +#[derive(IntoElement)] +struct SectionButton { + label: SharedString, + icon: IconName, + action: Box, + tab_index: usize, + focus_handle: FocusHandle, +} + +impl SectionButton { + fn new( + label: impl Into, + icon: IconName, + action: &dyn Action, + tab_index: usize, + focus_handle: FocusHandle, + ) -> Self { + Self { + label: label.into(), + icon, + action: action.boxed_clone(), + tab_index, + focus_handle, + } + } +} + +impl RenderOnce for SectionButton { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = format!("onb-button-{}", self.label); + let action_ref: &dyn Action = &*self.action; + + ButtonLike::new(id) + .tab_index(self.tab_index as isize) + .full_width() + .size(ButtonSize::Medium) + .child( + h_flex() + .w_full() + .justify_between() + .child( + h_flex() + .gap_2() + .child( + Icon::new(self.icon) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(Label::new(self.label)), + ) + .child( + KeyBinding::for_action_in(action_ref, &self.focus_handle, cx) + .size(rems_from_px(12.)), + ), + ) + .on_click(move |_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx)) + } +} + +struct SectionEntry { + icon: IconName, + title: &'static str, + action: &'static dyn Action, +} + +impl SectionEntry { + fn render(&self, button_index: usize, focus: &FocusHandle, _cx: &App) -> impl IntoElement { + SectionButton::new( + self.title, + self.icon, + self.action, + button_index, + focus.clone(), + ) + } +} + +const CONTENT: (Section<4>, Section<3>) = ( + Section { + title: "Get Started", + entries: [ + SectionEntry { + icon: IconName::Plus, + title: "New File", + action: &NewFile, + }, + SectionEntry { + icon: IconName::FolderOpen, + title: "Open Project", + action: &Open, + }, + SectionEntry { + icon: IconName::CloudDownload, + title: "Clone Repository", + action: &GitClone, + }, + SectionEntry { + icon: IconName::ListCollapse, + title: "Open Command Palette", + action: &command_palette::Toggle, + }, + ], + }, + Section { + title: "Configure", + entries: [ + SectionEntry { + icon: IconName::Settings, + title: "Open Settings", + action: &OpenSettings, + }, + SectionEntry { + icon: IconName::ZedAssistant, + title: "View AI Settings", + action: &agent::OpenSettings, + }, + SectionEntry { + icon: IconName::Blocks, + title: "Explore Extensions", + action: &Extensions { + category_filter: None, + id: None, + }, + }, + ], + }, +); + +struct Section { + title: &'static str, + entries: [SectionEntry; COLS], +} + +impl Section { + fn render(self, index_offset: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement { + v_flex() + .min_w_full() + .child(SectionHeader::new(self.title)) + .children( + self.entries + .iter() + .enumerate() + .map(|(index, entry)| entry.render(index_offset + index, focus, cx)), + ) + } +} + +pub struct WelcomePage { + workspace: WeakEntity, + focus_handle: FocusHandle, + fallback_to_recent_projects: bool, + recent_workspaces: Option>, +} + +impl WelcomePage { + pub fn new( + workspace: WeakEntity, + fallback_to_recent_projects: bool, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify()) + .detach(); + + if fallback_to_recent_projects { + cx.spawn_in(window, async move |this: WeakEntity, cx| { + let workspaces = WORKSPACE_DB + .recent_workspaces_on_disk() + .await + .log_err() + .unwrap_or_default(); + + this.update(cx, |this, cx| { + this.recent_workspaces = Some(workspaces); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + WelcomePage { + workspace, + focus_handle, + fallback_to_recent_projects, + recent_workspaces: None, + } + } + + fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(); + cx.notify(); + } + + fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { + window.focus_prev(); + cx.notify(); + } + + fn open_recent_project( + &mut self, + action: &OpenRecentProject, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(recent_workspaces) = &self.recent_workspaces { + if let Some((_workspace_id, location, paths)) = recent_workspaces.get(action.index) { + let paths = paths.clone(); + let location = location.clone(); + let is_local = matches!(location, SerializedWorkspaceLocation::Local); + let workspace = self.workspace.clone(); + + if is_local { + let paths = paths.paths().to_vec(); + cx.spawn_in(window, async move |_, cx| { + let _ = workspace.update_in(cx, |workspace, window, cx| { + workspace + .open_workspace_for_paths(true, paths, window, cx) + .detach(); + }); + }) + .detach(); + } else { + use zed_actions::OpenRecent; + window.dispatch_action(OpenRecent::default().boxed_clone(), cx); + } + } + } + } + + fn render_recent_project_section( + &self, + recent_projects: Vec, + ) -> impl IntoElement { + v_flex() + .w_full() + .child(SectionHeader::new("Recent Projects")) + .children(recent_projects) + } + + fn render_recent_project( + &self, + index: usize, + location: &SerializedWorkspaceLocation, + paths: &PathList, + ) -> impl IntoElement { + let (icon, title) = match location { + SerializedWorkspaceLocation::Local => { + let path = paths.paths().first().map(|p| p.as_path()); + let name = path + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Untitled".to_string()); + (IconName::Folder, name) + } + SerializedWorkspaceLocation::Remote(_) => { + (IconName::Server, "Remote Project".to_string()) + } + }; + + SectionButton::new( + title, + icon, + &OpenRecentProject { index }, + 10, + self.focus_handle.clone(), + ) + } +} + +impl Render for WelcomePage { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (first_section, second_section) = CONTENT; + let first_section_entries = first_section.entries.len(); + let last_index = first_section_entries + second_section.entries.len(); + + let recent_projects = self + .recent_workspaces + .as_ref() + .into_iter() + .flatten() + .take(5) + .enumerate() + .map(|(index, (_, loc, paths))| self.render_recent_project(index, loc, paths)) + .collect::>(); + + let second_section = if self.fallback_to_recent_projects && !recent_projects.is_empty() { + self.render_recent_project_section(recent_projects) + .into_any_element() + } else { + second_section + .render(first_section_entries, &self.focus_handle, cx) + .into_any_element() + }; + + let welcome_label = if self.fallback_to_recent_projects { + "Welcome back to Zed" + } else { + "Welcome to Zed" + }; + + h_flex() + .key_context("Welcome") + .track_focus(&self.focus_handle(cx)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::open_recent_project)) + .size_full() + .justify_center() + .overflow_hidden() + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .relative() + .size_full() + .px_12() + .py_40() + .max_w(px(1100.)) + .child( + v_flex() + .size_full() + .max_w_128() + .mx_auto() + .gap_6() + .overflow_x_hidden() + .child( + h_flex() + .w_full() + .justify_center() + .mb_4() + .gap_4() + .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.))) + .child( + v_flex().child(Headline::new(welcome_label)).child( + Label::new("The editor for what's next") + .size(LabelSize::Small) + .color(Color::Muted) + .italic(), + ), + ), + ) + .child(first_section.render(Default::default(), &self.focus_handle, cx)) + .child(second_section) + .when(!self.fallback_to_recent_projects, |this| { + this.child( + v_flex().gap_1().child(Divider::horizontal()).child( + Button::new("welcome-exit", "Return to Onboarding") + .tab_index(last_index as isize) + .full_width() + .label_size(LabelSize::XSmall) + .on_click(|_, window, cx| { + window.dispatch_action( + OpenOnboarding.boxed_clone(), + cx, + ); + }), + ), + ) + }), + ), + ) + } +} + +impl EventEmitter for WelcomePage {} + +impl Focusable for WelcomePage { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for WelcomePage { + type Event = ItemEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Welcome".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("New Welcome Page Opened") + } + + fn show_toolbar(&self) -> bool { + false + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(crate::item::ItemEvent)) { + f(*event) + } +} + +impl crate::SerializableItem for WelcomePage { + fn serialized_item_kind() -> &'static str { + "WelcomePage" + } + + fn cleanup( + workspace_id: crate::WorkspaceId, + alive_items: Vec, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + crate::delete_unloaded_items( + alive_items, + workspace_id, + "welcome_pages", + &persistence::WELCOME_PAGES, + cx, + ) + } + + fn deserialize( + _project: Entity, + workspace: gpui::WeakEntity, + workspace_id: crate::WorkspaceId, + item_id: crate::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + if persistence::WELCOME_PAGES + .get_welcome_page(item_id, workspace_id) + .ok() + .is_some_and(|is_open| is_open) + { + Task::ready(Ok( + cx.new(|cx| WelcomePage::new(workspace, false, window, cx)) + )) + } else { + Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize"))) + } + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: crate::ItemId, + _closing: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let workspace_id = workspace.database_id()?; + Some(cx.background_spawn(async move { + persistence::WELCOME_PAGES + .save_welcome_page(item_id, workspace_id, true) + .await + })) + } + + fn should_serialize(&self, event: &Self::Event) -> bool { + event == &ItemEvent::UpdateTab + } +} + +mod persistence { + use crate::WorkspaceDb; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; + + pub struct WelcomePagesDb(ThreadSafeConnection); + + impl Domain for WelcomePagesDb { + const NAME: &str = stringify!(WelcomePagesDb); + + const MIGRATIONS: &[&str] = (&[sql!( + CREATE TABLE welcome_pages ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + is_open INTEGER DEFAULT FALSE, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]); + } + + db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]); + + impl WelcomePagesDb { + query! { + pub async fn save_welcome_page( + item_id: crate::ItemId, + workspace_id: crate::WorkspaceId, + is_open: bool + ) -> Result<()> { + INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open) + VALUES (?, ?, ?) + } + } + + query! { + pub fn get_welcome_page( + item_id: crate::ItemId, + workspace_id: crate::WorkspaceId + ) -> Result { + SELECT is_open + FROM welcome_pages + WHERE item_id = ? AND workspace_id = ? + } + } + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0dbe8371247db6b3577e880bb88345428476747d..1634b68c5ce8771dc77a8010ef37ff1c6b617449 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -17,6 +17,7 @@ mod theme_preview; mod toast_layer; mod toolbar; pub mod utility_pane; +pub mod welcome; mod workspace_settings; pub use crate::notifications::NotificationFrame; @@ -273,6 +274,10 @@ actions!( ToggleRightDock, /// Toggles zoom on the active pane. ToggleZoom, + /// Zooms in on the active pane. + ZoomIn, + /// Zooms out of the active pane. + ZoomOut, /// Stops following a collaborator. Unfollow, /// Restores the banner. @@ -9595,6 +9600,105 @@ mod tests { }); } + #[gpui::test] + async fn test_pane_zoom_in_out(cx: &mut 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 pane = workspace.update_in(cx, |workspace, _window, _cx| { + workspace.active_pane().clone() + }); + + // Add an item to the pane so it can be zoomed + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(TestItem::new); + workspace.add_item(pane.clone(), Box::new(item), None, true, true, window, cx); + }); + + // Initially not zoomed + workspace.update_in(cx, |workspace, _window, cx| { + assert!(!pane.read(cx).is_zoomed(), "Pane starts unzoomed"); + assert!( + workspace.zoomed.is_none(), + "Workspace should track no zoomed pane" + ); + assert!(pane.read(cx).items_len() > 0, "Pane should have items"); + }); + + // Zoom In + pane.update_in(cx, |pane, window, cx| { + pane.zoom_in(&crate::ZoomIn, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!( + pane.read(cx).is_zoomed(), + "Pane should be zoomed after ZoomIn" + ); + assert!( + workspace.zoomed.is_some(), + "Workspace should track the zoomed pane" + ); + assert!( + pane.read(cx).focus_handle(cx).contains_focused(window, cx), + "ZoomIn should focus the pane" + ); + }); + + // Zoom In again is a no-op + pane.update_in(cx, |pane, window, cx| { + pane.zoom_in(&crate::ZoomIn, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!(pane.read(cx).is_zoomed(), "Second ZoomIn keeps pane zoomed"); + assert!( + workspace.zoomed.is_some(), + "Workspace still tracks zoomed pane" + ); + assert!( + pane.read(cx).focus_handle(cx).contains_focused(window, cx), + "Pane remains focused after repeated ZoomIn" + ); + }); + + // Zoom Out + pane.update_in(cx, |pane, window, cx| { + pane.zoom_out(&crate::ZoomOut, window, cx); + }); + + workspace.update_in(cx, |workspace, _window, cx| { + assert!( + !pane.read(cx).is_zoomed(), + "Pane should unzoom after ZoomOut" + ); + assert!( + workspace.zoomed.is_none(), + "Workspace clears zoom tracking after ZoomOut" + ); + }); + + // Zoom Out again is a no-op + pane.update_in(cx, |pane, window, cx| { + pane.zoom_out(&crate::ZoomOut, window, cx); + }); + + workspace.update_in(cx, |workspace, _window, cx| { + assert!( + !pane.read(cx).is_zoomed(), + "Second ZoomOut keeps pane unzoomed" + ); + assert!( + workspace.zoomed.is_none(), + "Workspace remains without zoomed pane" + ); + }); + } + #[gpui::test] async fn test_toggle_all_docks(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 654356e0c49997643b3f498205b52b089fcd92ab..89260c4da665cb60761a771d9e9bb530ffd3ba95 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1161,7 +1161,13 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp app_state, cx, |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) + let restore_on_startup = WorkspaceSettings::get_global(cx).restore_on_startup; + match restore_on_startup { + workspace::RestoreOnStartupBehavior::Launchpad => {} + _ => { + Editor::new_file(workspace, &Default::default(), window, cx); + } + } }, ) })? diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index ed22d7ef510e367b71b2a1057513471a4e32306a..c1d98936aa2ad20e6eef7f18bfed2d2c0615395a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -32,8 +32,8 @@ use git_ui::project_diff::ProjectDiffToolbar; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, Context, DismissEvent, Element, Entity, Focusable, KeyBinding, ParentElement, PathPromptOptions, PromptLevel, ReadGlobal, SharedString, - Styled, Task, TitlebarOptions, UpdateGlobal, WeakEntity, Window, WindowKind, WindowOptions, - actions, image_cache, point, px, retain_all, + Task, TitlebarOptions, UpdateGlobal, WeakEntity, Window, WindowKind, WindowOptions, actions, + image_cache, point, px, retain_all, }; use image_viewer::ImageInfo; use language::Capability; @@ -1690,6 +1690,7 @@ fn show_keymap_file_json_error( cx.new(|cx| { MessageNotification::new(message.clone(), cx) .primary_message("Open Keymap File") + .primary_icon(IconName::Settings) .primary_on_click(|window, cx| { window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx); cx.emit(DismissEvent); @@ -1748,16 +1749,18 @@ fn show_markdown_app_notification( cx.new(move |cx| { MessageNotification::new_from_builder(cx, move |window, cx| { image_cache(retain_all("notification-cache")) - .text_xs() - .child(markdown_preview::markdown_renderer::render_parsed_markdown( - &parsed_markdown.clone(), - Some(workspace_handle.clone()), - window, - cx, + .child(div().text_ui(cx).child( + markdown_preview::markdown_renderer::render_parsed_markdown( + &parsed_markdown.clone(), + Some(workspace_handle.clone()), + window, + cx, + ), )) .into_any() }) .primary_message(primary_button_message) + .primary_icon(IconName::Settings) .primary_on_click_arc(primary_button_on_click) }) }) @@ -4798,6 +4801,7 @@ mod tests { "keymap_editor", "keystroke_input", "language_selector", + "welcome", "line_ending_selector", "lsp_tool", "markdown", diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 5e855aa5a949254ba32658c26a59c48c7413844e..6352c20e5c0dcd0bd25063ca3a7bbcae87e48e3f 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -3,13 +3,14 @@ use crate::restorable_workspace_locations; use anyhow::{Context as _, Result, anyhow}; use cli::{CliRequest, CliResponse, ipc::IpcSender}; use cli::{IpcHandshake, ipc}; -use client::parse_zed_link; +use client::{ZedLink, parse_zed_link}; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use fs::Fs; use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures::channel::{mpsc, oneshot}; +use futures::future; use futures::future::join_all; use futures::{FutureExt, SinkExt, StreamExt}; use git_ui::file_diff_view::FileDiffView; @@ -111,8 +112,18 @@ impl OpenRequest { }); } else if url.starts_with("ssh://") { this.parse_ssh_file_path(&url, cx)? - } else if let Some(request_path) = parse_zed_link(&url, cx) { - this.parse_request_path(request_path).log_err(); + } else if let Some(zed_link) = parse_zed_link(&url, cx) { + match zed_link { + ZedLink::Channel { channel_id } => { + this.join_channel = Some(channel_id); + } + ZedLink::ChannelNotes { + channel_id, + heading, + } => { + this.open_channel_notes.push((channel_id, heading)); + } + } } else { log::error!("unhandled url: {}", url); } @@ -156,31 +167,6 @@ impl OpenRequest { self.parse_file_path(url.path()); Ok(()) } - - fn parse_request_path(&mut self, request_path: &str) -> Result<()> { - let mut parts = request_path.split('/'); - if parts.next() == Some("channel") - && let Some(slug) = parts.next() - && let Some(id_str) = slug.split('-').next_back() - && let Ok(channel_id) = id_str.parse::() - { - let Some(next) = parts.next() else { - self.join_channel = Some(channel_id); - return Ok(()); - }; - - if let Some(heading) = next.strip_prefix("notes#") { - self.open_channel_notes - .push((channel_id, Some(heading.to_string()))); - return Ok(()); - } - if next == "notes" { - self.open_channel_notes.push((channel_id, None)); - return Ok(()); - } - } - anyhow::bail!("invalid zed url: {request_path}") - } } #[derive(Clone)] @@ -514,33 +500,27 @@ async fn open_local_workspace( app_state: &Arc, cx: &mut AsyncApp, ) -> bool { - let mut errored = false; - let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), workspace_paths).await; - // Handle reuse flag by finding existing window to replace - let replace_window = if reuse { - cx.update(|cx| workspace::local_workspace_windows(cx).into_iter().next()) - .ok() - .flatten() - } else { - None - }; - - // For reuse, force new workspace creation but with replace_window set - let effective_open_new_workspace = if reuse { - Some(true) + // If reuse flag is passed, open a new workspace in an existing window. + let (open_new_workspace, replace_window) = if reuse { + ( + Some(true), + cx.update(|cx| workspace::local_workspace_windows(cx).into_iter().next()) + .ok() + .flatten(), + ) } else { - open_new_workspace + (open_new_workspace, None) }; - match open_paths_with_positions( + let (workspace, items) = match open_paths_with_positions( &paths_with_position, &diff_paths, app_state.clone(), workspace::OpenOptions { - open_new_workspace: effective_open_new_workspace, + open_new_workspace, replace_window, prefer_focused_window: wait, env: env.cloned(), @@ -550,80 +530,95 @@ async fn open_local_workspace( ) .await { - Ok((workspace, items)) => { - let mut item_release_futures = Vec::new(); + Ok(result) => result, + Err(error) => { + responses + .send(CliResponse::Stderr { + message: format!("error opening {paths_with_position:?}: {error}"), + }) + .log_err(); + return true; + } + }; - for item in items { - match item { - Some(Ok(item)) => { - cx.update(|cx| { - let released = oneshot::channel(); - item.on_release( - cx, - Box::new(move |_| { - let _ = released.0.send(()); - }), - ) - .detach(); - item_release_futures.push(released.1); - }) - .log_err(); - } - Some(Err(err)) => { - responses - .send(CliResponse::Stderr { - message: err.to_string(), - }) - .log_err(); - errored = true; - } - None => {} - } + let mut errored = false; + let mut item_release_futures = Vec::new(); + let mut subscriptions = Vec::new(); + + // If --wait flag is used with no paths, or a directory, then wait until + // the entire workspace is closed. + if 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 wait_for_window_close { + let (release_tx, release_rx) = oneshot::channel(); + item_release_futures.push(release_rx); + subscriptions.push(workspace.update(cx, |_, _, cx| { + cx.on_release(move |_, _| { + let _ = release_tx.send(()); + }) + })); + } + } - if wait { - let background = cx.background_executor().clone(); - let wait = async move { - if paths_with_position.is_empty() && diff_paths.is_empty() { - let (done_tx, done_rx) = oneshot::channel(); - let _subscription = workspace.update(cx, |_, _, cx| { - cx.on_release(move |_, _| { - let _ = done_tx.send(()); - }) - }); - let _ = done_rx.await; - } else { - let _ = futures::future::try_join_all(item_release_futures).await; - }; + for item in items { + match item { + Some(Ok(item)) => { + if wait { + let (release_tx, release_rx) = oneshot::channel(); + item_release_futures.push(release_rx); + subscriptions.push(cx.update(|cx| { + item.on_release( + cx, + Box::new(move |_| { + release_tx.send(()).ok(); + }), + ) + })); } - .fuse(); - - futures::pin_mut!(wait); - - loop { - // Repeatedly check if CLI is still open to avoid wasting resources - // waiting for files or workspaces to close. - let mut timer = background.timer(Duration::from_secs(1)).fuse(); - futures::select_biased! { - _ = wait => break, - _ = timer => { - if responses.send(CliResponse::Ping).is_err() { - break; - } - } + } + Some(Err(err)) => { + responses + .send(CliResponse::Stderr { + message: err.to_string(), + }) + .log_err(); + errored = true; + } + None => {} + } + } + + if wait { + let wait = async move { + let _subscriptions = subscriptions; + let _ = future::try_join_all(item_release_futures).await; + } + .fuse(); + futures::pin_mut!(wait); + + let background = cx.background_executor().clone(); + loop { + // Repeatedly check if CLI is still open to avoid wasting resources + // waiting for files or workspaces to close. + let mut timer = background.timer(Duration::from_secs(1)).fuse(); + futures::select_biased! { + _ = wait => break, + _ = timer => { + if responses.send(CliResponse::Ping).is_err() { + break; } } } } - Err(error) => { - errored = true; - responses - .send(CliResponse::Stderr { - message: format!("error opening {paths_with_position:?}: {error}"), - }) - .log_err(); - } } + errored } @@ -653,12 +648,13 @@ mod tests { ipc::{self}, }; use editor::Editor; - use gpui::TestAppContext; + use futures::poll; + use gpui::{AppContext as _, TestAppContext}; use language::LineEnding; use remote::SshConnectionOptions; use rope::Rope; use serde_json::json; - use std::sync::Arc; + use std::{sync::Arc, task::Poll}; use util::path; use workspace::{AppState, Workspace}; @@ -686,6 +682,7 @@ mod tests { port_forwards: None, nickname: None, upload_binary_over_ssh: false, + connection_timeout: None, }) ); assert_eq!(request.open_paths, vec!["/"]); @@ -753,6 +750,60 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_wait_with_directory_waits_for_window_close(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "dir1": { + "file1.txt": "content1", + }, + }), + ) + .await; + + let (response_tx, _) = ipc::channel::().unwrap(); + let workspace_paths = vec![path!("/root/dir1").to_owned()]; + + let (done_tx, mut done_rx) = futures::channel::oneshot::channel(); + cx.spawn({ + let app_state = app_state.clone(); + move |mut cx| async move { + let errored = open_local_workspace( + workspace_paths, + vec![], + None, + false, + true, + &response_tx, + None, + &app_state, + &mut cx, + ) + .await; + let _ = done_tx.send(errored); + } + }) + .detach(); + + cx.background_executor.run_until_parked(); + assert_eq!(cx.windows().len(), 1); + assert!(matches!(poll!(&mut done_rx), Poll::Pending)); + + let window = cx.windows()[0]; + cx.update_window(window, |_, window, _| window.remove_window()) + .unwrap(); + cx.background_executor.run_until_parked(); + + let errored = done_rx.await.unwrap(); + assert!(!errored); + } + #[gpui::test] async fn test_open_workspace_with_nonexistent_files(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index f69baa03b002fdcac5207f977a23cfc924283e2d..458ca10ecdf8915eef3ee69c6334b1a14cc0c219 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -70,6 +70,8 @@ actions!( OpenTelemetryLog, /// Opens the performance profiler. OpenPerformanceProfiler, + /// Opens the onboarding view. + OpenOnboarding, ] ); diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index 64ff871ce1b629fad72d4ddd6f9c8f42f2bf92da..788c0c1cf7cb0bfd64bdd83812e1e62bf51abf88 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -5,7 +5,7 @@ For invoice-based billing, a Business plan is required. Contact [sales@zed.dev]( ## Billing Information {#settings} -You can access billing information and settings at [zed.dev/account](https://zed.dev/account). +You can access billing information and settings at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Most of the page embeds information from our invoicing/metering partner, Orb (we're planning on a more native experience soon!). ## Billing Cycles {#billing-cycles} @@ -28,7 +28,7 @@ If payment of an invoice fails, Zed will block usage of our hosted models until ## Invoice History {#invoice-history} -You can access your invoice history by navigating to [zed.dev/account](https://zed.dev/account) and clicking `Invoice history` within the embedded Orb portal. +You can access your invoice history by navigating to [dashboard.zed.dev/account](https://dashboard.zed.dev/account) and clicking `Invoice history` within the embedded Orb portal. If you require historical Stripe invoices, email [billing-support@zed.dev](mailto:billing-support@zed.dev) diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index feef6d36d29eca4157254cc4c209f4a614a927de..65a427842cda461806dc79ecf67f3a180afd9763 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -58,7 +58,8 @@ In these cases, `alt-tab` is used instead to accept the prediction. When the lan On Linux, `alt-tab` is often used by the window manager for switching windows, so `alt-l` is provided as the default binding for accepting predictions. `tab` and `alt-tab` also work, but aren't displayed by default. -{#action editor::AcceptPartialEditPrediction} ({#kb editor::AcceptPartialEditPrediction}) can be used to accept the current edit prediction up to the next word boundary. +{#action editor::AcceptNextWordEditPrediction} ({#kb editor::AcceptNextWordEditPrediction}) can be used to accept the current edit prediction up to the next word boundary. +{#action editor::AcceptNextLineEditPrediction} ({#kb editor::AcceptNextLineEditPrediction}) can be used to accept the current edit prediction up to the new line boundary. ## Configuring Edit Prediction Keybindings {#edit-predictions-keybinding} diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index f13ece5d3eb6aac3af38a0046abddc474649f503..ee495b1ba7e67a6cc15359453fd7d3ae41b17233 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -347,6 +347,33 @@ Download and install Ollama from [ollama.com/download](https://ollama.com/downlo 3. In the Agent Panel, select one of the Ollama models using the model dropdown. +#### Ollama Autodiscovery + +Zed will automatically discover models that Ollama has pulled. You can turn this off by setting +the `auto_discover` field in the Ollama settings. If you do this, you should manually specify which +models are available. + +```json [settings] +{ + "language_models": { + "ollama": { + "api_url": "http://localhost:11434", + "auto_discover": false, + "available_models": [ + { + "name": "qwen2.5-coder", + "display_name": "qwen 2.5 coder", + "max_tokens": 32768, + "supports_tools": true, + "supports_thinking": true, + "supports_images": true + } + ] + } + } +} +``` + #### Ollama Context Length {#ollama-context} Zed has pre-configured maximum context lengths (`max_tokens`) to match the capabilities of common models. diff --git a/docs/src/ai/plans-and-usage.md b/docs/src/ai/plans-and-usage.md index fc59a894aacd524a10e31b65ababd4f8d79e3b8e..63f72211aa70b19b820fb9b368d47a3b008b726d 100644 --- a/docs/src/ai/plans-and-usage.md +++ b/docs/src/ai/plans-and-usage.md @@ -12,11 +12,11 @@ Usage of Zed's hosted models is measured on a token basis, converted to dollars Zed Pro comes with $5 of monthly dollar credit. A trial of Zed Pro includes $20 of credit, usable for 14 days. Monthly included credit resets on your monthly billing date. -To view your current usage, you can visit your account at [zed.dev/account](https://zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. +To view your current usage, you can visit your account at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. ## Spend Limits {#usage-spend-limits} -At the top of [the Account page](https://zed.dev/account), you'll find an input for `Maximum Token Spend`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription. +At the top of [the Account page](https://dashboard.zed.dev/account), you'll find an input for `Maximum Token Spend`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription. The default value for all Pro users is $10, for a total monthly spend with Zed of $20 ($10 for your Pro subscription, $10 in incremental token spend). This can be set to $0 to limit your spend with Zed to exactly $10/month. If you adjust this limit _higher_ than $10 and consume more than $10 of incremental token spend, you'll be billed via [threshold billing](./billing.md#threshold-billing). diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index c1ee463dee7ffe76aefee18022ba12df49a18dac..a962e1b65496054e425c914273cfdbb9e08bca34 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -3139,7 +3139,15 @@ List of strings containing any combination of: ```json [settings] { - "restore_on_startup": "none" + "restore_on_startup": "empty_tab" +} +``` + +4. Always start with the welcome launchpad: + +```json [settings] +{ + "restore_on_startup": "launchpad" } ``` diff --git a/docs/src/git.md b/docs/src/git.md index d562eb4d0a3b07f4de7df1b0831f6e91a2767c1d..8a94a79973b390f1d4e8075469b610d51b6f2016 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -145,7 +145,6 @@ You can specify your preferred model to use by providing a `commit_message_model ```json [settings] { "agent": { - "version": "2", "commit_message_model": { "provider": "anthropic", "model": "claude-3-5-haiku" diff --git a/docs/src/installation.md b/docs/src/installation.md index 7802ef7776a78deefb196ab005297e1f54314ea6..7d2009e3a0266160ce4e13056287c36ef7660008 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -22,6 +22,12 @@ brew install --cask zed@preview Get the latest stable builds via [the download page](https://zed.dev/download). If you want to download our preview build, you can find it on its [releases page](https://zed.dev/releases/preview). After the first manual installation, Zed will periodically check for install updates. +Additionally, you can install Zed using winget: + +```sh +winget install -e --id ZedIndustries.Zed +``` + ### Linux For most Linux users, the easiest way to install Zed is through our installation script: diff --git a/docs/src/windows.md b/docs/src/windows.md index 34a553dd5b032915ed52651f7f02b737995b959b..b7b4b6b7bf153a2cae7cbf2b7168d502cfbdaeb0 100644 --- a/docs/src/windows.md +++ b/docs/src/windows.md @@ -6,6 +6,14 @@ Get the latest stable builds via [the download page](https://zed.dev/download). You can also build zed from source, see [these docs](https://zed.dev/docs/development/windows) for instructions. +### Package managers + +Additionally, you can install Zed using winget: + +```sh +winget install -e --id ZedIndustries.Zed +``` + ## Uninstall - Installed via installer: Use `Settings` → `Apps` → `Installed apps`, search for Zed, and click Uninstall. diff --git a/script/prettier b/script/prettier index 5ad5d15cf0353b71a40821f3092ea0e7928abf9d..d7a9ba787fca2343cd705ff0d37e502a7aa9f77c 100755 --- a/script/prettier +++ b/script/prettier @@ -3,14 +3,20 @@ set -euxo pipefail PRETTIER_VERSION=3.5.0 -pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --parser=jsonc --check || { +if [[ "${1:-}" == "--write" ]]; then + MODE="--write" +else + MODE="--check" +fi + +pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --parser=jsonc $MODE || { echo "To fix, run from the root of the Zed repo:" echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --parser=jsonc --write" false } cd docs -pnpm dlx "prettier@${PRETTIER_VERSION}" . --check || { +pnpm dlx "prettier@${PRETTIER_VERSION}" . $MODE || { echo "To fix, run from the root of the Zed repo:" echo " cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .." false diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 717517402d619e54d30a502fcfe26418910aac35..fe476355203a69c962081c36fe350460b9df6f6b 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -5,6 +5,7 @@ use std::fs; use std::path::{Path, PathBuf}; mod after_release; +mod autofix_pr; mod cherry_pick; mod compare_perf; mod danger; @@ -111,6 +112,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(run_tests::run_tests), WorkflowFile::zed(release::release), WorkflowFile::zed(cherry_pick::cherry_pick), + WorkflowFile::zed(autofix_pr::autofix_pr), WorkflowFile::zed(compare_perf::compare_perf), WorkflowFile::zed(run_agent_evals::run_unit_evals), WorkflowFile::zed(run_agent_evals::run_cron_unit_evals), diff --git a/tooling/xtask/src/tasks/workflows/autofix_pr.rs b/tooling/xtask/src/tasks/workflows/autofix_pr.rs new file mode 100644 index 0000000000000000000000000000000000000000..835750e282dad39a3455fc0b5eb69bf82cc42201 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/autofix_pr.rs @@ -0,0 +1,93 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + runners, + steps::{self, FluentBuilder, NamedJob, named}, + vars::{self, StepOutput, WorkflowInput}, +}; + +pub fn autofix_pr() -> Workflow { + let pr_number = WorkflowInput::string("pr_number", None); + let autofix = run_autofix(&pr_number); + named::workflow() + .run_name(format!("autofix PR #{pr_number}")) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default().add_input(pr_number.name, pr_number.input()), + )) + .add_job(autofix.name, autofix.job) +} + +fn run_autofix(pr_number: &WorkflowInput) -> NamedJob { + fn authenticate_as_zippy() -> (Step, StepOutput) { + let step = named::uses( + "actions", + "create-github-app-token", + "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", + ) + .add_with(("app-id", vars::ZED_ZIPPY_APP_ID)) + .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY)) + .id("get-app-token"); + let output = StepOutput::new(&step, "token"); + (step, output) + } + + fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step { + named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token)) + } + + fn run_cargo_fmt() -> Step { + named::bash("cargo fmt --all") + } + + fn run_clippy_fix() -> Step { + named::bash( + "cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged", + ) + } + + fn run_prettier_fix() -> Step { + named::bash("./script/prettier --write") + } + + fn commit_and_push(token: &StepOutput) -> Step { + named::bash(indoc::indoc! {r#" + if git diff --quiet; then + echo "No changes to commit" + else + git add -A + git commit -m "Autofix" + git push + fi + "#}) + .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", + )) + .add_env(("GITHUB_TOKEN", token)) + } + + let (authenticate, token) = authenticate_as_zippy(); + + named::job( + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .add_step(authenticate) + .add_step(steps::checkout_repo_with_token(&token)) + .add_step(checkout_pr(pr_number, &token)) + .add_step(steps::setup_cargo_config(runners::Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::setup_pnpm()) + .add_step(run_prettier_fix()) + .add_step(run_cargo_fmt()) + .add_step(run_clippy_fix()) + .add_step(commit_and_push(&token)) + .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 34fcf8099031ec9d5562c76f45073a9936c285ff..8772011a2d1f48550095a916ab516cc98ac2d1f7 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -1,4 +1,4 @@ -use gh_workflow::*; +use gh_workflow::{ctx::Context, *}; use indoc::indoc; use crate::tasks::workflows::{ @@ -287,7 +287,8 @@ fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) -> .add("base", "main") .add("delete-branch", true) .add("token", generated_token.to_string()) - .add("sign-commits", true), + .add("sign-commits", true) + .add("assignees", Context::github().actor().to_string()), ) } diff --git a/tooling/xtask/src/tasks/workflows/extensions/mod.rs b/tooling/xtask/src/tasks/workflows/extensions.rs similarity index 100% rename from tooling/xtask/src/tasks/workflows/extensions/mod.rs rename to tooling/xtask/src/tasks/workflows/extensions.rs diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 722a5f0704542889703fdbb42c691d01bc50ace6..7d55df2db433d6e6eae96a5ae62a0c033689d904 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -1,6 +1,6 @@ use gh_workflow::*; -use crate::tasks::workflows::{runners::Platform, vars}; +use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput}; pub const BASH_SHELL: &str = "bash -euxo pipefail {0}"; // https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell @@ -17,6 +17,16 @@ pub fn checkout_repo() -> Step { .add_with(("clean", false)) } +pub fn checkout_repo_with_token(token: &StepOutput) -> Step { + named::uses( + "actions", + "checkout", + "11bd71901bbe5b1630ceea73d27597364c9af683", // v4 + ) + .add_with(("clean", false)) + .add_with(("token", token.to_string())) +} + pub fn setup_pnpm() -> Step { named::uses( "pnpm",