Merge remote-tracking branch 'origin/main' into provider-extensions

Richard Feldman created

Change summary

.github/workflows/autofix_pr.yml                                 |  83 
.github/workflows/extension_bump.yml                             |   1 
.gitignore                                                       |   3 
.rules                                                           |   6 
Cargo.lock                                                       |   6 
Cargo.toml                                                       |   2 
README.md                                                        |   2 
assets/keymaps/default-linux.json                                |  17 
assets/keymaps/default-macos.json                                |  13 
assets/keymaps/default-windows.json                              |  13 
assets/keymaps/linux/cursor.json                                 |   3 
assets/keymaps/linux/jetbrains.json                              |   4 
assets/keymaps/macos/cursor.json                                 |   3 
assets/keymaps/macos/jetbrains.json                              |   4 
assets/themes/one/one.json                                       | 104 
clippy.toml                                                      |   1 
crates/acp_thread/Cargo.toml                                     |   1 
crates/acp_thread/src/mention.rs                                 |  19 
crates/agent/src/agent.rs                                        |  24 
crates/agent/src/templates/system_prompt.hbs                     |   2 
crates/agent/src/tests/mod.rs                                    | 219 
crates/agent/src/thread.rs                                       |   4 
crates/agent/src/tools/terminal_tool.rs                          |  33 
crates/agent_ui/src/acp/model_selector.rs                        |   4 
crates/agent_ui/src/acp/thread_view.rs                           | 135 
crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs |  86 
crates/agent_ui/src/agent_ui.rs                                  |   8 
crates/agent_ui/src/buffer_codegen.rs                            |   3 
crates/agent_ui/src/profile_selector.rs                          |  56 
crates/agent_ui/src/ui.rs                                        |   4 
crates/agent_ui/src/ui/unavailable_editing_tooltip.rs            |  29 
crates/agent_ui_v2/src/agents_panel.rs                           |   3 
crates/call/src/call_impl/room.rs                                |  12 
crates/cli/src/main.rs                                           |   2 
crates/client/src/client.rs                                      |  58 
crates/collab/src/api/contributors.rs                            |  80 
crates/copilot/Cargo.toml                                        |   1 
crates/copilot/src/copilot_edit_prediction_delegate.rs           |  13 
crates/copilot/src/sign_in.rs                                    |  44 
crates/diagnostics/src/buffer_diagnostics.rs                     |  10 
crates/diagnostics/src/diagnostics.rs                            |  10 
crates/edit_prediction/src/edit_prediction.rs                    |  97 
crates/edit_prediction/src/edit_prediction_tests.rs              | 168 
crates/edit_prediction/src/mercury.rs                            |   6 
crates/edit_prediction/src/sweep_ai.rs                           |   6 
crates/edit_prediction/src/zeta1.rs                              |  26 
crates/edit_prediction_types/src/edit_prediction_types.rs        |   6 
crates/edit_prediction_ui/src/edit_prediction_button.rs          |  31 
crates/editor/src/actions.rs                                     |   3 
crates/editor/src/editor.rs                                      | 409 
crates/editor/src/editor_tests.rs                                | 163 
crates/editor/src/element.rs                                     |  11 
crates/editor/src/git/blame.rs                                   | 164 
crates/editor/src/hover_links.rs                                 | 124 
crates/editor/src/hover_popover.rs                               |   2 
crates/editor/src/scroll.rs                                      |   6 
crates/editor/src/selections_collection.rs                       |  63 
crates/eval/src/instance.rs                                      |   9 
crates/fs/src/fs.rs                                              |   2 
crates/git_ui/Cargo.toml                                         |   1 
crates/git_ui/src/git_panel.rs                                   | 397 
crates/gpui/src/platform/mac.rs                                  |   2 
crates/gpui/src/platform/mac/attributed_string.rs                |  34 
crates/gpui/src/platform/mac/display.rs                          |   7 
crates/gpui/src/platform/mac/metal_atlas.rs                      |   9 
crates/gpui/src/platform/mac/metal_renderer.rs                   |  61 
crates/gpui/src/platform/mac/platform.rs                         |  12 
crates/gpui/src/platform/mac/screen_capture.rs                   |   5 
crates/gpui/src/platform/mac/window.rs                           |  22 
crates/gpui/src/style.rs                                         |   5 
crates/gpui/src/styled.rs                                        |   7 
crates/gpui/src/taffy.rs                                         |  15 
crates/keymap_editor/src/keymap_editor.rs                        |  95 
crates/language/src/buffer.rs                                    |   6 
crates/language/src/buffer/row_chunk.rs                          |  62 
crates/language_model/src/request.rs                             |  36 
crates/language_models/src/provider/mistral.rs                   |   2 
crates/language_models/src/provider/ollama.rs                    |   8 
crates/language_models/src/settings.rs                           |   1 
crates/languages/src/javascript/injections.scm                   |  43 
crates/languages/src/python.rs                                   |  40 
crates/languages/src/rust.rs                                     |  32 
crates/languages/src/tsx/injections.scm                          |  43 
crates/languages/src/typescript/injections.scm                   |  43 
crates/markdown/src/markdown.rs                                  | 394 +
crates/markdown_preview/src/markdown_renderer.rs                 |  12 
crates/migrator/src/migrations.rs                                |  12 
crates/migrator/src/migrations/m_2025_12_08/keymap.rs            |  33 
crates/migrator/src/migrations/m_2025_12_15/settings.rs          |  52 
crates/migrator/src/migrator.rs                                  |  16 
crates/onboarding/Cargo.toml                                     |   1 
crates/onboarding/src/onboarding.rs                              |  22 
crates/onboarding/src/welcome.rs                                 | 443 -
crates/project/src/lsp_store.rs                                  |  21 
crates/project/src/lsp_store/inlay_hint_cache.rs                 |  11 
crates/project/src/project_settings.rs                           |  21 
crates/prompt_store/src/prompts.rs                               |  12 
crates/recent_projects/src/remote_servers.rs                     |   1 
crates/remote/src/transport/ssh.rs                               |  18 
crates/settings/src/keymap_file.rs                               |   8 
crates/settings/src/settings_content.rs                          |   3 
crates/settings/src/settings_content/language_model.rs           |   1 
crates/settings/src/settings_content/workspace.rs                |   9 
crates/settings/src/settings_store.rs                            |  11 
crates/settings_ui/src/page_data.rs                              |  25 
crates/settings_ui/src/settings_ui.rs                            |  82 
crates/tab_switcher/src/tab_switcher.rs                          |   4 
crates/terminal/src/terminal.rs                                  |   5 
crates/terminal/src/terminal_hyperlinks.rs                       |  80 
crates/title_bar/src/title_bar.rs                                |   2 
crates/ui/src/components/divider.rs                              |   2 
crates/vim/src/helix.rs                                          |   3 
crates/vim/src/normal/mark.rs                                    |  72 
crates/vim/src/state.rs                                          |  10 
crates/workspace/Cargo.toml                                      |   2 
crates/workspace/src/item.rs                                     |  10 
crates/workspace/src/notifications.rs                            |  77 
crates/workspace/src/pane.rs                                     |  49 
crates/workspace/src/shared_screen.rs                            |   5 
crates/workspace/src/welcome.rs                                  | 568 ++
crates/workspace/src/workspace.rs                                | 104 
crates/zed/src/main.rs                                           |   8 
crates/zed/src/zed.rs                                            |  20 
crates/zed/src/zed/open_listener.rs                              | 275 
crates/zed_actions/src/lib.rs                                    |   2 
docs/src/ai/billing.md                                           |   4 
docs/src/ai/edit-prediction.md                                   |   3 
docs/src/ai/llm-providers.md                                     |  27 
docs/src/ai/plans-and-usage.md                                   |   4 
docs/src/configuring-zed.md                                      |  10 
docs/src/git.md                                                  |   1 
docs/src/installation.md                                         |   6 
docs/src/windows.md                                              |   8 
script/prettier                                                  |  10 
tooling/xtask/src/tasks/workflows.rs                             |   2 
tooling/xtask/src/tasks/workflows/autofix_pr.rs                  |  93 
tooling/xtask/src/tasks/workflows/extension_bump.rs              |   5 
tooling/xtask/src/tasks/workflows/extensions.rs                  |   0 
tooling/xtask/src/tasks/workflows/steps.rs                       |  12 
139 files changed, 4,480 insertions(+), 1,620 deletions(-)

Detailed changes

.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}

.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:

.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

.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.

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",

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"

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:
 

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],
     },
   },
   {

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],
     },
   },
   {

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],
     },
   },
   {

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",
     },
   },
   {

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",

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",
     },
   },
   {

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",

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",

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" },

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

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");

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<Utc>,
 }
 
-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)]

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

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<AtomicBool>,
+    wait_for_exit: Shared<Task<acp::TerminalExitStatus>>,
+    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<acp::TerminalId> {
+        Ok(self.id.clone())
+    }
+
+    fn current_output(&self, _cx: &AsyncApp) -> Result<acp::TerminalOutputResponse> {
+        Ok(self.output.clone())
+    }
+
+    fn wait_for_exit(&self, _cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>> {
+        Ok(self.wait_for_exit.clone())
+    }
+
+    fn kill(&self, _cx: &AsyncApp) -> Result<()> {
+        self.killed.store(true, Ordering::SeqCst);
+        Ok(())
+    }
+}
+
+struct FakeThreadEnvironment {
+    handle: Rc<FakeTerminalHandle>,
+}
+
+impl crate::ThreadEnvironment for FakeThreadEnvironment {
+    fn create_terminal(
+        &self,
+        _command: String,
+        _cwd: Option<std::path::PathBuf>,
+        _output_byte_limit: Option<u64>,
+        _cx: &mut AsyncApp,
+    ) -> Task<Result<Rc<dyn crate::TerminalHandle>>> {
+        Task::ready(Ok(self.handle.clone() as Rc<dyn crate::TerminalHandle>))
+    }
+}
+
+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<Fuse<Task<Result<String>>>>> = 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;

crates/agent/src/thread.rs 🔗

@@ -530,6 +530,7 @@ pub trait TerminalHandle {
     fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId>;
     fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse>;
     fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>>;
+    fn kill(&self, cx: &AsyncApp) -> Result<()>;
 }
 
 pub trait ThreadEnvironment {
@@ -2658,7 +2659,6 @@ impl From<UserMessageContent> 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,
     }
 }

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<u64>,
 }
 
 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))

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::<Vec<_>>();
         let mut matches = match_strings(
             &candidates,

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<Self>) {
+        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<Self>) {
         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);

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<Arc<dyn LanguageModel>>,
     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::<SettingsStore>(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<Self>,
+    ) {
+        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<Self>) {
         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)
     }
 }

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::<AcceptEditPrediction>(),
-            TypeId::of::<AcceptPartialEditPrediction>(),
+            TypeId::of::<AcceptNextWordEditPrediction>(),
+            TypeId::of::<AcceptNextLineEditPrediction>(),
+            TypeId::of::<AcceptEditPrediction>(),
             TypeId::of::<ShowEditPrediction>(),
             TypeId::of::<NextEditPrediction>(),
             TypeId::of::<PreviousEditPrediction>(),

crates/agent_ui/src/buffer_codegen.rs 🔗

@@ -409,6 +409,9 @@ impl CodegenAlternative {
         model: Arc<dyn LanguageModel>,
         cx: &mut Context<Self>,
     ) -> 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);

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<Self>) {
+        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 == &current_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,
         )

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::*;

crates/agent_ui/src/ui/unavailable_editing_tooltip.rs 🔗

@@ -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<Self>) -> 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),
-                ),
-            )
-        })
-    }
-}

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);

crates/call/src/call_impl/room.rs 🔗

@@ -305,6 +305,7 @@ impl Room {
 
     pub(crate) fn leave(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
         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<Self>) {
+        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<Self>,
         client: Arc<Client>,
@@ -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
                         }
                     });

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

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<String>,
+    },
+}
+
 /// 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<ZedLink> {
     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::<u64>().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

crates/collab/src/api/contributors.rs 🔗

@@ -54,6 +54,26 @@ async fn check_is_contributor(
 ) -> Result<Json<CheckIsContributorResponse>> {
     let params = params.into_contributor_selector()?;
 
+    if CopilotSweAgentBot::is_copilot_bot(&params) {
+        return Ok(Json(CheckIsContributorResponse {
+            signed_at: Some(
+                CopilotSweAgentBot::created_at()
+                    .and_utc()
+                    .to_rfc3339_opts(SecondsFormat::Millis, true),
+            ),
+        }));
+    }
+
+    if Dependabot::is_dependabot(&params) {
+        return Ok(Json(CheckIsContributorResponse {
+            signed_at: Some(
+                Dependabot::created_at()
+                    .and_utc()
+                    .to_rfc3339_opts(SecondsFormat::Millis, true),
+            ),
+        }));
+    }
+
     if RenovateBot::is_renovate_bot(&params) {
         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<NaiveDateTime> = 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<NaiveDateTime> = 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]

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"] }

crates/copilot/src/copilot_edit_prediction_delegate.rs 🔗

@@ -269,6 +269,7 @@ fn common_prefix<T1: Iterator<Item = char>, T2: Iterator<Item = char>>(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!(

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<Copilot>,
     _subscription: Subscription,
+    sign_up_url: Option<String>,
 }
 
 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<Self>) {
+        // 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<String> {
+        // 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<Self>) -> impl IntoElement {
         let copied = cx
             .read_from_clipboard()
@@ -302,7 +335,12 @@ impl CopilotCodeVerification {
             )
     }
 
-    fn render_unauthorized_modal(cx: &mut Context<Self>) -> impl Element {
+    fn render_unauthorized_modal(&self, cx: &mut Context<Self>) -> 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;

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<Vec<BreadcrumbText>> {

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<Vec<BreadcrumbText>> {

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<String> = LazyLock::new(|| {
     }
     .to_string()
 });
-static PREDICT_EDITS_URL: LazyLock<Option<String>> = 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<EditPredictionRejection>,
     shown_predictions: VecDeque<EditPrediction>,
     rated_predictions: HashSet<EditPredictionId>,
+    custom_predict_edits_url: Option<Arc<Url>>,
 }
 
 #[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<Project>, cx: &mut Context<Self>) {
+        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<Arc<dyn EvalCache>>,
         #[cfg(feature = "cli-support")] eval_cache_kind: EvalCacheEntryKind,
     ) -> Result<(open_ai::Response, Option<EditPredictionUsage>)> {
-        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<Client>,
         llm_token: LlmApiToken,
         app_version: Version,
+        require_auth: bool,
     ) -> Result<(Res, Option<EditPredictionUsage>)>
     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?;

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();

crates/edit_prediction/src/mercury.rs 🔗

@@ -309,3 +309,9 @@ pub fn mercury_api_token(cx: &mut App) -> Entity<ApiKeyState> {
         })
         .clone()
 }
+
+pub fn load_mercury_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {
+    mercury_api_token(cx).update(cx, |key_state, cx| {
+        key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx)
+    })
+}

crates/edit_prediction/src/sweep_ai.rs 🔗

@@ -282,6 +282,12 @@ pub fn sweep_api_token(cx: &mut App) -> Entity<ApiKeyState> {
         .clone()
 }
 
+pub fn load_sweep_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {
+    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<str>,

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::<PredictEditsResponse>(
             |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;
 

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(

crates/edit_prediction_ui/src/edit_prediction_button.rs 🔗

@@ -487,6 +487,21 @@ impl EditPredictionButton {
         cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
             .detach();
 
+        cx.observe_global::<EditPredictionStore>(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<EditPredictionProvider> {
+    fn get_available_providers(&self, cx: &mut App) -> Vec<EditPredictionProvider> {
         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::<SweepFeatureFlag>()
-            && 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::<MercuryFeatureFlag>()
-            && 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,

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.

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<Self>,
     ) {
@@ -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::<EditPredictionPreview>();
+                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::<EditPredictionPreview>(
-                            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::<EditPredictionPreview>();
+                            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::<EditPredictionPreview>(
+                                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::<MultiBufferOffset>(&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::<String>();
+                                    if partial.is_empty() {
+                                        partial = text
+                                            .chars()
+                                            .by_ref()
+                                            .take_while(|c| c.is_whitespace() || !c.is_alphabetic())
+                                            .collect::<String>();
+                                    }
+                                    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<Self>,
     ) {
-        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::<MultiBufferOffset>(&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::<String>();
-                    if partial_completion.is_empty() {
-                        partial_completion = text
-                            .chars()
-                            .by_ref()
-                            .take_while(|c| c.is_whitespace() || !c.is_alphabetic())
-                            .collect::<String>();
-                    }
-
-                    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>,
+    ) {
+        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>,
+    ) {
+        self.accept_partial_edit_prediction(EditPredictionGranularity::Full, window, cx);
     }
 
     fn discard_edit_prediction(
@@ -8042,21 +8106,23 @@ impl Editor {
         cx: &mut Context<Self>,
     ) {
         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<AnyElement> {
-        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();
 

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::<Vec<_>>();
+        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, |_| {});

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,

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::<Vec<_>>()
-        });
-        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::<Vec<_>>()
+            })
+            .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::<Vec<_>>()
-                            .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::<Result<Vec<_>>>()
+                })??;
+                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:?}");
                         }
                     })
                 }

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<usize>)> {
+    static MD_LINK_REGEX: LazyLock<Regex> =
+        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<text::Anchor>, 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<String> = 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<String> = 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<String> = 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<String> = 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<String> =
+            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);

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()
     }
 }

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 {

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::<Vec<_>>();
         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<D: Ord + fmt::Debug + Copy>(
     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<D: Ord + fmt::Debug + Copy>(
         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<T: Ord + Copy>(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
+}

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 {

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() }
             }

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

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<GitStatusEntry>,
-        repo: &Repository,
         seen_directories: &mut HashSet<TreeKey>,
-        optimistic_staging: &HashMap<RepoPath, bool>,
     ) -> 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<TreeKey>,
-        optimistic_staging: &HashMap<RepoPath, bool>,
     ) -> (Vec<(GitListEntry, bool)>, Vec<GitStatusEntry>) {
         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<RepoPath, bool>,
-    ) -> 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<Task<()>>,
     bulk_staging: Option<BulkStaging>,
     stash_entries: GitStash,
-    optimistic_staging: HashMap<RepoPath, bool>,
     _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::<Vec<_>>();
@@ -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::<Vec<_>>();
-                    (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<TreeKey, ToggleState> = 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<Project>,
+        repo_work_dir: &Arc<Path>,
+        cx: &mut AsyncApp,
+    ) -> Option<String> {
+        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<Self>) {
         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\
+                        <project_rules>\n{rules}\n</project_rules>\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<RepoPath> = 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)
                             }),

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() }
 }

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];

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) };

crates/gpui/src/platform/mac/metal_atlas.rs 🔗

@@ -15,6 +15,9 @@ pub(crate) struct MetalAtlas(Mutex<MetalAtlasState>);
 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<Device>,
+    unified_memory: bool,
     monochrome_textures: AtlasTextureList<MetalAtlasTexture>,
     polychrome_textures: AtlasTextureList<MetalAtlasTexture>,
     tiles_by_key: FxHashMap<AtlasKey, AtlasTile>,
@@ -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 {

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())
     }
 

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<PathBuf> {
     let path: *mut c_char = msg_send![url, fileSystemRepresentation];
     anyhow::ensure!(!path.is_null(), "url is not a file path: {}", unsafe {

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<CGDirectDisplayID, ScreenMeta> {
     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];

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<UserTabbingPreference> {
         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];

crates/gpui/src/style.rs 🔗

@@ -265,6 +265,10 @@ pub struct Style {
     /// Equivalent to the Tailwind `grid-cols-<number>`
     pub grid_cols: Option<u16>,
 
+    /// 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<u16>,
+
     /// The row span of this element
     /// Equivalent to the Tailwind `grid-rows-<number>`
     pub grid_rows: Option<u16>,
@@ -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)]

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);

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<taffy::style::Style> for Style {
                 .unwrap_or_default()
         }
 
+        fn to_grid_repeat_min_content<T: taffy::style::CheapCloneStr>(
+            unit: &Option<u16>,
+        ) -> Vec<taffy::GridTemplateComponent<T>> {
+            // grid-template-columns: repeat(<number>, 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<taffy::style::Style> 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()

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<String>, 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::<KeymapEditor>());
-
-                    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<String>,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+    ) {
+        workspace
+            .with_local_workspace(window, cx, |workspace, window, cx| {
+                let existing = workspace
+                    .active_pane()
+                    .read(cx)
+                    .items()
+                    .find_map(|item| item.downcast::<KeymapEditor>());
+
+                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::<KeymapEditor>(cx);
 }

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) =

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;
 /// <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#inlayHintParams>
 #[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::<Vec<_>>();
+        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::<Vec<_>>();
         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<text::Anchor>],
-    ) -> impl Iterator<Item = RowChunk> {
+    pub fn applicable_chunks(&self, ranges: &[Range<Point>]) -> impl Iterator<Item = RowChunk> {
         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<Range<Anchor>> {
-        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<RowChunk> {
         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<BufferRow> {
         self.start..self.end_exclusive
     }
+
+    pub fn anchor_range(&self) -> Range<Anchor> {
+        self.start_anchor..self.end_anchor
+    }
 }

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<DevicePixels>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub size: Option<Size<DevicePixels>>,
 }
 
 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"),
         }

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,

crates/language_models/src/provider/ollama.rs 🔗

@@ -43,6 +43,7 @@ static API_KEY_ENV_VAR: LazyLock<EnvVar> = 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<AvailableModel>,
 }
 
@@ -238,10 +239,13 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
 
     fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
         let mut models: HashMap<String, ollama::Model> = 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

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 {

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")
+)

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 {

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<LanguageServerBinary> {
-    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<LanguageServ
             last = Some(path);
         }
 
-        let path = last.context("no cached binary")?;
+        let path = match last {
+            Some(last) => 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 {

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")
+)

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")
+)

crates/markdown/src/markdown.rs 🔗

@@ -70,6 +70,7 @@ pub struct MarkdownStyle {
     pub heading_level_styles: Option<HeadingLevelStyles>,
     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<Self>) {
-        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<Self>) {
         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<usize>),
+    Line(Range<usize>),
+    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::<crate::CopyAsMarkdown>(), {
-            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<Alignment>,
+    in_head: bool,
+    row_index: usize,
+    col_index: usize,
+}
+
+impl TableState {
+    fn start(&mut self, alignments: Vec<Alignment>) {
+        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<AnyDiv>,
     rendered_lines: Vec<RenderedLine>,
@@ -1457,7 +1549,7 @@ struct MarkdownElementBuilder {
     text_style_stack: Vec<TextStyleRefinement>,
     code_block_stack: Vec<Option<Arc<Language>>>,
     list_stack: Vec<ListStackEntry>,
-    table_alignments: Vec<Alignment>,
+    table: TableState,
     syntax_theme: Arc<SyntaxTheme>,
 }
 
@@ -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\\`");

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()

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;
+}

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<usize>, 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<HashMap<&str, &str>> = LazyLock::new(|| {
+    HashMap::from_iter([(
+        "editor::AcceptPartialEditPrediction",
+        "editor::AcceptNextWordEditPrediction",
+    )])
+});

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<usize>, 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")
+}

crates/migrator/src/migrator.rs 🔗

@@ -139,6 +139,10 @@ pub fn migrate_keymap(text: &str) -> Result<Option<String>> {
             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<Option<String>> {
             &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<Query> = LazyLock::new(|| {

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

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);
             }
 

crates/onboarding/src/welcome.rs 🔗

@@ -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<const COLS: usize> {
-    title: &'static str,
-    entries: [SectionEntry; COLS],
-}
-
-impl<const COLS: usize> Section<COLS> {
-    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<Self>) {
-        window.focus_next();
-        cx.notify();
-    }
-
-    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
-        window.focus_prev();
-        cx.notify();
-    }
-}
-
-impl Render for WelcomePage {
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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::<WelcomePage>()?;
-                                                                        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::<Onboarding>()?;
-                                                                            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<Self> {
-        cx.new(|cx| {
-            let focus_handle = cx.focus_handle();
-            cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
-                .detach();
-
-            WelcomePage { focus_handle }
-        })
-    }
-}
-
-impl EventEmitter<ItemEvent> 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<workspace::ItemId>,
-        _window: &mut Window,
-        cx: &mut App,
-    ) -> Task<gpui::Result<()>> {
-        workspace::delete_unloaded_items(
-            alive_items,
-            workspace_id,
-            "welcome_pages",
-            &persistence::WELCOME_PAGES,
-            cx,
-        )
-    }
-
-    fn deserialize(
-        _project: Entity<project::Project>,
-        _workspace: gpui::WeakEntity<workspace::Workspace>,
-        workspace_id: workspace::WorkspaceId,
-        item_id: workspace::ItemId,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> Task<gpui::Result<Entity<Self>>> {
-        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<Self>,
-    ) -> Option<Task<gpui::Result<()>>> {
-        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<bool> {
-                SELECT is_open
-                FROM welcome_pages
-                WHERE item_id = ? AND workspace_id = ?
-            }
-        }
-    }
-}

crates/project/src/lsp_store.rs 🔗

@@ -6849,9 +6849,15 @@ impl LspStore {
         ranges: &[Range<text::Anchor>],
         cx: &mut Context<Self>,
     ) -> Vec<Range<BufferRow>> {
+        let buffer_snapshot = buffer.read(cx).snapshot();
+        let ranges = ranges
+            .iter()
+            .map(|range| range.to_point(&buffer_snapshot))
+            .collect::<Vec<_>>();
+
         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::<Vec<_>>();
+
         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::<Vec<_>>();
             match chunks_queried_for.as_slice() {
                 &[chunk] => {

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<text::Anchor>],
-    ) -> impl Iterator<Item = RowChunk> {
+    pub fn applicable_chunks(&self, ranges: &[Range<Point>]) -> impl Iterator<Item = RowChunk> {
         self.chunks.applicable_chunks(ranges)
     }
 
@@ -226,8 +223,4 @@ impl BufferInlayHints {
             }
         }
     }
-
-    pub fn chunk_range(&self, chunk: RowChunk) -> Option<Range<Anchor>> {
-        self.chunks.chunk_range(chunk)
-    }
 }

crates/project/src/project_settings.rs 🔗

@@ -792,13 +792,20 @@ impl SettingsObserver {
         event: &WorktreeStoreEvent,
         cx: &mut Context<Self>,
     ) {
-        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::<SettingsStore, _>(|store, cx| {
+                    store.clear_local_settings(*worktree_id, cx).log_err();
+                });
+            }
+            _ => {}
         }
     }
 

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<WorktreeContext>,

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,
                 })
         });
     }

crates/remote/src/transport/ssh.rs 🔗

@@ -55,6 +55,7 @@ pub struct SshConnectionOptions {
     pub password: Option<String>,
     pub args: Option<Vec<String>>,
     pub port_forwards: Option<Vec<SshPortForwardOption>>,
+    pub connection_timeout: Option<u16>,
 
     pub nickname: Option<String>,
     pub upload_binary_over_ssh: bool,
@@ -71,6 +72,7 @@ impl From<settings::SshConnection> 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<String> {
         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 {

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),

crates/settings/src/settings_content.rs 🔗

@@ -930,6 +930,9 @@ pub struct SshConnection {
     pub upload_binary_over_ssh: Option<bool>,
 
     pub port_forwards: Option<Vec<SshPortForwardOption>>,
+    /// Timeout in seconds for SSH connection and downloading the remote server binary.
+    /// Defaults to 10 seconds if not specified.
+    pub connection_timeout: Option<u16>,
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom, Debug)]

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<String>,
+    pub auto_discover: Option<bool>,
     pub available_models: Option<Vec<OllamaAvailableModel>>,
 }
 

crates/settings/src/settings_content/workspace.rs 🔗

@@ -42,7 +42,7 @@ pub struct WorkspaceSettingsContent {
     /// Default: off
     pub autosave: Option<AutosaveSetting>,
     /// 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<RestoreOnStartupBehavior>,
     /// 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]

crates/settings/src/settings_store.rs 🔗

@@ -247,6 +247,7 @@ pub trait AnySettingValue: 'static + Send + Sync {
     fn all_local_values(&self) -> Vec<(WorktreeId, Arc<RelPath>, &dyn Any)>;
     fn set_global_value(&mut self, value: Box<dyn Any>);
     fn set_local_value(&mut self, root_id: WorktreeId, path: Arc<RelPath>, value: Box<dyn Any>);
+    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<T: Settings> AnySettingValue for SettingValue<T> {
             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)]

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> {
         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",

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<SharedString>,
+    button_text: SharedString,
+    on_click: Arc<dyn Fn(&mut SettingsWindow, &mut Window, &mut App) + Send + Sync>,
+}
+
+impl PartialEq for ActionLink {
+    fn eq(&self, other: &Self) -> bool {
+        self.title == other.title
+    }
+}
+
 fn all_language_names(cx: &App) -> Vec<SharedString> {
     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);

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

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<RangeInclusive<AlacPoint>>,
     pub last_content: TerminalContent,
     pub selection_head: Option<AlacPoint>,
+
     pub breadcrumb_text: String,
     title_override: Option<String>,
     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
             }

crates/terminal/src/terminal_hyperlinks.rs 🔗

@@ -160,8 +160,8 @@ fn sanitize_url_punctuation<T: EventListener>(
     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<T: EventListener>(
                 _ => (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);
         }

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)

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()),

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,

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::<Editor>(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"));
+        })
+    }
 }

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)
     }
 

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

crates/workspace/src/item.rs 🔗

@@ -883,8 +883,14 @@ impl<T: Item> ItemHandle for Entity<T> {
                     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);
+                        }
                     }
                 },
             )

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<project::LanguageServerPromptRequest>,
     scroll_handle: ScrollHandle,
+    markdown: Entity<Markdown>,
 }
 
 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::<WorkspaceErrorNotification>()
 }
 
+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,

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<ProjectItemKind, Box<dyn Any + Send>>,
+    welcome_page: Option<Entity<crate::welcome::WelcomePage>>,
 
     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<Self>) {
+        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<Self>) {
+        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())
                             }
                         }
                     })

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),

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<SharedString>) -> 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<dyn Action>,
+    tab_index: usize,
+    focus_handle: FocusHandle,
+}
+
+impl SectionButton {
+    fn new(
+        label: impl Into<SharedString>,
+        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<const COLS: usize> {
+    title: &'static str,
+    entries: [SectionEntry; COLS],
+}
+
+impl<const COLS: usize> Section<COLS> {
+    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<Workspace>,
+    focus_handle: FocusHandle,
+    fallback_to_recent_projects: bool,
+    recent_workspaces: Option<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>>,
+}
+
+impl WelcomePage {
+    pub fn new(
+        workspace: WeakEntity<Workspace>,
+        fallback_to_recent_projects: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> 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<Self>, 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<Self>) {
+        window.focus_next();
+        cx.notify();
+    }
+
+    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_prev();
+        cx.notify();
+    }
+
+    fn open_recent_project(
+        &mut self,
+        action: &OpenRecentProject,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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>,
+    ) -> 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<Self>) -> 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::<Vec<_>>();
+
+        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<ItemEvent> 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<crate::ItemId>,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Task<gpui::Result<()>> {
+        crate::delete_unloaded_items(
+            alive_items,
+            workspace_id,
+            "welcome_pages",
+            &persistence::WELCOME_PAGES,
+            cx,
+        )
+    }
+
+    fn deserialize(
+        _project: Entity<project::Project>,
+        workspace: gpui::WeakEntity<Workspace>,
+        workspace_id: crate::WorkspaceId,
+        item_id: crate::ItemId,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task<gpui::Result<Entity<Self>>> {
+        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<Self>,
+    ) -> Option<Task<gpui::Result<()>>> {
+        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<bool> {
+                SELECT is_open
+                FROM welcome_pages
+                WHERE item_id = ? AND workspace_id = ?
+            }
+        }
+    }
+}

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);

crates/zed/src/main.rs 🔗

@@ -1161,7 +1161,13 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, 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);
+                        }
+                    }
                 },
             )
         })?

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<F>(
                 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",

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::<u64>()
-        {
-            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<AppState>,
     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::<CliResponse>().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);

crates/zed_actions/src/lib.rs 🔗

@@ -70,6 +70,8 @@ actions!(
         OpenTelemetryLog,
         /// Opens the performance profiler.
         OpenPerformanceProfiler,
+        /// Opens the onboarding view.
+        OpenOnboarding,
     ]
 );
 

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)
 

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}
 

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.

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).
 

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"
 }
 ```
 

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"

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:

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.

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

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),

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<Use>, 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<Run> {
+        named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token))
+    }
+
+    fn run_cargo_fmt() -> Step<Run> {
+        named::bash("cargo fmt --all")
+    }
+
+    fn run_clippy_fix() -> Step<Run> {
+        named::bash(
+            "cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged",
+        )
+    }
+
+    fn run_prettier_fix() -> Step<Run> {
+        named::bash("./script/prettier --write")
+    }
+
+    fn commit_and_push(token: &StepOutput) -> Step<Run> {
+        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)),
+    )
+}

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()),
     )
 }
 

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<Use> {
     .add_with(("clean", false))
 }
 
+pub fn checkout_repo_with_token(token: &StepOutput) -> Step<Use> {
+    named::uses(
+        "actions",
+        "checkout",
+        "11bd71901bbe5b1630ceea73d27597364c9af683", // v4
+    )
+    .add_with(("clean", false))
+    .add_with(("token", token.to_string()))
+}
+
 pub fn setup_pnpm() -> Step<Use> {
     named::uses(
         "pnpm",