Merge branch 'main' into multibuffer_breadcrumbs_toolbar_redesign

KyleBarton created

Change summary

.factory/prompts/docs-automation/phase2-explore.md                        |   55 
.factory/prompts/docs-automation/phase3-analyze.md                        |   57 
.factory/prompts/docs-automation/phase4-plan.md                           |   76 
.factory/prompts/docs-automation/phase5-apply.md                          |   67 
.factory/prompts/docs-automation/phase6-summarize.md                      |   54 
.factory/prompts/docs-automation/phase7-commit.md                         |   67 
.github/actions/build_docs/action.yml                                     |   12 
.github/workflows/autofix_pr.yml                                          |  132 
.github/workflows/cherry_pick.yml                                         |    2 
.github/workflows/community_champion_auto_labeler.yml                     |    1 
.github/workflows/community_close_stale_issues.yml                        |   25 
.github/workflows/docs_automation.yml                                     |  264 
.github/workflows/extension_bump.yml                                      |    1 
.github/workflows/release.yml                                             |    8 
.github/workflows/run_tests.yml                                           |   11 
.gitignore                                                                |    4 
.mailmap                                                                  |    3 
CONTRIBUTING.md                                                           |   37 
Cargo.lock                                                                |  381 
Cargo.toml                                                                |   27 
Dockerfile-collab                                                         |    2 
README.md                                                                 |    3 
REVIEWERS.conl                                                            |    8 
assets/keymaps/default-linux.json                                         |   17 
assets/keymaps/default-macos.json                                         |   24 
assets/keymaps/default-windows.json                                       |   18 
assets/keymaps/linux/cursor.json                                          |    3 
assets/keymaps/linux/jetbrains.json                                       |    4 
assets/keymaps/macos/jetbrains.json                                       |    4 
assets/keymaps/vim.json                                                   |    5 
assets/prompts/content_prompt_v2.hbs                                      |    3 
assets/settings/default.json                                              |   34 
assets/themes/one/one.json                                                |  104 
crates/acp_thread/Cargo.toml                                              |    1 
crates/acp_thread/src/acp_thread.rs                                       |   50 
crates/acp_thread/src/connection.rs                                       |   21 
crates/acp_thread/src/mention.rs                                          |   19 
crates/action_log/src/action_log.rs                                       |    8 
crates/agent/src/agent.rs                                                 |  366 
crates/agent/src/history_store.rs                                         |   13 
crates/agent/src/tests/mod.rs                                             |  178 
crates/agent/src/thread.rs                                                |  102 
crates/agent/src/tools.rs                                                 |    8 
crates/agent/src/tools/context_server_registry.rs                         |  200 
crates/agent/src/tools/edit_file_tool.rs                                  |   47 
crates/agent/src/tools/restore_file_from_disk_tool.rs                     |  352 
crates/agent/src/tools/save_file_tool.rs                                  |  351 
crates/agent_settings/Cargo.toml                                          |    1 
crates/agent_settings/src/agent_settings.rs                               |   12 
crates/agent_ui/Cargo.toml                                                |    2 
crates/agent_ui/src/acp/message_editor.rs                                 |  249 
crates/agent_ui/src/acp/mode_selector.rs                                  |   14 
crates/agent_ui/src/acp/model_selector.rs                                 |  423 
crates/agent_ui/src/acp/model_selector_popover.rs                         |   71 
crates/agent_ui/src/acp/thread_history.rs                                 |   24 
crates/agent_ui/src/acp/thread_view.rs                                    |  656 
crates/agent_ui/src/agent_configuration.rs                                |   16 
crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs         |   10 
crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs |    2 
crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs          |  143 
crates/agent_ui/src/agent_diff.rs                                         |   12 
crates/agent_ui/src/agent_model_selector.rs                               |   49 
crates/agent_ui/src/agent_panel.rs                                        |  248 
crates/agent_ui/src/agent_ui.rs                                           |    7 
crates/agent_ui/src/buffer_codegen.rs                                     |  125 
crates/agent_ui/src/completion_provider.rs                                |   13 
crates/agent_ui/src/favorite_models.rs                                    |   57 
crates/agent_ui/src/inline_assistant.rs                                   |   62 
crates/agent_ui/src/inline_prompt_editor.rs                               |   88 
crates/agent_ui/src/language_model_selector.rs                            |  325 
crates/agent_ui/src/profile_selector.rs                                   |   56 
crates/agent_ui/src/terminal_inline_assistant.rs                          |    6 
crates/agent_ui/src/text_thread_editor.rs                                 |  283 
crates/agent_ui/src/ui.rs                                                 |    4 
crates/agent_ui/src/ui/acp_onboarding_modal.rs                            |    4 
crates/agent_ui/src/ui/claude_code_onboarding_modal.rs                    |    4 
crates/agent_ui/src/ui/model_selector_components.rs                       |  189 
crates/agent_ui/src/ui/onboarding_modal.rs                                |    4 
crates/agent_ui/src/ui/unavailable_editing_tooltip.rs                     |   29 
crates/agent_ui_v2/Cargo.toml                                             |    7 
crates/agent_ui_v2/LICENSE-GPL                                            |    2 
crates/agent_ui_v2/src/thread_history.rs                                  |   24 
crates/ai_onboarding/src/agent_api_keys_onboarding.rs                     |   20 
crates/ai_onboarding/src/agent_panel_onboarding_content.rs                |   21 
crates/anthropic/src/anthropic.rs                                         |   65 
crates/bedrock/src/bedrock.rs                                             |    2 
crates/buffer_diff/src/buffer_diff.rs                                     |   39 
crates/call/src/call_impl/room.rs                                         |   12 
crates/cli/src/main.rs                                                    |    2 
crates/client/src/client.rs                                               |   58 
crates/codestral/src/codestral.rs                                         |   12 
crates/collab/src/api/contributors.rs                                     |   85 
crates/collab/src/tests/remote_editing_collaboration_tests.rs             |  288 
crates/collab/src/tests/test_server.rs                                    |    3 
crates/collab_ui/src/collab_panel.rs                                      |   16 
crates/collab_ui/src/collab_panel/channel_modal.rs                        |    2 
crates/command_palette/src/command_palette.rs                             |    6 
crates/component_preview/Cargo.toml                                       |   45 
crates/component_preview/LICENSE-GPL                                      |    1 
crates/component_preview/examples/component_preview.rs                    |   18 
crates/component_preview/src/component_preview.rs                         |   18 
crates/component_preview/src/component_preview_example.rs                 |  145 
crates/component_preview/src/persistence.rs                               |    0 
crates/context_server/Cargo.toml                                          |    1 
crates/context_server/src/client.rs                                       |  103 
crates/context_server/src/context_server.rs                               |   16 
crates/context_server/src/protocol.rs                                     |    6 
crates/context_server/src/types.rs                                        |    2 
crates/copilot/Cargo.toml                                                 |    1 
crates/copilot/src/copilot.rs                                             |  175 
crates/copilot/src/copilot_edit_prediction_delegate.rs                    |  421 
crates/copilot/src/request.rs                                             |  100 
crates/copilot/src/sign_in.rs                                             |   48 
crates/debugger_ui/src/debugger_panel.rs                                  |    4 
crates/debugger_ui/src/new_process_modal.rs                               |   22 
crates/debugger_ui/src/onboarding_modal.rs                                |    4 
crates/debugger_ui/src/session/running.rs                                 |    2 
crates/debugger_ui/src/session/running/breakpoint_list.rs                 |   10 
crates/debugger_ui/src/session/running/console.rs                         |    2 
crates/debugger_ui/src/session/running/memory_view.rs                     |    2 
crates/debugger_ui/src/session/running/variable_list.rs                   |    4 
crates/deepseek/src/deepseek.rs                                           |    5 
crates/diagnostics/src/buffer_diagnostics.rs                              |   16 
crates/diagnostics/src/diagnostic_renderer.rs                             |    2 
crates/diagnostics/src/diagnostics.rs                                     |   16 
crates/docs_preprocessor/Cargo.toml                                       |    5 
crates/docs_preprocessor/src/main.rs                                      |   88 
crates/edit_prediction/src/mercury.rs                                     |   19 
crates/edit_prediction/src/onboarding_modal.rs                            |    4 
crates/edit_prediction/src/sweep_ai.rs                                    |   19 
crates/edit_prediction/src/zed_edit_prediction_delegate.rs                |   11 
crates/edit_prediction_cli/src/headless.rs                                |    3 
crates/edit_prediction_cli/src/load_project.rs                            |    1 
crates/edit_prediction_types/src/edit_prediction_types.rs                 |   26 
crates/edit_prediction_ui/src/rate_prediction_modal.rs                    |    2 
crates/editor/benches/editor_render.rs                                    |    6 
crates/editor/src/bracket_colorization.rs                                 |   55 
crates/editor/src/code_context_menus.rs                                   |  157 
crates/editor/src/display_map.rs                                          |   49 
crates/editor/src/display_map/block_map.rs                                |    3 
crates/editor/src/display_map/inlay_map.rs                                |    9 
crates/editor/src/display_map/wrap_map.rs                                 |  118 
crates/editor/src/edit_prediction_tests.rs                                |   18 
crates/editor/src/editor.rs                                               | 1105 
crates/editor/src/editor_settings.rs                                      |    3 
crates/editor/src/editor_tests.rs                                         | 1286 
crates/editor/src/element.rs                                              |  336 
crates/editor/src/git/blame.rs                                            |  220 
crates/editor/src/hover_links.rs                                          |    2 
crates/editor/src/hover_popover.rs                                        |    2 
crates/editor/src/items.rs                                                |    6 
crates/editor/src/jsx_tag_auto_close.rs                                   |    2 
crates/editor/src/mouse_context_menu.rs                                   |   10 
crates/editor/src/scroll.rs                                               |    6 
crates/editor/src/scroll/autoscroll.rs                                    |   17 
crates/editor/src/test.rs                                                 |    8 
crates/editor/src/test/editor_lsp_test_context.rs                         |   88 
crates/editor/src/test/editor_test_context.rs                             |   10 
crates/eval/src/instance.rs                                               |    1 
crates/eval_utils/LICENSE-GPL                                             |    2 
crates/extension/src/extension_host_proxy.rs                              |   48 
crates/extension/src/extension_manifest.rs                                |   14 
crates/extension_api/src/extension_api.rs                                 |   13 
crates/extension_host/benches/extension_compilation_benchmark.rs          |    1 
crates/extension_host/src/capability_granter.rs                           |    1 
crates/extension_host/src/extension_store_test.rs                         |    3 
crates/extension_host/src/wasm_host.rs                                    |   10 
crates/extension_host/src/wasm_host/wit.rs                                |    2 
crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs                   |    6 
crates/file_finder/src/file_finder.rs                                     |    2 
crates/fs/src/fs.rs                                                       |   33 
crates/git/src/blame.rs                                                   |   11 
crates/git/src/commit.rs                                                  |   49 
crates/git/src/git.rs                                                     |    1 
crates/git_ui/Cargo.toml                                                  |    1 
crates/git_ui/src/blame_ui.rs                                             |    5 
crates/git_ui/src/branch_picker.rs                                        |  292 
crates/git_ui/src/clone.rs                                                |  155 
crates/git_ui/src/commit_modal.rs                                         |   17 
crates/git_ui/src/commit_tooltip.rs                                       |    2 
crates/git_ui/src/commit_view.rs                                          |   75 
crates/git_ui/src/file_history_view.rs                                    |    4 
crates/git_ui/src/git_panel.rs                                            |  674 
crates/git_ui/src/git_ui.rs                                               |    3 
crates/git_ui/src/onboarding.rs                                           |    4 
crates/git_ui/src/project_diff.rs                                         |   10 
crates/git_ui/src/worktree_picker.rs                                      |   42 
crates/go_to_line/src/go_to_line.rs                                       |    2 
crates/google_ai/src/google_ai.rs                                         |   24 
crates/gpui/Cargo.toml                                                    |    8 
crates/gpui/examples/focus_visible.rs                                     |   10 
crates/gpui/examples/input.rs                                             |   13 
crates/gpui/examples/on_window_close_quit.rs                              |    4 
crates/gpui/examples/popover.rs                                           |  174 
crates/gpui/examples/tab_stop.rs                                          |   54 
crates/gpui/examples/window.rs                                            |   65 
crates/gpui/src/app.rs                                                    |   68 
crates/gpui/src/app/async_context.rs                                      |    2 
crates/gpui/src/app/context.rs                                            |    4 
crates/gpui/src/app/test_context.rs                                       |    2 
crates/gpui/src/elements/div.rs                                           |  101 
crates/gpui/src/elements/surface.rs                                       |    1 
crates/gpui/src/elements/text.rs                                          |   42 
crates/gpui/src/elements/uniform_list.rs                                  |    6 
crates/gpui/src/executor.rs                                               |   10 
crates/gpui/src/gpui.rs                                                   |    4 
crates/gpui/src/interactive.rs                                            |    4 
crates/gpui/src/key_dispatch.rs                                           |  229 
crates/gpui/src/keymap.rs                                                 |   35 
crates/gpui/src/platform.rs                                               |   16 
crates/gpui/src/platform/linux/wayland/client.rs                          |   79 
crates/gpui/src/platform/linux/wayland/window.rs                          |  106 
crates/gpui/src/platform/linux/x11/client.rs                              |   84 
crates/gpui/src/platform/linux/x11/window.rs                              |   89 
crates/gpui/src/platform/mac.rs                                           |    3 
crates/gpui/src/platform/mac/attributed_string.rs                         |  129 
crates/gpui/src/platform/mac/open_type.rs                                 |    5 
crates/gpui/src/platform/mac/pasteboard.rs                                |  344 
crates/gpui/src/platform/mac/platform.rs                                  |  397 
crates/gpui/src/platform/mac/screen_capture.rs                            |   14 
crates/gpui/src/platform/mac/text_system.rs                               |   26 
crates/gpui/src/platform/mac/window.rs                                    |   52 
crates/gpui/src/platform/test/platform.rs                                 |   24 
crates/gpui/src/platform/windows/dispatcher.rs                            |   91 
crates/gpui/src/platform/windows/events.rs                                |   16 
crates/gpui/src/platform/windows/platform.rs                              |   26 
crates/gpui/src/platform/windows/window.rs                                |   32 
crates/gpui/src/queue.rs                                                  |   25 
crates/gpui/src/style.rs                                                  |   13 
crates/gpui/src/styled.rs                                                 |   17 
crates/gpui/src/taffy.rs                                                  |   15 
crates/gpui/src/test.rs                                                   |    5 
crates/gpui/src/text_system/line.rs                                       |   12 
crates/gpui/src/text_system/line_wrapper.rs                               |  271 
crates/gpui/src/window.rs                                                 |  117 
crates/gpui/src/window/prompts.rs                                         |    6 
crates/gpui_macros/src/derive_visual_context.rs                           |    2 
crates/image_viewer/src/image_viewer.rs                                   |    4 
crates/inspector_ui/src/inspector.rs                                      |    1 
crates/keymap_editor/src/keymap_editor.rs                                 |  123 
crates/keymap_editor/src/ui_components/keystroke_input.rs                 |    4 
crates/language/Cargo.toml                                                |    2 
crates/language/src/buffer.rs                                             |  136 
crates/language/src/buffer/row_chunk.rs                                   |   62 
crates/language/src/buffer_tests.rs                                       |   98 
crates/language/src/language.rs                                           |   73 
crates/language/src/language_settings.rs                                  |    6 
crates/language/src/syntax_map.rs                                         |   82 
crates/language/src/syntax_map/syntax_map_tests.rs                        |    4 
crates/language/src/text_diff.rs                                          |   61 
crates/language/src/toolchain.rs                                          |    7 
crates/language_model/src/language_model.rs                               |   21 
crates/language_model/src/registry.rs                                     |  195 
crates/language_model/src/request.rs                                      |  204 
crates/language_models/Cargo.toml                                         |    2 
crates/language_models/src/extension.rs                                   |   67 
crates/language_models/src/language_models.rs                             |   53 
crates/language_models/src/provider/anthropic.rs                          |  298 
crates/language_models/src/provider/bedrock.rs                            |  467 
crates/language_models/src/provider/cloud.rs                              |   16 
crates/language_models/src/provider/copilot_chat.rs                       |   16 
crates/language_models/src/provider/deepseek.rs                           |    6 
crates/language_models/src/provider/google.rs                             |    6 
crates/language_models/src/provider/lmstudio.rs                           |   10 
crates/language_models/src/provider/mistral.rs                            |    8 
crates/language_models/src/provider/ollama.rs                             |  161 
crates/language_models/src/provider/open_ai.rs                            |    6 
crates/language_models/src/provider/open_ai_compatible.rs                 |    6 
crates/language_models/src/provider/open_router.rs                        |   44 
crates/language_models/src/provider/vercel.rs                             |    6 
crates/language_models/src/provider/x_ai.rs                               |    6 
crates/language_models/src/settings.rs                                    |    1 
crates/language_tools/src/lsp_button.rs                                   |   32 
crates/language_tools/src/lsp_log_view.rs                                 |   25 
crates/language_tools/src/syntax_tree_view.rs                             |    2 
crates/languages/Cargo.toml                                               |    1 
crates/languages/src/c/textobjects.scm                                    |    6 
crates/languages/src/cpp/textobjects.scm                                  |    6 
crates/languages/src/css.rs                                               |   10 
crates/languages/src/javascript/injections.scm                            |   43 
crates/languages/src/javascript/textobjects.scm                           |   38 
crates/languages/src/json.rs                                              |   10 
crates/languages/src/markdown/config.toml                                 |    3 
crates/languages/src/python.rs                                            |   70 
crates/languages/src/rust.rs                                              |   88 
crates/languages/src/tailwind.rs                                          |   10 
crates/languages/src/tsx/injections.scm                                   |   43 
crates/languages/src/tsx/textobjects.scm                                  |   38 
crates/languages/src/typescript.rs                                        |   19 
crates/languages/src/typescript/injections.scm                            |   43 
crates/languages/src/typescript/textobjects.scm                           |   39 
crates/languages/src/vtsls.rs                                             |   76 
crates/languages/src/yaml.rs                                              |   11 
crates/languages/src/yaml/config.toml                                     |    2 
crates/lsp/src/lsp.rs                                                     |   12 
crates/markdown/src/markdown.rs                                           |  117 
crates/markdown_preview/src/markdown_preview_view.rs                      |    4 
crates/markdown_preview/src/markdown_renderer.rs                          |   12 
crates/migrator/src/migrations.rs                                         |    6 
crates/migrator/src/migrations/m_2025_12_15/settings.rs                   |   52 
crates/migrator/src/migrator.rs                                           |    8 
crates/mistral/src/mistral.rs                                             |   10 
crates/multi_buffer/src/multi_buffer.rs                                   |   13 
crates/multi_buffer/src/multi_buffer_tests.rs                             |   13 
crates/node_runtime/src/node_runtime.rs                                   |   65 
crates/onboarding/Cargo.toml                                              |    1 
crates/onboarding/src/basics_page.rs                                      |   48 
crates/onboarding/src/onboarding.rs                                       |   28 
crates/onboarding/src/welcome.rs                                          |  443 
crates/outline/src/outline.rs                                             |    2 
crates/outline_panel/src/outline_panel.rs                                 |   12 
crates/outline_panel/src/outline_panel_settings.rs                        |    8 
crates/picker/src/picker.rs                                               |    2 
crates/project/Cargo.toml                                                 |    2 
crates/project/src/agent_server_store.rs                                  |   22 
crates/project/src/buffer_store.rs                                        |   10 
crates/project/src/debugger/breakpoint_store.rs                           |    4 
crates/project/src/debugger/session.rs                                    |   23 
crates/project/src/git_store.rs                                           |  234 
crates/project/src/lsp_store.rs                                           |  171 
crates/project/src/lsp_store/inlay_hint_cache.rs                          |   11 
crates/project/src/persistence.rs                                         |   60 
crates/project/src/prettier_store.rs                                      |    2 
crates/project/src/project.rs                                             |  172 
crates/project/src/project_settings.rs                                    |  212 
crates/project/src/project_tests.rs                                       |  143 
crates/project/src/toolchain_store.rs                                     |   26 
crates/project/src/trusted_worktrees.rs                                   | 1378 
crates/project/src/x.py                                                   |    1 
crates/project_benchmarks/src/main.rs                                     |    1 
crates/project_panel/Cargo.toml                                           |    1 
crates/project_panel/src/project_panel.rs                                 |  133 
crates/project_panel/src/project_panel_settings.rs                        |    8 
crates/prompt_store/Cargo.toml                                            |    5 
crates/prompt_store/src/prompt_store.rs                                   |  357 
crates/prompt_store/src/prompts.rs                                        |   40 
crates/proto/proto/worktree.proto                                         |  180 
crates/proto/proto/zed.proto                                              |    5 
crates/proto/src/proto.rs                                                 |   10 
crates/recent_projects/src/remote_connections.rs                          |   35 
crates/recent_projects/src/remote_servers.rs                              |   33 
crates/remote/src/remote.rs                                               |    5 
crates/remote/src/remote_client.rs                                        |   61 
crates/remote/src/transport.rs                                            |   68 
crates/remote/src/transport/docker.rs                                     |   14 
crates/remote/src/transport/ssh.rs                                        |  367 
crates/remote/src/transport/wsl.rs                                        |    7 
crates/remote_server/Cargo.toml                                           |    2 
crates/remote_server/src/headless_project.rs                              |   59 
crates/remote_server/src/remote_editing_tests.rs                          |    3 
crates/remote_server/src/unix.rs                                          |    4 
crates/rules_library/src/rules_library.rs                                 |  449 
crates/schema_generator/Cargo.toml                                        |    1 
crates/schema_generator/src/main.rs                                       |    6 
crates/search/src/buffer_search.rs                                        |  120 
crates/search/src/project_search.rs                                       |   16 
crates/search/src/search.rs                                               |    2 
crates/search/src/search_bar.rs                                           |    2 
crates/settings/src/keymap_file.rs                                        |    8 
crates/settings/src/settings_content.rs                                   |   16 
crates/settings/src/settings_content/agent.rs                             |   13 
crates/settings/src/settings_content/language.rs                          |    8 
crates/settings/src/settings_content/language_model.rs                    |    3 
crates/settings/src/settings_content/project.rs                           |   30 
crates/settings/src/settings_content/workspace.rs                         |    9 
crates/settings/src/settings_store.rs                                     |   11 
crates/settings/src/vscode_import.rs                                      |    3 
crates/settings_ui/src/page_data.rs                                       |  186 
crates/settings_ui/src/settings_ui.rs                                     |  148 
crates/supermaven/src/supermaven_edit_prediction_delegate.rs              |   11 
crates/tab_switcher/src/tab_switcher.rs                                   |    4 
crates/terminal/src/terminal.rs                                           |  439 
crates/terminal/src/terminal_hyperlinks.rs                                |  318 
crates/terminal_view/src/terminal_element.rs                              |   23 
crates/terminal_view/src/terminal_panel.rs                                |  144 
crates/terminal_view/src/terminal_scrollbar.rs                            |   18 
crates/terminal_view/src/terminal_view.rs                                 |   30 
crates/title_bar/build.rs                                                 |   28 
crates/title_bar/src/application_menu.rs                                  |   20 
crates/title_bar/src/platforms/platform_mac.rs                            |   14 
crates/title_bar/src/title_bar.rs                                         |  340 
crates/toolchain_selector/src/active_toolchain.rs                         |    9 
crates/toolchain_selector/src/toolchain_selector.rs                       |   53 
crates/ui/src/components.rs                                               |    2 
crates/ui/src/components/callout.rs                                       |    2 
crates/ui/src/components/context_menu.rs                                  |   72 
crates/ui/src/components/divider.rs                                       |    2 
crates/ui/src/components/icon.rs                                          |   22 
crates/ui/src/components/inline_code.rs                                   |   64 
crates/ui/src/components/label/label.rs                                   |    9 
crates/ui/src/components/label/label_like.rs                              |   23 
crates/ui/src/components/navigable.rs                                     |    4 
crates/ui/src/components/notification/alert_modal.rs                      |  231 
crates/ui/src/components/popover_menu.rs                                  |   16 
crates/ui/src/components/right_click_menu.rs                              |   16 
crates/ui_input/src/number_field.rs                                       |   70 
crates/util/src/redact.rs                                                 |   34 
crates/vim/src/command.rs                                                 |  206 
crates/vim/src/motion.rs                                                  |    8 
crates/vim/src/object.rs                                                  |  400 
crates/vim/src/vim.rs                                                     |    1 
crates/vim/src/visual.rs                                                  |   16 
crates/which_key/Cargo.toml                                               |   23 
crates/which_key/LICENSE-GPL                                              |    1 
crates/which_key/src/which_key.rs                                         |   98 
crates/which_key/src/which_key_modal.rs                                   |  308 
crates/which_key/src/which_key_settings.rs                                |   18 
crates/workspace/Cargo.toml                                               |    2 
crates/workspace/src/dock.rs                                              |   22 
crates/workspace/src/item.rs                                              |   24 
crates/workspace/src/modal_layer.rs                                       |  108 
crates/workspace/src/notifications.rs                                     |   77 
crates/workspace/src/pane.rs                                              |  138 
crates/workspace/src/persistence.rs                                       |  440 
crates/workspace/src/security_modal.rs                                    |  334 
crates/workspace/src/shared_screen.rs                                     |    5 
crates/workspace/src/welcome.rs                                           |  568 
crates/workspace/src/workspace.rs                                         |  512 
crates/worktree/Cargo.toml                                                |    2 
crates/worktree/src/ignore.rs                                             |   37 
crates/worktree/src/worktree.rs                                           |  322 
crates/worktree/src/worktree_tests.rs                                     |  385 
crates/worktree_benchmarks/src/main.rs                                    |    4 
crates/zed/Cargo.toml                                                     |   13 
crates/zed/resources/Document.icns                                        |    0 
crates/zed/src/main.rs                                                    |  114 
crates/zed/src/zed-main.rs                                                |    8 
crates/zed/src/zed.rs                                                     |   67 
crates/zed/src/zed/edit_prediction_registry.rs                            |   17 
crates/zed/src/zed/open_listener.rs                                       |  483 
crates/zed_actions/src/lib.rs                                             |    4 
crates/ztracing/src/lib.rs                                                |   22 
docs/.rules                                                               |  158 
docs/AGENTS.md                                                            |  353 
docs/src/SUMMARY.md                                                       |   13 
docs/src/ai/billing.md                                                    |    4 
docs/src/ai/llm-providers.md                                              |   27 
docs/src/ai/plans-and-usage.md                                            |    4 
docs/src/ai/privacy-and-security.md                                       |    4 
docs/src/ai/rules.md                                                      |    2 
docs/src/completions.md                                                   |    4 
docs/src/configuring-zed.md                                               |   71 
docs/src/dev-containers.md                                                |   50 
docs/src/development.md                                                   |    4 
docs/src/development/glossary.md                                          |    2 
docs/src/development/linux.md                                             |    4 
docs/src/development/local-collaboration.md                               |  207 
docs/src/development/macos.md                                             |    4 
docs/src/development/windows.md                                           |    4 
docs/src/git.md                                                           |    1 
docs/src/installation.md                                                  |    6 
docs/src/languages/javascript.md                                          |   30 
docs/src/languages/markdown.md                                            |   34 
docs/src/languages/ruby.md                                                |   11 
docs/src/languages/tailwindcss.md                                         |   30 
docs/src/languages/typescript.md                                          |   30 
docs/src/migrate/_research-notes.md                                       |   73 
docs/src/migrate/intellij.md                                              |  357 
docs/src/migrate/pycharm.md                                               |  438 
docs/src/migrate/rustrover.md                                             |  501 
docs/src/migrate/webstorm.md                                              |  455 
docs/src/windows.md                                                       |    8 
docs/src/worktree-trust.md                                                |   58 
flake.lock                                                                |   30 
flake.nix                                                                 |    4 
nix/build.nix                                                             |  169 
rust-toolchain.toml                                                       |    2 
script/bundle-mac                                                         |   11 
script/danger/dangerfile.ts                                               |    3 
script/danger/package.json                                                |    2 
script/danger/pnpm-lock.yaml                                              |   10 
script/generate-action-metadata                                           |   10 
script/prettier                                                           |   10 
script/triage_watcher.jl                                                  |   38 
script/verify-macos-document-icon                                         |   81 
tooling/xtask/src/tasks/workflows.rs                                      |    2 
tooling/xtask/src/tasks/workflows/autofix_pr.rs                           |  162 
tooling/xtask/src/tasks/workflows/cherry_pick.rs                          |   17 
tooling/xtask/src/tasks/workflows/extension_bump.rs                       |    5 
tooling/xtask/src/tasks/workflows/extensions.rs                           |    0 
tooling/xtask/src/tasks/workflows/release.rs                              |    5 
tooling/xtask/src/tasks/workflows/run_tests.rs                            |    7 
tooling/xtask/src/tasks/workflows/steps.rs                                |   29 
483 files changed, 27,853 insertions(+), 7,452 deletions(-)

Detailed changes

.factory/prompts/docs-automation/phase2-explore.md 🔗

@@ -0,0 +1,55 @@
+# Phase 2: Explore Repository
+
+You are analyzing a codebase to understand its structure before reviewing documentation impact.
+
+## Objective
+Produce a structured overview of the repository to inform subsequent documentation analysis.
+
+## Instructions
+
+1. **Identify Primary Languages and Frameworks**
+   - Scan for Cargo.toml, package.json, or other manifest files
+   - Note the primary language(s) and key dependencies
+
+2. **Map Documentation Structure**
+   - This project uses **mdBook** (https://rust-lang.github.io/mdBook/)
+   - Documentation is in `docs/src/`
+   - Table of contents: `docs/src/SUMMARY.md` (mdBook format: https://rust-lang.github.io/mdBook/format/summary.html)
+   - Style guide: `docs/.rules`
+   - Agent guidelines: `docs/AGENTS.md`
+   - Formatting: Prettier (config in `docs/.prettierrc`)
+
+3. **Identify Build and Tooling**
+   - Note build systems (cargo, npm, etc.)
+   - Identify documentation tooling (mdbook, etc.)
+
+4. **Output Format**
+Produce a JSON summary:
+
+```json
+{
+  "primary_language": "Rust",
+  "frameworks": ["GPUI"],
+  "documentation": {
+    "system": "mdBook",
+    "location": "docs/src/",
+    "toc_file": "docs/src/SUMMARY.md",
+    "toc_format": "https://rust-lang.github.io/mdBook/format/summary.html",
+    "style_guide": "docs/.rules",
+    "agent_guidelines": "docs/AGENTS.md",
+    "formatter": "prettier",
+    "formatter_config": "docs/.prettierrc",
+    "custom_preprocessor": "docs_preprocessor (handles {#kb action::Name} syntax)"
+  },
+  "key_directories": {
+    "source": "crates/",
+    "docs": "docs/src/",
+    "extensions": "extensions/"
+  }
+}
+```
+
+## Constraints
+- Read-only: Do not modify any files
+- Focus on structure, not content details
+- Complete within 2 minutes

.factory/prompts/docs-automation/phase3-analyze.md 🔗

@@ -0,0 +1,57 @@
+# Phase 3: Analyze Changes
+
+You are analyzing code changes to understand their nature and scope.
+
+## Objective
+Produce a clear, neutral summary of what changed in the codebase.
+
+## Input
+You will receive:
+- List of changed files from the triggering commit/PR
+- Repository structure from Phase 2
+
+## Instructions
+
+1. **Categorize Changed Files**
+   - Source code (which crates/modules)
+   - Configuration
+   - Tests
+   - Documentation (already existing)
+   - Other
+
+2. **Analyze Each Change**
+   - Review diffs for files likely to impact documentation
+   - Focus on: public APIs, settings, keybindings, commands, user-visible behavior
+
+3. **Identify What Did NOT Change**
+   - Note stable interfaces or behaviors
+   - Important for avoiding unnecessary documentation updates
+
+4. **Output Format**
+Produce a markdown summary:
+
+```markdown
+## Change Analysis
+
+### Changed Files Summary
+| Category | Files | Impact Level |
+| --- | --- | --- |
+| Source - [crate] | file1.rs, file2.rs | High/Medium/Low |
+| Settings | settings.json | Medium |
+| Tests | test_*.rs | None |
+
+### Behavioral Changes
+- **[Feature/Area]**: Description of what changed from user perspective
+- **[Feature/Area]**: Description...
+
+### Unchanged Areas
+- [Area]: Confirmed no changes to [specific behavior]
+
+### Files Requiring Deeper Review
+- `path/to/file.rs`: Reason for deeper review
+```
+
+## Constraints
+- Read-only: Do not modify any files
+- Neutral tone: Describe what changed, not whether it's good/bad
+- Do not propose documentation changes yet

.factory/prompts/docs-automation/phase4-plan.md 🔗

@@ -0,0 +1,76 @@
+# Phase 4: Plan Documentation Impact
+
+You are determining whether and how documentation should be updated based on code changes.
+
+## Objective
+Produce a structured documentation plan that will guide Phase 5 execution.
+
+## Documentation System
+This is an **mdBook** site (https://rust-lang.github.io/mdBook/):
+- `docs/src/SUMMARY.md` defines book structure per https://rust-lang.github.io/mdBook/format/summary.html
+- If adding new pages, they MUST be added to SUMMARY.md
+- Use `{#kb action::ActionName}` syntax for keybindings (custom preprocessor expands these)
+- Prettier formatting (80 char width) will be applied automatically
+
+## Input
+You will receive:
+- Change analysis from Phase 3
+- Repository structure from Phase 2
+- Documentation guidelines from `docs/AGENTS.md`
+
+## Instructions
+
+1. **Review AGENTS.md**
+   - Load and apply all rules from `docs/AGENTS.md`
+   - Respect scope boundaries (in-scope vs out-of-scope)
+
+2. **Evaluate Documentation Impact**
+   For each behavioral change from Phase 3:
+   - Does existing documentation cover this area?
+   - Is the documentation now inaccurate or incomplete?
+   - Classify per AGENTS.md "Change Classification" section
+
+3. **Identify Specific Updates**
+   For each required update:
+   - Exact file path
+   - Specific section or heading
+   - Type of change (update existing, add new, deprecate)
+   - Description of the change
+
+4. **Flag Uncertainty**
+   Explicitly mark:
+   - Assumptions you're making
+   - Areas where human confirmation is needed
+   - Ambiguous requirements
+
+5. **Output Format**
+Use the exact format specified in `docs/AGENTS.md` Phase 4 section:
+
+```markdown
+## Documentation Impact Assessment
+
+### Summary
+Brief description of code changes analyzed.
+
+### Documentation Updates Required: [Yes/No]
+
+### Planned Changes
+
+#### 1. [File Path]
+- **Section**: [Section name or "New section"]
+- **Change Type**: [Update/Add/Deprecate]
+- **Reason**: Why this change is needed
+- **Description**: What will be added/modified
+
+### Uncertainty Flags
+- [ ] [Description of any assumptions or areas needing confirmation]
+
+### No Changes Needed
+- [List files reviewed but not requiring updates, with brief reason]
+```
+
+## Constraints
+- Read-only: Do not modify any files
+- Conservative: When uncertain, flag for human review rather than planning changes
+- Scoped: Only plan changes that trace directly to code changes from Phase 3
+- No scope expansion: Do not plan "improvements" unrelated to triggering changes

.factory/prompts/docs-automation/phase5-apply.md 🔗

@@ -0,0 +1,67 @@
+# Phase 5: Apply Documentation Plan
+
+You are executing a pre-approved documentation plan for an **mdBook** documentation site.
+
+## Objective
+Implement exactly the changes specified in the documentation plan from Phase 4.
+
+## Documentation System
+- **mdBook**: https://rust-lang.github.io/mdBook/
+- **SUMMARY.md**: Follows mdBook format (https://rust-lang.github.io/mdBook/format/summary.html)
+- **Prettier**: Will be run automatically after this phase (80 char line width)
+- **Custom preprocessor**: Use `{#kb action::ActionName}` for keybindings instead of hardcoding
+
+## Input
+You will receive:
+- Documentation plan from Phase 4
+- Documentation guidelines from `docs/AGENTS.md`
+- Style rules from `docs/.rules`
+
+## Instructions
+
+1. **Validate Plan**
+   - Confirm all planned files are within scope per AGENTS.md
+   - Verify no out-of-scope files are targeted
+
+2. **Execute Each Planned Change**
+   For each item in "Planned Changes":
+   - Navigate to the specified file
+   - Locate the specified section
+   - Apply the described change
+   - Follow style rules from `docs/.rules`
+
+3. **Style Compliance**
+   Every edit must follow `docs/.rules`:
+   - Second person, present tense
+   - No hedging words ("simply", "just", "easily")
+   - Proper keybinding format (`Cmd+Shift+P`)
+   - Settings Editor first, JSON second
+   - Correct terminology (folder not directory, etc.)
+
+4. **Preserve Context**
+   - Maintain surrounding content structure
+   - Keep consistent heading levels
+   - Preserve existing cross-references
+
+## Constraints
+- Execute ONLY changes listed in the plan
+- Do not discover new documentation targets
+- Do not make stylistic improvements outside planned sections
+- Do not expand scope beyond what Phase 4 specified
+- If a planned change cannot be applied (file missing, section not found), skip and note it
+
+## Output
+After applying changes, output a summary:
+
+```markdown
+## Applied Changes
+
+### Successfully Applied
+- `path/to/file.md`: [Brief description of change]
+
+### Skipped (Could Not Apply)
+- `path/to/file.md`: [Reason - e.g., "Section not found"]
+
+### Warnings
+- [Any issues encountered during application]
+```

.factory/prompts/docs-automation/phase6-summarize.md 🔗

@@ -0,0 +1,54 @@
+# Phase 6: Summarize Changes
+
+You are generating a summary of documentation updates for PR review.
+
+## Objective
+Create a clear, reviewable summary of all documentation changes made.
+
+## Input
+You will receive:
+- Applied changes report from Phase 5
+- Original change analysis from Phase 3
+- Git diff of documentation changes
+
+## Instructions
+
+1. **Gather Change Information**
+   - List all modified documentation files
+   - Identify the corresponding code changes that triggered each update
+
+2. **Generate Summary**
+   Use the format specified in `docs/AGENTS.md` Phase 6 section:
+
+```markdown
+## Documentation Update Summary
+
+### Changes Made
+| File | Change | Related Code |
+| --- | --- | --- |
+| docs/src/path.md | Brief description | PR #123 or commit SHA |
+
+### Rationale
+Brief explanation of why these updates were made, linking back to the triggering code changes.
+
+### Review Notes
+- Items reviewers should pay special attention to
+- Any uncertainty flags from Phase 4 that were addressed
+- Assumptions made during documentation
+```
+
+3. **Add Context for Reviewers**
+   - Highlight any changes that might be controversial
+   - Note if any planned changes were skipped and why
+   - Flag areas where reviewer expertise is especially needed
+
+## Output Format
+The summary should be suitable for:
+- PR description body
+- Commit message (condensed version)
+- Team communication
+
+## Constraints
+- Read-only (documentation changes already applied in Phase 5)
+- Factual: Describe what was done, not justify why it's good
+- Complete: Account for all changes, including skipped items

.factory/prompts/docs-automation/phase7-commit.md 🔗

@@ -0,0 +1,67 @@
+# Phase 7: Commit and Open PR
+
+You are creating a git branch, committing documentation changes, and opening a PR.
+
+## Objective
+Package documentation updates into a reviewable pull request.
+
+## Input
+You will receive:
+- Summary from Phase 6
+- List of modified files
+
+## Instructions
+
+1. **Create Branch**
+   ```sh
+   git checkout -b docs/auto-update-{date}
+   ```
+   Use format: `docs/auto-update-YYYY-MM-DD` or `docs/auto-update-{short-sha}`
+
+2. **Stage and Commit**
+   - Stage only documentation files in `docs/src/`
+   - Do not stage any other files
+   
+   Commit message format:
+   ```
+   docs: auto-update documentation for [brief description]
+   
+   [Summary from Phase 6, condensed]
+   
+   Triggered by: [commit SHA or PR reference]
+   
+   Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
+   ```
+
+3. **Push Branch**
+   ```sh
+   git push -u origin docs/auto-update-{date}
+   ```
+
+4. **Create Pull Request**
+   Use the Phase 6 summary as the PR body.
+   
+   PR Title: `docs: [Brief description of documentation updates]`
+   
+   Labels (if available): `documentation`, `automated`
+   
+   Base branch: `main`
+
+## Constraints
+- Do NOT auto-merge
+- Do NOT request specific reviewers (let CODEOWNERS handle it)
+- Do NOT modify files outside `docs/src/`
+- If no changes to commit, exit gracefully with message "No documentation changes to commit"
+
+## Output
+```markdown
+## PR Created
+
+- **Branch**: docs/auto-update-{date}
+- **PR URL**: https://github.com/zed-industries/zed/pull/XXXX
+- **Status**: Ready for review
+
+### Commit
+- SHA: {commit-sha}
+- Files: {count} documentation files modified
+```

.github/actions/build_docs/action.yml 🔗

@@ -19,6 +19,18 @@ runs:
       shell: bash -euxo pipefail {0}
       run: ./script/linux
 
+    - name: Install mold linker
+      shell: bash -euxo pipefail {0}
+      run: ./script/install-mold
+
+    - name: Download WASI SDK
+      shell: bash -euxo pipefail {0}
+      run: ./script/download-wasi-sdk
+
+    - name: Generate action metadata
+      shell: bash -euxo pipefail {0}
+      run: ./script/generate-action-metadata
+
     - name: Check for broken links (in MD)
       uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
       with:

.github/workflows/autofix_pr.yml 🔗

@@ -0,0 +1,132 @@
+# 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
+      run_clippy:
+        description: run_clippy
+        type: boolean
+        default: 'true'
+jobs:
+  run_autofix:
+    runs-on: namespace-profile-16x32-ubuntu-2204
+    steps:
+    - name: steps::checkout_repo
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+    - name: autofix_pr::run_autofix::checkout_pr
+      run: gh pr checkout ${{ inputs.pr_number }}
+      shell: bash -euxo pipefail {0}
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_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_cargo_fix
+      if: ${{ inputs.run_clippy }}
+      run: cargo fix --workspace --release --all-targets --all-features --allow-dirty --allow-staged
+      shell: bash -euxo pipefail {0}
+    - name: autofix_pr::run_autofix::run_clippy_fix
+      if: ${{ inputs.run_clippy }}
+      run: cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged
+      shell: bash -euxo pipefail {0}
+    - id: create-patch
+      name: autofix_pr::run_autofix::create_patch
+      run: |
+        if git diff --quiet; then
+            echo "No changes to commit"
+            echo "has_changes=false" >> "$GITHUB_OUTPUT"
+        else
+            git diff > autofix.patch
+            echo "has_changes=true" >> "$GITHUB_OUTPUT"
+        fi
+      shell: bash -euxo pipefail {0}
+    - name: upload artifact autofix-patch
+      uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
+      with:
+        name: autofix-patch
+        path: autofix.patch
+        if-no-files-found: ignore
+        retention-days: '1'
+    - name: steps::cleanup_cargo_config
+      if: always()
+      run: |
+        rm -rf ./../.cargo
+      shell: bash -euxo pipefail {0}
+    outputs:
+      has_changes: ${{ steps.create-patch.outputs.has_changes }}
+  commit_changes:
+    needs:
+    - run_autofix
+    if: needs.run_autofix.outputs.has_changes == 'true'
+    runs-on: namespace-profile-2x4-ubuntu-2404
+    steps:
+    - id: get-app-token
+      name: steps::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::commit_changes::checkout_pr
+      run: gh pr checkout ${{ inputs.pr_number }}
+      shell: bash -euxo pipefail {0}
+      env:
+        GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
+    - name: autofix_pr::download_patch_artifact
+      uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
+      with:
+        name: autofix-patch
+    - name: autofix_pr::commit_changes::apply_patch
+      run: git apply autofix.patch
+      shell: bash -euxo pipefail {0}
+    - name: autofix_pr::commit_changes::commit_and_push
+      run: |
+        git commit -am "Autofix"
+        git push
+      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 }}
+concurrency:
+  group: ${{ github.workflow }}-${{ inputs.pr_number }}
+  cancel-in-progress: true

.github/workflows/cherry_pick.yml 🔗

@@ -30,7 +30,7 @@ jobs:
       with:
         clean: false
     - id: get-app-token
-      name: cherry_pick::run_cherry_pick::authenticate_as_zippy
+      name: steps::authenticate_as_zippy
       uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
       with:
         app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}

.github/workflows/community_close_stale_issues.yml 🔗

@@ -1,29 +1,40 @@
 name: "Close Stale Issues"
 on:
   schedule:
-    - cron: "0 8 31 DEC *"
+    - cron: "0 2 * * 5"
   workflow_dispatch:
+    inputs:
+      debug-only:
+        description: "Run in dry-run mode (no changes made)"
+        type: boolean
+        default: false
+      operations-per-run:
+        description: "Max number of issues to process (default: 1000)"
+        type: number
+        default: 1000
 
 jobs:
   stale:
     if: github.repository_owner == 'zed-industries'
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
+      - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
         with:
           repo-token: ${{ secrets.GITHUB_TOKEN }}
           stale-issue-message: >
-            Hi there! 👋
-
-            We're working to clean up our issue tracker by closing older bugs that might not be relevant anymore. If you are able to reproduce this issue in the latest version of Zed, please let us know by commenting on this issue, and it will be kept open. If you can't reproduce it, feel free to close the issue yourself. Otherwise, it will close automatically in 14 days.
+            Hi there!
+            Zed development moves fast and a significant number of bugs become outdated.
+            If you can reproduce this bug on the latest stable Zed, please let us know by leaving a comment with the Zed version.
+            If the bug doesn't appear for you anymore, feel free to close the issue yourself; otherwise, the bot will close it in a couple of weeks.
 
             Thanks for your help!
-          close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please open a new issue with a link to this issue."
+          close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please leave a comment with your Zed version so that we can reopen the issue."
           days-before-stale: 60
           days-before-close: 14
           only-issue-types: "Bug,Crash"
-          operations-per-run: 1000
+          operations-per-run: ${{ inputs.operations-per-run || 1000 }}
           ascending: true
           enable-statistics: true
+          debug-only: ${{ inputs.debug-only }}
           stale-issue-label: "stale"
           exempt-issue-labels: "never stale"

.github/workflows/docs_automation.yml 🔗

@@ -0,0 +1,264 @@
+name: Documentation Automation
+
+on:
+  # push:
+  #   branches: [main]
+  #   paths:
+  #     - 'crates/**'
+  #     - 'extensions/**'
+  workflow_dispatch:
+    inputs:
+      pr_number:
+        description: 'PR number to analyze (gets full PR diff)'
+        required: false
+        type: string
+      trigger_sha:
+        description: 'Commit SHA to analyze (ignored if pr_number is set)'
+        required: false
+        type: string
+
+permissions:
+  contents: write
+  pull-requests: write
+
+env:
+  FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
+  DROID_MODEL: claude-opus-4-5-20251101
+
+jobs:
+  docs-automation:
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Install Droid CLI
+        id: install-droid
+        run: |
+          curl -fsSL https://app.factory.ai/cli | sh
+          echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
+          echo "DROID_BIN=${HOME}/.local/bin/droid" >> "$GITHUB_ENV"
+          # Verify installation
+          "${HOME}/.local/bin/droid" --version
+
+      - name: Setup Node.js (for Prettier)
+        uses: actions/setup-node@v4
+        with:
+          node-version: '20'
+
+      - name: Install Prettier
+        run: npm install -g prettier
+
+      - name: Get changed files
+        id: changed
+        run: |
+          if [ -n "${{ inputs.pr_number }}" ]; then
+            # Get full PR diff
+            echo "Analyzing PR #${{ inputs.pr_number }}"
+            echo "source=pr" >> "$GITHUB_OUTPUT"
+            echo "ref=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
+            gh pr diff "${{ inputs.pr_number }}" --name-only > /tmp/changed_files.txt
+          elif [ -n "${{ inputs.trigger_sha }}" ]; then
+            # Get single commit diff
+            SHA="${{ inputs.trigger_sha }}"
+            echo "Analyzing commit $SHA"
+            echo "source=commit" >> "$GITHUB_OUTPUT"
+            echo "ref=$SHA" >> "$GITHUB_OUTPUT"
+            git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt
+          else
+            # Default to current commit
+            SHA="${{ github.sha }}"
+            echo "Analyzing commit $SHA"
+            echo "source=commit" >> "$GITHUB_OUTPUT"
+            echo "ref=$SHA" >> "$GITHUB_OUTPUT"
+            git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt || git diff --name-only HEAD~1 HEAD > /tmp/changed_files.txt
+          fi
+
+          echo "Changed files:"
+          cat /tmp/changed_files.txt
+        env:
+          GH_TOKEN: ${{ github.token }}
+
+      # Phase 0: Guardrails are loaded via AGENTS.md in each phase
+
+      # Phase 2: Explore Repository (Read-Only - default)
+      - name: "Phase 2: Explore Repository"
+        id: phase2
+        run: |
+          "$DROID_BIN" exec \
+            -m "$DROID_MODEL" \
+            -f .factory/prompts/docs-automation/phase2-explore.md \
+            > /tmp/phase2-output.txt 2>&1 || true
+          echo "Repository exploration complete"
+          cat /tmp/phase2-output.txt
+
+      # Phase 3: Analyze Changes (Read-Only - default)
+      - name: "Phase 3: Analyze Changes"
+        id: phase3
+        run: |
+          CHANGED_FILES=$(tr '\n' ' ' < /tmp/changed_files.txt)
+          echo "Analyzing changes in: $CHANGED_FILES"
+
+          # Build prompt with context
+          cat > /tmp/phase3-prompt.md << 'EOF'
+          $(cat .factory/prompts/docs-automation/phase3-analyze.md)
+
+          ## Context
+
+          ### Changed Files
+          $CHANGED_FILES
+
+          ### Phase 2 Output
+          $(cat /tmp/phase2-output.txt)
+          EOF
+
+          "$DROID_BIN" exec \
+            -m "$DROID_MODEL" \
+            "$(cat .factory/prompts/docs-automation/phase3-analyze.md)
+
+            Changed files: $CHANGED_FILES" \
+            > /tmp/phase3-output.md 2>&1 || true
+          echo "Change analysis complete"
+          cat /tmp/phase3-output.md
+
+      # Phase 4: Plan Documentation Impact (Read-Only - default)
+      - name: "Phase 4: Plan Documentation Impact"
+        id: phase4
+        run: |
+          "$DROID_BIN" exec \
+            -m "$DROID_MODEL" \
+            -f .factory/prompts/docs-automation/phase4-plan.md \
+            > /tmp/phase4-plan.md 2>&1 || true
+          echo "Documentation plan complete"
+          cat /tmp/phase4-plan.md
+
+          # Check if updates are required
+          if grep -q "NO_UPDATES_REQUIRED" /tmp/phase4-plan.md; then
+            echo "updates_required=false" >> "$GITHUB_OUTPUT"
+          else
+            echo "updates_required=true" >> "$GITHUB_OUTPUT"
+          fi
+
+      # Phase 5: Apply Plan (Write-Enabled with --auto medium)
+      - name: "Phase 5: Apply Documentation Plan"
+        id: phase5
+        if: steps.phase4.outputs.updates_required == 'true'
+        run: |
+          "$DROID_BIN" exec \
+            -m "$DROID_MODEL" \
+            --auto medium \
+            -f .factory/prompts/docs-automation/phase5-apply.md \
+            > /tmp/phase5-report.md 2>&1 || true
+          echo "Documentation updates applied"
+          cat /tmp/phase5-report.md
+
+      # Phase 5b: Format with Prettier
+      - name: "Phase 5b: Format with Prettier"
+        id: phase5b
+        if: steps.phase4.outputs.updates_required == 'true'
+        run: |
+          echo "Formatting documentation with Prettier..."
+          cd docs && prettier --write src/
+
+          echo "Verifying Prettier formatting passes..."
+          cd docs && prettier --check src/
+
+          echo "Prettier formatting complete"
+
+      # Phase 6: Summarize Changes (Read-Only - default)
+      - name: "Phase 6: Summarize Changes"
+        id: phase6
+        if: steps.phase4.outputs.updates_required == 'true'
+        run: |
+          # Get git diff of docs
+          git diff docs/src/ > /tmp/docs-diff.txt || true
+
+          "$DROID_BIN" exec \
+            -m "$DROID_MODEL" \
+            -f .factory/prompts/docs-automation/phase6-summarize.md \
+            > /tmp/phase6-summary.md 2>&1 || true
+          echo "Summary generated"
+          cat /tmp/phase6-summary.md
+
+      # Phase 7: Commit and Open PR
+      - name: "Phase 7: Create PR"
+        id: phase7
+        if: steps.phase4.outputs.updates_required == 'true'
+        run: |
+          # Check if there are actual changes
+          if git diff --quiet docs/src/; then
+            echo "No documentation changes detected"
+            exit 0
+          fi
+
+          # Configure git
+          git config user.name "factory-droid[bot]"
+          git config user.email "138933559+factory-droid[bot]@users.noreply.github.com"
+
+          # Daily batch branch - one branch per day, multiple commits accumulate
+          BRANCH_NAME="docs/auto-update-$(date +%Y-%m-%d)"
+
+          # Stash local changes from phase 5
+          git stash push -m "docs-automation-changes" -- docs/src/
+
+          # Check if branch already exists on remote
+          if git ls-remote --exit-code --heads origin "$BRANCH_NAME" > /dev/null 2>&1; then
+            echo "Branch $BRANCH_NAME exists, checking out and updating..."
+            git fetch origin "$BRANCH_NAME"
+            git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME"
+          else
+            echo "Creating new branch $BRANCH_NAME..."
+            git checkout -b "$BRANCH_NAME"
+          fi
+
+          # Apply stashed changes
+          git stash pop || true
+
+          # Stage and commit
+          git add docs/src/
+          SUMMARY=$(head -50 < /tmp/phase6-summary.md)
+          git commit -m "docs: auto-update documentation
+
+          ${SUMMARY}
+
+          Triggered by: ${{ steps.changed.outputs.source }} ${{ steps.changed.outputs.ref }}
+
+          Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>"
+
+          # Push
+          git push -u origin "$BRANCH_NAME"
+
+          # Check if PR already exists for this branch
+          EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number' || echo "")
+
+          if [ -n "$EXISTING_PR" ]; then
+            echo "PR #$EXISTING_PR already exists for branch $BRANCH_NAME, updated with new commit"
+          else
+            # Create new PR
+            gh pr create \
+              --title "docs: automated documentation update ($(date +%Y-%m-%d))" \
+              --body-file /tmp/phase6-summary.md \
+              --base main || true
+            echo "PR created on branch: $BRANCH_NAME"
+          fi
+        env:
+          GH_TOKEN: ${{ github.token }}
+
+      # Summary output
+      - name: "Summary"
+        if: always()
+        run: |
+          echo "## Documentation Automation Summary" >> "$GITHUB_STEP_SUMMARY"
+          echo "" >> "$GITHUB_STEP_SUMMARY"
+
+          if [ "${{ steps.phase4.outputs.updates_required }}" == "false" ]; then
+            echo "No documentation updates required for this change." >> "$GITHUB_STEP_SUMMARY"
+          elif [ -f /tmp/phase6-summary.md ]; then
+            cat /tmp/phase6-summary.md >> "$GITHUB_STEP_SUMMARY"
+          else
+            echo "Workflow completed. Check individual phase outputs for details." >> "$GITHUB_STEP_SUMMARY"
+          fi

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

.github/workflows/release.yml 🔗

@@ -472,11 +472,17 @@ jobs:
     if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
+    - id: get-app-token
+      name: steps::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: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
       run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
       shell: bash -euxo pipefail {0}
       env:
-        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
   notify_on_failure:
     needs:
     - upload_release_assets

.github/workflows/run_tests.yml 🔗

@@ -74,9 +74,12 @@ jobs:
       uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
       with:
         version: '9'
-    - name: ./script/prettier
+    - name: steps::prettier
       run: ./script/prettier
       shell: bash -euxo pipefail {0}
+    - name: steps::cargo_fmt
+      run: cargo fmt --all -- --check
+      shell: bash -euxo pipefail {0}
     - name: ./script/check-todos
       run: ./script/check-todos
       shell: bash -euxo pipefail {0}
@@ -87,9 +90,6 @@ jobs:
       uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06
       with:
         config: ./typos.toml
-    - name: steps::cargo_fmt
-      run: cargo fmt --all -- --check
-      shell: bash -euxo pipefail {0}
     timeout-minutes: 60
   run_tests_windows:
     needs:
@@ -353,6 +353,9 @@ jobs:
     - name: steps::download_wasi_sdk
       run: ./script/download-wasi-sdk
       shell: bash -euxo pipefail {0}
+    - name: ./script/generate-action-metadata
+      run: ./script/generate-action-metadata
+      shell: bash -euxo pipefail {0}
     - name: run_tests::check_docs::install_mdbook
       uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08
       with:

.gitignore 🔗

@@ -8,6 +8,7 @@
 .DS_Store
 .blob_store
 .build
+.claude/settings.local.json
 .envrc
 .flatpak-builder
 .idea
@@ -35,10 +36,11 @@
 DerivedData/
 Packages
 xcuserdata/
+crates/docs_preprocessor/actions.json
 
 # Don't commit any secrets to the repo.
 .env
 .env.secret.toml
 
 # `nix build` output
-/result 
+/result

.mailmap 🔗

@@ -141,6 +141,9 @@ Uladzislau Kaminski <i@uladkaminski.com>
 Uladzislau Kaminski <i@uladkaminski.com> <uladzislau_kaminski@epam.com>
 Vitaly Slobodin <vitaliy.slobodin@gmail.com>
 Vitaly Slobodin <vitaliy.slobodin@gmail.com> <vitaly_slobodin@fastmail.com>
+Yara <davidsk@zed.dev>
+Yara <git@davidsk.dev>
+Yara <git@yara.blue>
 Will Bradley <williambbradley@gmail.com>
 Will Bradley <williambbradley@gmail.com> <will@zed.dev>
 WindSoilder <WindSoilder@outlook.com>

CONTRIBUTING.md 🔗

@@ -15,15 +15,17 @@ with the community to improve the product in ways we haven't thought of (or had
 
 In particular we love PRs that are:
 
-- Fixes to existing bugs and issues.
-- Small enhancements to existing features, particularly to make them work for more people.
+- Fixing or extending the docs.
+- Fixing bugs.
+- Small enhancements to existing features to make them work for more people (making things work on more platforms/modes/whatever).
 - Small extra features, like keybindings or actions you miss from other editors or extensions.
-- Work towards shipping larger features on our roadmap.
+- Part of a Community Program like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541).
 
 If you're looking for concrete ideas:
 
-- Our [top-ranking issues](https://github.com/zed-industries/zed/issues/5393) based on votes by the community.
-- Our [public roadmap](https://zed.dev/roadmap) contains a rough outline of our near-term priorities for Zed.
+- [Curated board of issues](https://github.com/orgs/zed-industries/projects/69) suitable for everyone from first-time contributors to seasoned community champions.
+- [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible).
+- [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search).
 
 ## Sending changes
 
@@ -37,9 +39,17 @@ like, sorry).
 Although we will take a look, we tend to only merge about half the PRs that are
 submitted. If you'd like your PR to have the best chance of being merged:
 
-- Include a clear description of what you're solving, and why it's important to you.
-- Include tests.
-- If it changes the UI, attach screenshots or screen recordings.
+- Make sure the change is **desired**: we're always happy to accept bugfixes,
+  but features should be confirmed with us first if you aim to avoid wasted
+  effort. If there isn't already a GitHub issue for your feature with staff
+  confirmation that we want it, start with a GitHub discussion rather than a PR.
+- Include a clear description of **what you're solving**, and why it's important.
+- Include **tests**.
+- If it changes the UI, attach **screenshots** or screen recordings.
+- Make the PR about **one thing only**, e.g. if it's a bugfix, don't add two
+  features and a refactoring on top of that.
+- Keep AI assistance under your judgement and responsibility: it's unlikely
+  we'll merge a vibe-coded PR that the author doesn't understand.
 
 The internal advice for reviewers is as follows:
 
@@ -50,10 +60,9 @@ The internal advice for reviewers is as follows:
 If you need more feedback from us: the best way is to be responsive to
 Github comments, or to offer up time to pair with us.
 
-If you are making a larger change, or need advice on how to finish the change
-you're making, please open the PR early. We would love to help you get
-things right, and it's often easier to see how to solve a problem before the
-diff gets too big.
+If you need help deciding how to fix a bug, or finish implementing a feature
+that we've agreed we want, please open a PR early so we can discuss how to make
+the change with code in hand.
 
 ## Things we will (probably) not merge
 
@@ -61,11 +70,11 @@ Although there are few hard and fast rules, typically we don't merge:
 
 - Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions).
 - New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs.
+- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit.
 - Giant refactorings.
 - Non-trivial changes with no tests.
 - Stylistic code changes that do not alter any app logic. Reducing allocations, removing `.unwrap()`s, fixing typos is great; making code "more readable" — maybe not so much.
-- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit.
-- Anything that seems completely AI generated.
+- Anything that seems AI-generated without understanding the output.
 
 ## Bird's-eye view of Zed
 

Cargo.lock 🔗

@@ -37,6 +37,7 @@ dependencies = [
  "terminal",
  "ui",
  "url",
+ "urlencoding",
  "util",
  "uuid",
  "watch",
@@ -110,6 +111,15 @@ dependencies = [
  "workspace",
 ]
 
+[[package]]
+name = "addr2line"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
+dependencies = [
+ "gimli 0.31.1",
+]
+
 [[package]]
 name = "addr2line"
 version = "0.25.1"
@@ -216,9 +226,9 @@ dependencies = [
 
 [[package]]
 name = "agent-client-protocol"
-version = "0.9.0"
+version = "0.9.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2ffe7d502c1e451aafc5aff655000f84d09c9af681354ac0012527009b1af13"
+checksum = "d3e527d7dfe0f334313d42d1d9318f0a79665f6f21c440d0798f230a77a7ed6c"
 dependencies = [
  "agent-client-protocol-schema",
  "anyhow",
@@ -233,9 +243,9 @@ dependencies = [
 
 [[package]]
 name = "agent-client-protocol-schema"
-version = "0.10.0"
+version = "0.10.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8af81cc2d5c3f9c04f73db452efd058333735ba9d51c2cf7ef33c9fee038e7e6"
+checksum = "6903a00e8ac822f9bacac59a1932754d7387c72ebb7c9c7439ad021505591da4"
 dependencies = [
  "anyhow",
  "derive_more 2.0.1",
@@ -291,6 +301,7 @@ dependencies = [
 name = "agent_settings"
 version = "0.1.0"
 dependencies = [
+ "agent-client-protocol",
  "anyhow",
  "cloud_llm_client",
  "collections",
@@ -782,7 +793,7 @@ dependencies = [
  "url",
  "wayland-backend",
  "wayland-client",
- "wayland-protocols 0.32.9",
+ "wayland-protocols",
  "zbus",
 ]
 
@@ -1430,9 +1441,9 @@ dependencies = [
 
 [[package]]
 name = "aws-config"
-version = "1.8.8"
+version = "1.8.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37cf2b6af2a95a20e266782b4f76f1a5e12bf412a9db2de9c1e9123b9d8c0ad8"
+checksum = "1856b1b48b65f71a4dd940b1c0931f9a7b646d4a924b9828ffefc1454714668a"
 dependencies = [
  "aws-credential-types",
  "aws-runtime",
@@ -1496,9 +1507,9 @@ dependencies = [
 
 [[package]]
 name = "aws-runtime"
-version = "1.5.12"
+version = "1.5.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa006bb32360ed90ac51203feafb9d02e3d21046e1fd3a450a404b90ea73e5d"
+checksum = "9f2402da1a5e16868ba98725e5d73f26b8116eaa892e56f2cd0bf5eec7985f70"
 dependencies = [
  "aws-credential-types",
  "aws-sigv4",
@@ -1521,9 +1532,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sdk-bedrockruntime"
-version = "1.109.0"
+version = "1.112.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbfdfd941dcb253c17bf70baddbf1e5b22f19e29d313d2e049bad4b1dadb2011"
+checksum = "c06c037e6823696d752702ec2bad758d3cf95d1b92b712c8ac7e93824b5e2391"
 dependencies = [
  "aws-credential-types",
  "aws-runtime",
@@ -1603,9 +1614,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sdk-sso"
-version = "1.86.0"
+version = "1.88.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a0abbfab841446cce6e87af853a3ba2cc1bc9afcd3f3550dd556c43d434c86d"
+checksum = "d05b276777560aa9a196dbba2e3aada4d8006d3d7eeb3ba7fe0c317227d933c4"
 dependencies = [
  "aws-credential-types",
  "aws-runtime",
@@ -1625,9 +1636,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sdk-ssooidc"
-version = "1.88.0"
+version = "1.90.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a68d675582afea0e94d38b6ca9c5aaae4ca14f1d36faa6edb19b42e687e70d7"
+checksum = "f9be14d6d9cd761fac3fd234a0f47f7ed6c0df62d83c0eeb7012750e4732879b"
 dependencies = [
  "aws-credential-types",
  "aws-runtime",
@@ -1647,9 +1658,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sdk-sts"
-version = "1.88.0"
+version = "1.90.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d30990923f4f675523c51eb1c0dec9b752fb267b36a61e83cbc219c9d86da715"
+checksum = "98a862d704c817d865c8740b62d8bbeb5adcb30965e93b471df8a5bcefa20a80"
 dependencies = [
  "aws-credential-types",
  "aws-runtime",
@@ -1670,9 +1681,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sigv4"
-version = "1.3.5"
+version = "1.3.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bffc03068fbb9c8dd5ce1c6fb240678a5cffb86fb2b7b1985c999c4b83c8df68"
+checksum = "c35452ec3f001e1f2f6db107b6373f1f48f05ec63ba2c5c9fa91f07dad32af11"
 dependencies = [
  "aws-credential-types",
  "aws-smithy-eventstream",
@@ -1729,9 +1740,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-eventstream"
-version = "0.60.12"
+version = "0.60.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9656b85088f8d9dc7ad40f9a6c7228e1e8447cdf4b046c87e152e0805dea02fa"
+checksum = "e29a304f8319781a39808847efb39561351b1bb76e933da7aa90232673638658"
 dependencies = [
  "aws-smithy-types",
  "bytes 1.10.1",
@@ -1740,9 +1751,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-http"
-version = "0.62.4"
+version = "0.62.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3feafd437c763db26aa04e0cc7591185d0961e64c61885bece0fb9d50ceac671"
+checksum = "445d5d720c99eed0b4aa674ed00d835d9b1427dd73e04adaf2f94c6b2d6f9fca"
 dependencies = [
  "aws-smithy-eventstream",
  "aws-smithy-runtime-api",
@@ -1750,6 +1761,7 @@ dependencies = [
  "bytes 1.10.1",
  "bytes-utils",
  "futures-core",
+ "futures-util",
  "http 0.2.12",
  "http 1.3.1",
  "http-body 0.4.6",
@@ -1761,9 +1773,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-http-client"
-version = "1.1.3"
+version = "1.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1053b5e587e6fa40ce5a79ea27957b04ba660baa02b28b7436f64850152234f1"
+checksum = "623254723e8dfd535f566ee7b2381645f8981da086b5c4aa26c0c41582bb1d2c"
 dependencies = [
  "aws-smithy-async",
  "aws-smithy-runtime-api",
@@ -1791,9 +1803,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-json"
-version = "0.61.6"
+version = "0.61.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cff418fc8ec5cadf8173b10125f05c2e7e1d46771406187b2c878557d4503390"
+checksum = "2db31f727935fc63c6eeae8b37b438847639ec330a9161ece694efba257e0c54"
 dependencies = [
  "aws-smithy-types",
 ]
@@ -1819,9 +1831,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-runtime"
-version = "1.9.3"
+version = "1.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40ab99739082da5347660c556689256438defae3bcefd66c52b095905730e404"
+checksum = "0bbe9d018d646b96c7be063dd07987849862b0e6d07c778aad7d93d1be6c1ef0"
 dependencies = [
  "aws-smithy-async",
  "aws-smithy-http",
@@ -1843,9 +1855,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-runtime-api"
-version = "1.9.1"
+version = "1.9.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3683c5b152d2ad753607179ed71988e8cfd52964443b4f74fd8e552d0bbfeb46"
+checksum = "ec7204f9fd94749a7c53b26da1b961b4ac36bf070ef1e0b94bb09f79d4f6c193"
 dependencies = [
  "aws-smithy-async",
  "aws-smithy-types",
@@ -1860,9 +1872,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-types"
-version = "1.3.3"
+version = "1.3.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f5b3a7486f6690ba25952cabf1e7d75e34d69eaff5081904a47bc79074d6457"
+checksum = "25f535879a207fce0db74b679cfc3e91a3159c8144d717d55f5832aea9eef46e"
 dependencies = [
  "base64-simd",
  "bytes 1.10.1",
@@ -1886,18 +1898,18 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-xml"
-version = "0.60.11"
+version = "0.60.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9c34127e8c624bc2999f3b657e749c1393bedc9cd97b92a804db8ced4d2e163"
+checksum = "eab77cdd036b11056d2a30a7af7b775789fb024bf216acc13884c6c97752ae56"
 dependencies = [
  "xmlparser",
 ]
 
 [[package]]
 name = "aws-types"
-version = "1.3.9"
+version = "1.3.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2fd329bf0e901ff3f60425691410c69094dc2a1f34b331f37bfc4e9ac1565a1"
+checksum = "d79fb68e3d7fe5d4833ea34dc87d2e97d26d3086cb3da660bb6b1f76d98680b6"
 dependencies = [
  "aws-credential-types",
  "aws-smithy-async",
@@ -1996,7 +2008,7 @@ version = "0.3.76"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
 dependencies = [
- "addr2line",
+ "addr2line 0.25.1",
  "cfg-if",
  "libc",
  "miniz_oxide",
@@ -2884,6 +2896,17 @@ dependencies = [
  "util",
 ]
 
+[[package]]
+name = "chardetng"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
+dependencies = [
+ "cfg-if",
+ "encoding_rs",
+ "memchr",
+]
+
 [[package]]
 name = "chrono"
 version = "0.4.42"
@@ -3502,6 +3525,33 @@ dependencies = [
  "theme",
 ]
 
+[[package]]
+name = "component_preview"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "client",
+ "collections",
+ "component",
+ "db",
+ "fs",
+ "gpui",
+ "language",
+ "log",
+ "node_runtime",
+ "notifications",
+ "project",
+ "release_channel",
+ "reqwest_client",
+ "session",
+ "settings",
+ "theme",
+ "ui",
+ "ui_input",
+ "uuid",
+ "workspace",
+]
+
 [[package]]
 name = "compression-codecs"
 version = "0.4.31"
@@ -3612,6 +3662,7 @@ dependencies = [
  "serde",
  "serde_json",
  "settings",
+ "slotmap",
  "smol",
  "tempfile",
  "terminal",
@@ -3672,6 +3723,7 @@ dependencies = [
  "task",
  "theme",
  "ui",
+ "url",
  "util",
  "workspace",
  "zlog",
@@ -3922,20 +3974,38 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "cranelift-assembler-x64"
+version = "0.120.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5023e06632d8f351c2891793ccccfe4aef957954904392434038745fb6f1f68"
+dependencies = [
+ "cranelift-assembler-x64-meta",
+]
+
+[[package]]
+name = "cranelift-assembler-x64-meta"
+version = "0.120.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c4012b4c8c1f6eb05c0a0a540e3e1ee992631af51aa2bbb3e712903ce4fd65"
+dependencies = [
+ "cranelift-srcgen",
+]
+
 [[package]]
 name = "cranelift-bforest"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4"
+checksum = "4d6d883b4942ef3a7104096b8bc6f2d1a41393f159ac8de12aed27b25d67f895"
 dependencies = [
  "cranelift-entity",
 ]
 
 [[package]]
 name = "cranelift-bitset"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34"
+checksum = "db7b2ee9eec6ca8a716d900d5264d678fb2c290c58c46c8da7f94ee268175d17"
 dependencies = [
  "serde",
  "serde_derive",
@@ -3943,11 +4013,12 @@ dependencies = [
 
 [[package]]
 name = "cranelift-codegen"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e"
+checksum = "aeda0892577afdce1ac2e9a983a55f8c5b87a59334e1f79d8f735a2d7ba4f4b4"
 dependencies = [
  "bumpalo",
+ "cranelift-assembler-x64",
  "cranelift-bforest",
  "cranelift-bitset",
  "cranelift-codegen-meta",
@@ -3956,9 +4027,10 @@ dependencies = [
  "cranelift-entity",
  "cranelift-isle",
  "gimli 0.31.1",
- "hashbrown 0.14.5",
+ "hashbrown 0.15.5",
  "log",
  "postcard",
+ "pulley-interpreter",
  "regalloc2",
  "rustc-hash 2.1.1",
  "serde",
@@ -3970,33 +4042,36 @@ dependencies = [
 
 [[package]]
 name = "cranelift-codegen-meta"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8"
+checksum = "e461480d87f920c2787422463313326f67664e68108c14788ba1676f5edfcd15"
 dependencies = [
+ "cranelift-assembler-x64-meta",
  "cranelift-codegen-shared",
+ "cranelift-srcgen",
+ "pulley-interpreter",
 ]
 
 [[package]]
 name = "cranelift-codegen-shared"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb"
+checksum = "976584d09f200c6c84c4b9ff7af64fc9ad0cb64dffa5780991edd3fe143a30a1"
 
 [[package]]
 name = "cranelift-control"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef"
+checksum = "46d43d70f4e17c545aa88dbf4c84d4200755d27c6e3272ebe4de65802fa6a955"
 dependencies = [
  "arbitrary",
 ]
 
 [[package]]
 name = "cranelift-entity"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323"
+checksum = "d75418674520cb400c8772bfd6e11a62736c78fc1b6e418195696841d1bf91f1"
 dependencies = [
  "cranelift-bitset",
  "serde",
@@ -4005,9 +4080,9 @@ dependencies = [
 
 [[package]]
 name = "cranelift-frontend"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57"
+checksum = "3c8b1a91c86687a344f3c52dd6dfb6e50db0dfa7f2e9c7711b060b3623e1fdeb"
 dependencies = [
  "cranelift-codegen",
  "log",
@@ -4017,21 +4092,27 @@ dependencies = [
 
 [[package]]
 name = "cranelift-isle"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d"
+checksum = "711baa4e3432d4129295b39ec2b4040cc1b558874ba0a37d08e832e857db7285"
 
 [[package]]
 name = "cranelift-native"
-version = "0.116.1"
+version = "0.120.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7"
+checksum = "41c83e8666e3bcc5ffeaf6f01f356f0e1f9dcd69ce5511a1efd7ca5722001a3f"
 dependencies = [
  "cranelift-codegen",
  "libc",
  "target-lexicon 0.13.3",
 ]
 
+[[package]]
+name = "cranelift-srcgen"
+version = "0.120.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02e3f4d783a55c64266d17dc67d2708852235732a100fc40dd9f1051adc64d7b"
+
 [[package]]
 name = "crash-context"
 version = "0.6.3"
@@ -4967,8 +5048,6 @@ name = "docs_preprocessor"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "command_palette",
- "gpui",
  "mdbook",
  "regex",
  "serde",
@@ -4977,7 +5056,6 @@ dependencies = [
  "task",
  "theme",
  "util",
- "zed",
  "zlog",
 ]
 
@@ -7077,6 +7155,7 @@ dependencies = [
  "picker",
  "pretty_assertions",
  "project",
+ "prompt_store",
  "rand 0.9.2",
  "recent_projects",
  "remote",
@@ -7315,7 +7394,7 @@ dependencies = [
  "wayland-backend",
  "wayland-client",
  "wayland-cursor",
- "wayland-protocols 0.31.2",
+ "wayland-protocols",
  "wayland-protocols-plasma",
  "wayland-protocols-wlr",
  "windows 0.61.3",
@@ -8753,6 +8832,7 @@ dependencies = [
  "ctor",
  "diffy",
  "ec4rs",
+ "encoding_rs",
  "fs",
  "futures 0.3.31",
  "fuzzy",
@@ -8771,6 +8851,7 @@ dependencies = [
  "regex",
  "rpc",
  "schemars",
+ "semver",
  "serde",
  "serde_json",
  "settings",
@@ -8875,6 +8956,8 @@ dependencies = [
  "credentials_provider",
  "deepseek",
  "editor",
+ "extension",
+ "extension_host",
  "fs",
  "futures 0.3.31",
  "google_ai",
@@ -9005,6 +9088,7 @@ dependencies = [
  "regex",
  "rope",
  "rust-embed",
+ "semver",
  "serde",
  "serde_json",
  "serde_json_lenient",
@@ -10841,7 +10925,6 @@ dependencies = [
  "documented",
  "fs",
  "fuzzy",
- "git",
  "gpui",
  "menu",
  "notifications",
@@ -12419,6 +12502,8 @@ dependencies = [
  "context_server",
  "dap",
  "dap_adapters",
+ "db",
+ "encoding_rs",
  "extension",
  "fancy-regex",
  "fs",
@@ -12512,6 +12597,7 @@ dependencies = [
  "gpui",
  "language",
  "menu",
+ "notifications",
  "pretty_assertions",
  "project",
  "rayon",
@@ -12589,6 +12675,8 @@ dependencies = [
  "paths",
  "rope",
  "serde",
+ "strum 0.27.2",
+ "tempfile",
  "text",
  "util",
  "uuid",
@@ -12792,13 +12880,12 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3"
 
 [[package]]
 name = "pulley-interpreter"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d"
+checksum = "986beaef947a51d17b42b0ea18ceaa88450d35b6994737065ed505c39172db71"
 dependencies = [
  "cranelift-bitset",
  "log",
- "sptr",
  "wasmtime-math",
 ]
 
@@ -13297,9 +13384,9 @@ dependencies = [
 
 [[package]]
 name = "regalloc2"
-version = "0.11.2"
+version = "0.12.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a"
+checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734"
 dependencies = [
  "allocator-api2",
  "bumpalo",
@@ -14247,6 +14334,7 @@ dependencies = [
  "schemars",
  "serde",
  "serde_json",
+ "settings",
  "theme",
 ]
 
@@ -17307,9 +17395,9 @@ dependencies = [
 
 [[package]]
 name = "tree-sitter"
-version = "0.25.10"
+version = "0.26.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
+checksum = "974d205cc395652cfa8b37daa053fe56eebd429acf8dc055503fee648dae981e"
 dependencies = [
  "cc",
  "regex",
@@ -18396,6 +18484,16 @@ dependencies = [
  "wasmparser 0.227.1",
 ]
 
+[[package]]
+name = "wasm-encoder"
+version = "0.229.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38ba1d491ecacb085a2552025c10a675a6fddcbd03b1fc9b36c536010ce265d2"
+dependencies = [
+ "leb128fmt",
+ "wasmparser 0.229.0",
+]
+
 [[package]]
 name = "wasm-metadata"
 version = "0.201.0"
@@ -18480,23 +18578,37 @@ dependencies = [
  "semver",
 ]
 
+[[package]]
+name = "wasmparser"
+version = "0.229.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc3b1f053f5d41aa55640a1fa9b6d1b8a9e4418d118ce308d20e24ff3575a8c"
+dependencies = [
+ "bitflags 2.9.4",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+ "serde",
+]
+
 [[package]]
 name = "wasmprinter"
-version = "0.221.3"
+version = "0.229.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283"
+checksum = "d25dac01892684a99b8fbfaf670eb6b56edea8a096438c75392daeb83156ae2e"
 dependencies = [
  "anyhow",
  "termcolor",
- "wasmparser 0.221.3",
+ "wasmparser 0.229.0",
 ]
 
 [[package]]
 name = "wasmtime"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69"
+checksum = "57373e1d8699662fb791270ac5dfac9da5c14f618ecf940cdb29dc3ad9472a3c"
 dependencies = [
+ "addr2line 0.24.2",
  "anyhow",
  "async-trait",
  "bitflags 2.9.4",
@@ -18504,7 +18616,7 @@ dependencies = [
  "cc",
  "cfg-if",
  "encoding_rs",
- "hashbrown 0.14.5",
+ "hashbrown 0.15.5",
  "indexmap",
  "libc",
  "log",
@@ -18512,12 +18624,11 @@ dependencies = [
  "memfd",
  "object 0.36.7",
  "once_cell",
- "paste",
  "postcard",
  "psm",
  "pulley-interpreter",
  "rayon",
- "rustix 0.38.44",
+ "rustix 1.1.2",
  "semver",
  "serde",
  "serde_derive",
@@ -18525,7 +18636,7 @@ dependencies = [
  "sptr",
  "target-lexicon 0.13.3",
  "trait-variant",
- "wasmparser 0.221.3",
+ "wasmparser 0.229.0",
  "wasmtime-asm-macros",
  "wasmtime-component-macro",
  "wasmtime-component-util",
@@ -18542,18 +18653,18 @@ dependencies = [
 
 [[package]]
 name = "wasmtime-asm-macros"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2"
+checksum = "bd0fc91372865167a695dc98d0d6771799a388a7541d3f34e939d0539d6583de"
 dependencies = [
  "cfg-if",
 ]
 
 [[package]]
 name = "wasmtime-c-api-impl"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea30cef3608f2de5797c7bbb94c1ba4f3676d9a7f81ae86ced1b512e2766ed0c"
+checksum = "46db556f1dccdd88e0672bd407162ab0036b72e5eccb0f4398d8251cba32dba1"
 dependencies = [
  "anyhow",
  "log",
@@ -18564,9 +18675,9 @@ dependencies = [
 
 [[package]]
 name = "wasmtime-c-api-macros"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "022a79ebe1124d5d384d82463d7e61c6b4dd857d81f15cb8078974eeb86db65b"
+checksum = "315cc6bc8cdc66f296accb26d7625ae64c1c7b6da6f189e8a72ce6594bf7bd36"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -18574,9 +18685,9 @@ dependencies = [
 
 [[package]]
 name = "wasmtime-component-macro"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf"
+checksum = "25c9c7526675ff9a9794b115023c4af5128e3eb21389bfc3dc1fd344d549258f"
 dependencies = [
  "anyhow",
  "proc-macro2",
@@ -18584,20 +18695,20 @@ dependencies = [
  "syn 2.0.106",
  "wasmtime-component-util",
  "wasmtime-wit-bindgen",
- "wit-parser 0.221.3",
+ "wit-parser 0.229.0",
 ]
 
 [[package]]
 name = "wasmtime-component-util"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e"
+checksum = "cc42ec8b078875804908d797cb4950fec781d9add9684c9026487fd8eb3f6291"
 
 [[package]]
 name = "wasmtime-cranelift"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87"
+checksum = "b2bd72f0a6a0ffcc6a184ec86ac35c174e48ea0e97bbae277c8f15f8bf77a566"
 dependencies = [
  "anyhow",
  "cfg-if",
@@ -18607,22 +18718,23 @@ dependencies = [
  "cranelift-frontend",
  "cranelift-native",
  "gimli 0.31.1",
- "itertools 0.12.1",
+ "itertools 0.14.0",
  "log",
  "object 0.36.7",
+ "pulley-interpreter",
  "smallvec",
  "target-lexicon 0.13.3",
- "thiserror 1.0.69",
- "wasmparser 0.221.3",
+ "thiserror 2.0.17",
+ "wasmparser 0.229.0",
  "wasmtime-environ",
  "wasmtime-versioned-export-macros",
 ]
 
 [[package]]
 name = "wasmtime-environ"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad"
+checksum = "e6187bb108a23eb25d2a92aa65d6c89fb5ed53433a319038a2558567f3011ff2"
 dependencies = [
  "anyhow",
  "cpp_demangle",
@@ -18639,22 +18751,22 @@ dependencies = [
  "serde_derive",
  "smallvec",
  "target-lexicon 0.13.3",
- "wasm-encoder 0.221.3",
- "wasmparser 0.221.3",
+ "wasm-encoder 0.229.0",
+ "wasmparser 0.229.0",
  "wasmprinter",
  "wasmtime-component-util",
 ]
 
 [[package]]
 name = "wasmtime-fiber"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117"
+checksum = "dc8965d2128c012329f390e24b8b2758dd93d01bf67e1a1a0dd3d8fd72f56873"
 dependencies = [
  "anyhow",
  "cc",
  "cfg-if",
- "rustix 0.38.44",
+ "rustix 1.1.2",
  "wasmtime-asm-macros",
  "wasmtime-versioned-export-macros",
  "windows-sys 0.59.0",
@@ -18662,9 +18774,9 @@ dependencies = [
 
 [[package]]
 name = "wasmtime-jit-icache-coherence"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1"
+checksum = "7af0e940cb062a45c0b3f01a926f77da5947149e99beb4e3dd9846d5b8f11619"
 dependencies = [
  "anyhow",
  "cfg-if",
@@ -18674,24 +18786,24 @@ dependencies = [
 
 [[package]]
 name = "wasmtime-math"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17"
+checksum = "acfca360e719dda9a27e26944f2754ff2fd5bad88e21919c42c5a5f38ddd93cb"
 dependencies = [
  "libm",
 ]
 
 [[package]]
 name = "wasmtime-slab"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf"
+checksum = "48e240559cada55c4b24af979d5f6c95e0029f5772f32027ec3c62b258aaff65"
 
 [[package]]
 name = "wasmtime-versioned-export-macros"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b"
+checksum = "d0963c1438357a3d8c0efe152b4ef5259846c1cf8b864340270744fe5b3bae5e"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -18700,9 +18812,9 @@ dependencies = [
 
 [[package]]
 name = "wasmtime-wasi"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d1be69bfcab1bdac74daa7a1f9695ab992b9c8e21b9b061e7d66434097e0ca4"
+checksum = "4ae951b72c7c6749a1c15dcdfb6d940a2614c932b4a54f474636e78e2c744b4c"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -18717,30 +18829,43 @@ dependencies = [
  "futures 0.3.31",
  "io-extras",
  "io-lifetimes",
- "rustix 0.38.44",
+ "rustix 1.1.2",
  "system-interface",
- "thiserror 1.0.69",
+ "thiserror 2.0.17",
  "tokio",
  "tracing",
- "trait-variant",
  "url",
  "wasmtime",
+ "wasmtime-wasi-io",
  "wiggle",
  "windows-sys 0.59.0",
 ]
 
+[[package]]
+name = "wasmtime-wasi-io"
+version = "33.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a835790dcecc3d7051ec67da52ba9e04af25e1bc204275b9391e3f0042b10797"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "bytes 1.10.1",
+ "futures 0.3.31",
+ "wasmtime",
+]
+
 [[package]]
 name = "wasmtime-winch"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f"
+checksum = "cbc3b117d03d6eeabfa005a880c5c22c06503bb8820f3aa2e30f0e8d87b6752f"
 dependencies = [
  "anyhow",
  "cranelift-codegen",
  "gimli 0.31.1",
  "object 0.36.7",
  "target-lexicon 0.13.3",
- "wasmparser 0.221.3",
+ "wasmparser 0.229.0",
  "wasmtime-cranelift",
  "wasmtime-environ",
  "winch-codegen",
@@ -18748,14 +18873,14 @@ dependencies = [
 
 [[package]]
 name = "wasmtime-wit-bindgen"
-version = "29.0.1"
+version = "33.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6"
+checksum = "1382f4f09390eab0d75d4994d0c3b0f6279f86a571807ec67a8253c87cf6a145"
 dependencies = [
  "anyhow",
  "heck 0.5.0",
  "indexmap",
- "wit-parser 0.221.3",
+ "wit-parser 0.229.0",
 ]
 
 [[package]]
@@ -18831,18 +18956,6 @@ dependencies = [
  "xcursor",
 ]
 
-[[package]]
-name = "wayland-protocols"
-version = "0.31.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4"
-dependencies = [
- "bitflags 2.9.4",
- "wayland-backend",
- "wayland-client",
- "wayland-scanner",
-]
-
 [[package]]
 name = "wayland-protocols"
 version = "0.32.9"

Cargo.toml 🔗

@@ -39,6 +39,7 @@ members = [
     "crates/command_palette",
     "crates/command_palette_hooks",
     "crates/component",
+    "crates/component_preview",
     "crates/context_server",
     "crates/copilot",
     "crates/crashes",
@@ -192,11 +193,13 @@ members = [
     "crates/vercel",
     "crates/vim",
     "crates/vim_mode_setting",
+    "crates/which_key",
     "crates/watch",
     "crates/web_search",
     "crates/web_search_providers",
     "crates/workspace",
     "crates/worktree",
+    "crates/worktree_benchmarks",
     "crates/x_ai",
     "crates/zed",
     "crates/zed_actions",
@@ -273,6 +276,7 @@ collections = { path = "crates/collections", version = "0.1.0" }
 command_palette = { path = "crates/command_palette" }
 command_palette_hooks = { path = "crates/command_palette_hooks" }
 component = { path = "crates/component" }
+component_preview  = { path = "crates/component_preview" }
 context_server = { path = "crates/context_server" }
 copilot = { path = "crates/copilot" }
 crashes = { path = "crates/crashes" }
@@ -415,6 +419,7 @@ util_macros = { path = "crates/util_macros" }
 vercel = { path = "crates/vercel" }
 vim = { path = "crates/vim" }
 vim_mode_setting = { path = "crates/vim_mode_setting" }
+which_key = { path = "crates/which_key" }
 
 watch = { path = "crates/watch" }
 web_search = { path = "crates/web_search" }
@@ -436,7 +441,7 @@ ztracing_macro = { path = "crates/ztracing_macro" }
 # External crates
 #
 
-agent-client-protocol = { version = "=0.9.0", features = ["unstable"] }
+agent-client-protocol = { version = "=0.9.2", features = ["unstable"] }
 aho-corasick = "1.1"
 alacritty_terminal = "0.25.1-rc1"
 any_vec = "0.14"
@@ -455,15 +460,15 @@ async-task = "4.7"
 async-trait = "0.1"
 async-tungstenite = "0.31.0"
 async_zip = { version = "0.0.18", features = ["deflate", "deflate64"] }
-aws-config = { version = "1.6.1", features = ["behavior-version-latest"] }
-aws-credential-types = { version = "1.2.2", features = [
+aws-config = { version = "1.8.10", features = ["behavior-version-latest"] }
+aws-credential-types = { version = "1.2.8", features = [
     "hardcoded-credentials",
 ] }
-aws-sdk-bedrockruntime = { version = "1.80.0", features = [
+aws-sdk-bedrockruntime = { version = "1.112.0", features = [
     "behavior-version-latest",
 ] }
-aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
-aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
+aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] }
+aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] }
 backtrace = "0.3"
 base64 = "0.22"
 bincode = "1.2.1"
@@ -476,6 +481,7 @@ bytes = "1.0"
 cargo_metadata = "0.19"
 cargo_toml = "0.21"
 cfg-if = "1.0.3"
+chardetng = "0.1"
 chrono = { version = "0.4", features = ["serde"] }
 ciborium = "0.2"
 circular-buffer = "1.0"
@@ -499,6 +505,7 @@ dotenvy = "0.15.0"
 ec4rs = "1.1"
 emojis = "0.6.1"
 env_logger = "0.11"
+encoding_rs = "0.8"
 exec = "0.3.1"
 fancy-regex = "0.16.0"
 fork = "0.4.0"
@@ -663,7 +670,7 @@ tokio-socks = { version = "0.5.2", default-features = false, features = ["future
 toml = "0.8"
 toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] }
 tower-http = "0.4.4"
-tree-sitter = { version = "0.25.10", features = ["wasm"] }
+tree-sitter = { version = "0.26", features = ["wasm"] }
 tree-sitter-bash = "0.25.1"
 tree-sitter-c = "0.23"
 tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" }
@@ -697,7 +704,7 @@ uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] }
 walkdir = "2.5"
 wasm-encoder = "0.221"
 wasmparser = "0.221"
-wasmtime = { version = "29", default-features = false, features = [
+wasmtime = { version = "33", default-features = false, features = [
     "async",
     "demangle",
     "runtime",
@@ -706,7 +713,7 @@ wasmtime = { version = "29", default-features = false, features = [
     "incremental-cache",
     "parallel-compilation",
 ] }
-wasmtime-wasi = "29"
+wasmtime-wasi = "33"
 wax = "0.6"
 which = "6.0.0"
 windows-core = "0.61"
@@ -857,8 +864,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"

Dockerfile-collab 🔗

@@ -1,6 +1,6 @@
 # syntax = docker/dockerfile:1.2
 
-FROM rust:1.91.1-bookworm as builder
+FROM rust:1.92-bookworm as builder
 WORKDIR app
 COPY . .
 

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:
 
@@ -20,7 +20,6 @@ Other platforms are not yet available:
 - [Building Zed for macOS](./docs/src/development/macos.md)
 - [Building Zed for Linux](./docs/src/development/linux.md)
 - [Building Zed for Windows](./docs/src/development/windows.md)
-- [Running Collaboration Locally](./docs/src/development/local-collaboration.md)
 
 ### Contributing
 

REVIEWERS.conl 🔗

@@ -28,7 +28,7 @@ ai
   = @rtfeldman
 
 audio
-  = @dvdsk
+  = @yara-blue
 
 crashes
   = @p1n3appl3
@@ -53,7 +53,7 @@ extension
 git
   = @cole-miller
   = @danilo-leal
-  = @dvdsk
+  = @yara-blue
   = @kubkon
   = @Anthony-Eid
   = @cameron1024
@@ -76,7 +76,7 @@ languages
 
 linux
   = @cole-miller
-  = @dvdsk
+  = @yara-blue
   = @p1n3appl3
   = @probably-neb
   = @smitbarmase
@@ -92,7 +92,7 @@ multi_buffer
   = @SomeoneToIgnore
 
 pickers
-  = @dvdsk
+  = @yara-blue
   = @p1n3appl3
   = @SomeoneToIgnore
 

assets/keymaps/default-linux.json 🔗

@@ -45,6 +45,7 @@
       "ctrl-alt-z": "edit_prediction::RatePredictions",
       "ctrl-alt-shift-i": "edit_prediction::ToggleMenu",
       "ctrl-alt-l": "lsp_tool::ToggleMenu",
+      "ctrl-alt-shift-s": "workspace::ToggleWorktreeSecurity",
     },
   },
   {
@@ -226,6 +227,7 @@
       "ctrl-g": "search::SelectNextMatch",
       "ctrl-shift-g": "search::SelectPreviousMatch",
       "ctrl-k l": "agent::OpenRulesLibrary",
+      "ctrl-shift-v": "agent::PasteRaw",
     },
   },
   {
@@ -251,6 +253,7 @@
       "ctrl-y": "agent::AllowOnce",
       "ctrl-alt-y": "agent::AllowAlways",
       "ctrl-alt-z": "agent::RejectOnce",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -291,6 +294,7 @@
       "shift-ctrl-r": "agent::OpenAgentDiff",
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
+      "ctrl-shift-v": "agent::PasteRaw",
     },
   },
   {
@@ -302,6 +306,7 @@
       "shift-ctrl-r": "agent::OpenAgentDiff",
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
+      "ctrl-shift-v": "agent::PasteRaw",
     },
   },
   {
@@ -345,6 +350,7 @@
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -900,8 +906,10 @@
   {
     "context": "GitPanel && ChangesList",
     "bindings": {
-      "up": "menu::SelectPrevious",
-      "down": "menu::SelectNext",
+      "left": "git_panel::CollapseSelectedEntry",
+      "right": "git_panel::ExpandSelectedEntry",
+      "up": "git_panel::PreviousEntry",
+      "down": "git_panel::NextEntry",
       "enter": "menu::Confirm",
       "alt-y": "git::StageFile",
       "alt-shift-y": "git::UnstageFile",
@@ -1263,6 +1271,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 🔗

@@ -51,6 +51,7 @@
       "ctrl-cmd-i": "edit_prediction::ToggleMenu",
       "ctrl-cmd-l": "lsp_tool::ToggleMenu",
       "ctrl-cmd-c": "editor::DisplayCursorNames",
+      "ctrl-cmd-s": "workspace::ToggleWorktreeSecurity",
     },
   },
   {
@@ -265,6 +266,8 @@
       "cmd-g": "search::SelectNextMatch",
       "cmd-shift-g": "search::SelectPreviousMatch",
       "cmd-k l": "agent::OpenRulesLibrary",
+      "alt-tab": "agent::CycleFavoriteModels",
+      "cmd-shift-v": "agent::PasteRaw",
     },
   },
   {
@@ -291,6 +294,7 @@
       "cmd-y": "agent::AllowOnce",
       "cmd-alt-y": "agent::AllowAlways",
       "cmd-alt-z": "agent::RejectOnce",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -332,6 +336,7 @@
       "shift-ctrl-r": "agent::OpenAgentDiff",
       "cmd-shift-y": "agent::KeepAll",
       "cmd-shift-n": "agent::RejectAll",
+      "cmd-shift-v": "agent::PasteRaw",
     },
   },
   {
@@ -344,6 +349,7 @@
       "shift-ctrl-r": "agent::OpenAgentDiff",
       "cmd-shift-y": "agent::KeepAll",
       "cmd-shift-n": "agent::RejectAll",
+      "cmd-shift-v": "agent::PasteRaw",
     },
   },
   {
@@ -385,6 +391,7 @@
       "cmd-shift-y": "agent::KeepAll",
       "cmd-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -396,6 +403,7 @@
       "cmd-shift-y": "agent::KeepAll",
       "cmd-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -879,6 +887,7 @@
     "use_key_equivalents": true,
     "bindings": {
       "cmd-alt-/": "agent::ToggleModelSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
       "ctrl-[": "agent::CyclePreviousInlineAssist",
       "ctrl-]": "agent::CycleNextInlineAssist",
       "cmd-shift-enter": "inline_assistant::ThumbsUpResult",
@@ -975,10 +984,12 @@
     "context": "GitPanel && ChangesList",
     "use_key_equivalents": true,
     "bindings": {
-      "up": "menu::SelectPrevious",
-      "down": "menu::SelectNext",
-      "cmd-up": "menu::SelectFirst",
-      "cmd-down": "menu::SelectLast",
+      "up": "git_panel::PreviousEntry",
+      "down": "git_panel::NextEntry",
+      "cmd-up": "git_panel::FirstEntry",
+      "cmd-down": "git_panel::LastEntry",
+      "left": "git_panel::CollapseSelectedEntry",
+      "right": "git_panel::ExpandSelectedEntry",
       "enter": "menu::Confirm",
       "cmd-alt-y": "git::ToggleStaged",
       "space": "git::ToggleStaged",
@@ -1366,6 +1377,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 🔗

@@ -43,6 +43,7 @@
       "ctrl-shift-i": "edit_prediction::ToggleMenu",
       "shift-alt-l": "lsp_tool::ToggleMenu",
       "ctrl-shift-alt-c": "editor::DisplayCursorNames",
+      "ctrl-shift-alt-s": "workspace::ToggleWorktreeSecurity",
     },
   },
   {
@@ -226,6 +227,7 @@
       "ctrl-g": "search::SelectNextMatch",
       "ctrl-shift-g": "search::SelectPreviousMatch",
       "ctrl-k l": "agent::OpenRulesLibrary",
+      "ctrl-shift-v": "agent::PasteRaw",
     },
   },
   {
@@ -252,6 +254,7 @@
       "shift-alt-a": "agent::AllowOnce",
       "ctrl-alt-y": "agent::AllowAlways",
       "shift-alt-z": "agent::RejectOnce",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -294,6 +297,7 @@
       "ctrl-shift-r": "agent::OpenAgentDiff",
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
+      "ctrl-shift-v": "agent::PasteRaw",
     },
   },
   {
@@ -306,6 +310,7 @@
       "ctrl-shift-r": "agent::OpenAgentDiff",
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
+      "ctrl-shift-v": "agent::PasteRaw",
     },
   },
   {
@@ -341,6 +346,7 @@
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -352,6 +358,7 @@
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -904,8 +911,10 @@
     "context": "GitPanel && ChangesList",
     "use_key_equivalents": true,
     "bindings": {
-      "up": "menu::SelectPrevious",
-      "down": "menu::SelectNext",
+      "up": "git_panel::PreviousEntry",
+      "down": "git_panel::NextEntry",
+      "left": "git_panel::CollapseSelectedEntry",
+      "right": "git_panel::ExpandSelectedEntry",
       "enter": "menu::Confirm",
       "alt-y": "git::StageFile",
       "shift-alt-y": "git::UnstageFile",
@@ -1295,6 +1304,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/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/keymaps/vim.json 🔗

@@ -502,6 +502,11 @@
       "g p": "pane::ActivatePreviousItem",
       "shift-h": "pane::ActivatePreviousItem", // not a helix default
       "g .": "vim::HelixGotoLastModification",
+      "g o": "editor::ToggleSelectedDiffHunks", // Zed specific
+      "g shift-o": "git::ToggleStaged", // Zed specific
+      "g shift-r": "git::Restore", // Zed specific
+      "g u": "git::StageAndNext", // Zed specific
+      "g shift-u": "git::UnstageAndNext", // Zed specific
 
       // Window mode
       "space w v": "pane::SplitDown",

assets/prompts/content_prompt_v2.hbs 🔗

@@ -14,7 +14,6 @@ The section you'll need to rewrite is marked with <rewrite_this></rewrite_this>
 The context around the relevant section has been truncated (possibly in the middle of a line) for brevity.
 {{/if}}
 
-{{#if rewrite_section}}
 And here's the section to rewrite based on that prompt again for reference:
 
 <rewrite_this>
@@ -33,8 +32,6 @@ Below are the diagnostic errors visible to the user.  If the user requests probl
 {{/each}}
 {{/if}}
 
-{{/if}}
-
 Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding {{content_type}} will be preserved.
 
 Start at the indentation level in the original file in the rewritten {{content_type}}.

assets/settings/default.json 🔗

@@ -972,6 +972,8 @@
           "now": true,
           "find_path": true,
           "read_file": true,
+          "restore_file_from_disk": true,
+          "save_file": true,
           "open": true,
           "grep": true,
           "terminal": true,
@@ -1176,6 +1178,10 @@
   "remove_trailing_whitespace_on_save": true,
   // Whether to start a new line with a comment when a previous line is a comment as well.
   "extend_comment_on_newline": true,
+  // Whether to continue markdown lists when pressing enter.
+  "extend_list_on_newline": true,
+  // Whether to indent list items when pressing tab after a list marker.
+  "indent_list_on_tab": true,
   // Removes any lines containing only whitespace at the end of the file and
   // ensures just one newline at the end.
   "ensure_final_newline_on_save": true,
@@ -1319,6 +1325,14 @@
   "hidden_files": ["**/.*"],
   // Git gutter behavior configuration.
   "git": {
+    // Global switch to enable or disable all git integration features.
+    // If set to true, disables all git integration features.
+    // If set to false, individual git integration features below will be independently enabled or disabled.
+    "disable_git": false,
+    // Whether to enable git status tracking.
+    "enable_status": true,
+    // Whether to enable git diff display.
+    "enable_diff": true,
     // Control whether the git gutter is shown. May take 2 values:
     // 1. Show the gutter
     //      "git_gutter": "tracked_files"
@@ -1703,7 +1717,12 @@
   // }
   //
   "file_types": {
-    "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"],
+    "JSONC": [
+      "**/.zed/*.json",
+      "**/.vscode/**/*.json",
+      "**/{zed,Zed}/{settings,keymap,tasks,debug}.json",
+      "tsconfig*.json",
+    ],
     "Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"],
     "Shell Script": [".env.*"],
   },
@@ -2060,6 +2079,12 @@
     //
     // Default: true
     "restore_unsaved_buffers": true,
+    // Whether or not to skip worktree trust checks.
+    // When trusted, project settings are synchronized automatically,
+    // language and MCP servers are downloaded and started automatically.
+    //
+    // Default: false
+    "trust_all_worktrees": false,
   },
   // Zed's Prettier integration settings.
   // Allows to enable/disable formatting with Prettier
@@ -2144,6 +2169,13 @@
     // The shape can be one of the following: "block", "bar", "underline", "hollow".
     "cursor_shape": {},
   },
+  // Which-key popup settings
+  "which_key": {
+    // Whether to show the which-key popup when holding down key combinations.
+    "enabled": false,
+    // Delay in milliseconds before showing the which-key popup.
+    "delay_ms": 1000,
+  },
   // The server to connect to. If the environment variable
   // ZED_SERVER_URL is set, it will override this setting.
   "server_url": "https://zed.dev",

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

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/acp_thread.rs 🔗

@@ -43,6 +43,7 @@ pub struct UserMessage {
     pub content: ContentBlock,
     pub chunks: Vec<acp::ContentBlock>,
     pub checkpoint: Option<Checkpoint>,
+    pub indented: bool,
 }
 
 #[derive(Debug)]
@@ -73,6 +74,7 @@ impl UserMessage {
 #[derive(Debug, PartialEq)]
 pub struct AssistantMessage {
     pub chunks: Vec<AssistantMessageChunk>,
+    pub indented: bool,
 }
 
 impl AssistantMessage {
@@ -123,6 +125,14 @@ pub enum AgentThreadEntry {
 }
 
 impl AgentThreadEntry {
+    pub fn is_indented(&self) -> bool {
+        match self {
+            Self::UserMessage(message) => message.indented,
+            Self::AssistantMessage(message) => message.indented,
+            Self::ToolCall(_) => false,
+        }
+    }
+
     pub fn to_markdown(&self, cx: &App) -> String {
         match self {
             Self::UserMessage(message) => message.to_markdown(cx),
@@ -182,6 +192,7 @@ pub struct ToolCall {
     pub locations: Vec<acp::ToolCallLocation>,
     pub resolved_locations: Vec<Option<AgentLocation>>,
     pub raw_input: Option<serde_json::Value>,
+    pub raw_input_markdown: Option<Entity<Markdown>>,
     pub raw_output: Option<serde_json::Value>,
 }
 
@@ -212,6 +223,11 @@ impl ToolCall {
             }
         }
 
+        let raw_input_markdown = tool_call
+            .raw_input
+            .as_ref()
+            .and_then(|input| markdown_for_raw_output(input, &language_registry, cx));
+
         let result = Self {
             id: tool_call.tool_call_id,
             label: cx
@@ -222,6 +238,7 @@ impl ToolCall {
             resolved_locations: Vec::default(),
             status,
             raw_input: tool_call.raw_input,
+            raw_input_markdown,
             raw_output: tool_call.raw_output,
         };
         Ok(result)
@@ -297,6 +314,7 @@ impl ToolCall {
         }
 
         if let Some(raw_input) = raw_input {
+            self.raw_input_markdown = markdown_for_raw_output(&raw_input, &language_registry, cx);
             self.raw_input = Some(raw_input);
         }
 
@@ -1184,6 +1202,16 @@ impl AcpThread {
         message_id: Option<UserMessageId>,
         chunk: acp::ContentBlock,
         cx: &mut Context<Self>,
+    ) {
+        self.push_user_content_block_with_indent(message_id, chunk, false, cx)
+    }
+
+    pub fn push_user_content_block_with_indent(
+        &mut self,
+        message_id: Option<UserMessageId>,
+        chunk: acp::ContentBlock,
+        indented: bool,
+        cx: &mut Context<Self>,
     ) {
         let language_registry = self.project.read(cx).languages().clone();
         let path_style = self.project.read(cx).path_style(cx);
@@ -1194,8 +1222,10 @@ impl AcpThread {
                 id,
                 content,
                 chunks,
+                indented: existing_indented,
                 ..
             }) = last_entry
+            && *existing_indented == indented
         {
             *id = message_id.or(id.take());
             content.append(chunk.clone(), &language_registry, path_style, cx);
@@ -1210,6 +1240,7 @@ impl AcpThread {
                     content,
                     chunks: vec![chunk],
                     checkpoint: None,
+                    indented,
                 }),
                 cx,
             );
@@ -1221,12 +1252,26 @@ impl AcpThread {
         chunk: acp::ContentBlock,
         is_thought: bool,
         cx: &mut Context<Self>,
+    ) {
+        self.push_assistant_content_block_with_indent(chunk, is_thought, false, cx)
+    }
+
+    pub fn push_assistant_content_block_with_indent(
+        &mut self,
+        chunk: acp::ContentBlock,
+        is_thought: bool,
+        indented: bool,
+        cx: &mut Context<Self>,
     ) {
         let language_registry = self.project.read(cx).languages().clone();
         let path_style = self.project.read(cx).path_style(cx);
         let entries_len = self.entries.len();
         if let Some(last_entry) = self.entries.last_mut()
-            && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry
+            && let AgentThreadEntry::AssistantMessage(AssistantMessage {
+                chunks,
+                indented: existing_indented,
+            }) = last_entry
+            && *existing_indented == indented
         {
             let idx = entries_len - 1;
             cx.emit(AcpThreadEvent::EntryUpdated(idx));
@@ -1255,6 +1300,7 @@ impl AcpThread {
             self.push_entry(
                 AgentThreadEntry::AssistantMessage(AssistantMessage {
                     chunks: vec![chunk],
+                    indented,
                 }),
                 cx,
             );
@@ -1317,6 +1363,7 @@ impl AcpThread {
                     locations: Vec::new(),
                     resolved_locations: Vec::new(),
                     raw_input: None,
+                    raw_input_markdown: None,
                     raw_output: None,
                 };
                 self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx);
@@ -1704,6 +1751,7 @@ impl AcpThread {
                         content: block,
                         chunks: message,
                         checkpoint: None,
+                        indented: false,
                     }),
                     cx,
                 );

crates/acp_thread/src/connection.rs 🔗

@@ -202,6 +202,21 @@ pub trait AgentModelSelector: 'static {
     fn should_render_footer(&self) -> bool {
         false
     }
+
+    /// Whether this selector supports the favorites feature.
+    /// Only the native agent uses the model ID format that maps to settings.
+    fn supports_favorites(&self) -> bool {
+        false
+    }
+}
+
+/// Icon for a model in the model selector.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum AgentModelIcon {
+    /// A built-in icon from Zed's icon set.
+    Named(IconName),
+    /// Path to a custom SVG icon file.
+    Path(SharedString),
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -209,7 +224,7 @@ pub struct AgentModelInfo {
     pub id: acp::ModelId,
     pub name: SharedString,
     pub description: Option<SharedString>,
-    pub icon: Option<IconName>,
+    pub icon: Option<AgentModelIcon>,
 }
 
 impl From<acp::ModelInfo> for AgentModelInfo {
@@ -239,6 +254,10 @@ impl AgentModelList {
             AgentModelList::Grouped(groups) => groups.is_empty(),
         }
     }
+
+    pub fn is_flat(&self) -> bool {
+        matches!(self, AgentModelList::Flat(_))
+    }
 }
 
 #[cfg(feature = "test-support")]

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/action_log/src/action_log.rs 🔗

@@ -6,7 +6,7 @@ use futures::{FutureExt, StreamExt, channel::mpsc};
 use gpui::{
     App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
 };
-use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint};
+use language::{Anchor, Buffer, BufferEvent, Point, ToPoint};
 use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
 use std::{cmp, ops::Range, sync::Arc};
 use text::{Edit, Patch, Rope};
@@ -150,7 +150,7 @@ impl ActionLog {
                 if buffer
                     .read(cx)
                     .file()
-                    .is_some_and(|file| file.disk_state() == DiskState::Deleted)
+                    .is_some_and(|file| file.disk_state().is_deleted())
                 {
                     // If the buffer had been edited by a tool, but it got
                     // deleted externally, we want to stop tracking it.
@@ -162,7 +162,7 @@ impl ActionLog {
                 if buffer
                     .read(cx)
                     .file()
-                    .is_some_and(|file| file.disk_state() != DiskState::Deleted)
+                    .is_some_and(|file| !file.disk_state().is_deleted())
                 {
                     // If the buffer had been deleted by a tool, but it got
                     // resurrected externally, we want to clear the edits we
@@ -769,7 +769,7 @@ impl ActionLog {
                 tracked.version != buffer.version
                     && buffer
                         .file()
-                        .is_some_and(|file| file.disk_state() != DiskState::Deleted)
+                        .is_some_and(|file| !file.disk_state().is_deleted())
             })
             .map(|(buffer, _)| buffer)
     }

crates/agent/src/agent.rs 🔗

@@ -5,12 +5,12 @@ mod legacy_thread;
 mod native_agent_server;
 pub mod outline;
 mod templates;
-mod thread;
-mod tools;
-
 #[cfg(test)]
 mod tests;
+mod thread;
+mod tools;
 
+use context_server::ContextServerId;
 pub use db::*;
 pub use history_store::*;
 pub use native_agent_server::NativeAgentServer;
@@ -18,11 +18,11 @@ pub use templates::*;
 pub use thread::*;
 pub use tools::*;
 
-use acp_thread::{AcpThread, AgentModelSelector};
+use acp_thread::{AcpThread, AgentModelSelector, UserMessageId};
 use agent_client_protocol as acp;
 use anyhow::{Context as _, Result, anyhow};
 use chrono::{DateTime, Utc};
-use collections::{HashSet, IndexMap};
+use collections::{HashMap, HashSet, IndexMap};
 use fs::Fs;
 use futures::channel::{mpsc, oneshot};
 use futures::future::Shared;
@@ -30,15 +30,15 @@ use futures::{StreamExt, future};
 use gpui::{
     App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
 };
-use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry};
+use language_model::{IconOrSvg, 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};
 use std::any::Any;
-use std::collections::HashMap;
 use std::path::{Path, PathBuf};
 use std::rc::Rc;
 use std::sync::Arc;
@@ -51,18 +51,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,
 }
@@ -105,7 +93,7 @@ impl LanguageModels {
     fn refresh_list(&mut self, cx: &App) {
         let providers = LanguageModelRegistry::global(cx)
             .read(cx)
-            .providers()
+            .visible_providers()
             .into_iter()
             .filter(|provider| provider.is_authenticated(cx))
             .collect::<Vec<_>>();
@@ -165,7 +153,10 @@ impl LanguageModels {
             id: Self::model_id(model),
             name: model.name().0,
             description: None,
-            icon: Some(provider.icon()),
+            icon: Some(match provider.icon() {
+                IconOrSvg::Svg(path) => acp_thread::AgentModelIcon::Path(path),
+                IconOrSvg::Icon(name) => acp_thread::AgentModelIcon::Named(name),
+            }),
         }
     }
 
@@ -176,7 +167,7 @@ impl LanguageModels {
     fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> {
         let authenticate_all_providers = LanguageModelRegistry::global(cx)
             .read(cx)
-            .providers()
+            .visible_providers()
             .iter()
             .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
             .collect::<Vec<_>>();
@@ -263,12 +254,24 @@ impl NativeAgent {
             .await;
 
         cx.new(|cx| {
+            let context_server_store = project.read(cx).context_server_store();
+            let context_server_registry =
+                cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
+
             let mut subscriptions = vec![
                 cx.subscribe(&project, Self::handle_project_event),
                 cx.subscribe(
                     &LanguageModelRegistry::global(cx),
                     Self::handle_models_updated_event,
                 ),
+                cx.subscribe(
+                    &context_server_store,
+                    Self::handle_context_server_store_updated,
+                ),
+                cx.subscribe(
+                    &context_server_registry,
+                    Self::handle_context_server_registry_event,
+                ),
             ];
             if let Some(prompt_store) = prompt_store.as_ref() {
                 subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
@@ -277,16 +280,14 @@ impl NativeAgent {
             let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
                 watch::channel(());
             Self {
-                sessions: HashMap::new(),
+                sessions: HashMap::default(),
                 history,
                 project_context: cx.new(|_| project_context),
                 project_context_needs_refresh: project_context_needs_refresh_tx,
                 _maintain_project_context: cx.spawn(async move |this, cx| {
                     Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await
                 }),
-                context_server_registry: cx.new(|cx| {
-                    ContextServerRegistry::new(project.read(cx).context_server_store(), cx)
-                }),
+                context_server_registry,
                 templates,
                 models: LanguageModels::new(cx),
                 project,
@@ -355,6 +356,9 @@ impl NativeAgent {
                 pending_save: Task::ready(()),
             },
         );
+
+        self.update_available_commands(cx);
+
         acp_thread
     }
 
@@ -425,10 +429,7 @@ impl NativeAgent {
                 .into_iter()
                 .flat_map(|(contents, prompt_metadata)| match contents {
                     Ok(contents) => Some(UserRulesContext {
-                        uuid: match prompt_metadata.id {
-                            prompt_store::PromptId::User { uuid } => uuid,
-                            prompt_store::PromptId::EditWorkflow => return None,
-                        },
+                        uuid: prompt_metadata.id.as_user()?,
                         title: prompt_metadata.title.map(|title| title.to_string()),
                         contents,
                     }),
@@ -622,6 +623,99 @@ impl NativeAgent {
         }
     }
 
+    fn handle_context_server_store_updated(
+        &mut self,
+        _store: Entity<project::context_server_store::ContextServerStore>,
+        _event: &project::context_server_store::Event,
+        cx: &mut Context<Self>,
+    ) {
+        self.update_available_commands(cx);
+    }
+
+    fn handle_context_server_registry_event(
+        &mut self,
+        _registry: Entity<ContextServerRegistry>,
+        event: &ContextServerRegistryEvent,
+        cx: &mut Context<Self>,
+    ) {
+        match event {
+            ContextServerRegistryEvent::ToolsChanged => {}
+            ContextServerRegistryEvent::PromptsChanged => {
+                self.update_available_commands(cx);
+            }
+        }
+    }
+
+    fn update_available_commands(&self, cx: &mut Context<Self>) {
+        let available_commands = self.build_available_commands(cx);
+        for session in self.sessions.values() {
+            if let Some(acp_thread) = session.acp_thread.upgrade() {
+                acp_thread.update(cx, |thread, cx| {
+                    thread
+                        .handle_session_update(
+                            acp::SessionUpdate::AvailableCommandsUpdate(
+                                acp::AvailableCommandsUpdate::new(available_commands.clone()),
+                            ),
+                            cx,
+                        )
+                        .log_err();
+                });
+            }
+        }
+    }
+
+    fn build_available_commands(&self, cx: &App) -> Vec<acp::AvailableCommand> {
+        let registry = self.context_server_registry.read(cx);
+
+        let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default();
+        for context_server_prompt in registry.prompts() {
+            *prompt_name_counts
+                .entry(context_server_prompt.prompt.name.as_str())
+                .or_insert(0) += 1;
+        }
+
+        registry
+            .prompts()
+            .flat_map(|context_server_prompt| {
+                let prompt = &context_server_prompt.prompt;
+
+                let should_prefix = prompt_name_counts
+                    .get(prompt.name.as_str())
+                    .copied()
+                    .unwrap_or(0)
+                    > 1;
+
+                let name = if should_prefix {
+                    format!("{}.{}", context_server_prompt.server_id, prompt.name)
+                } else {
+                    prompt.name.clone()
+                };
+
+                let mut command = acp::AvailableCommand::new(
+                    name,
+                    prompt.description.clone().unwrap_or_default(),
+                );
+
+                match prompt.arguments.as_deref() {
+                    Some([arg]) => {
+                        let hint = format!("<{}>", arg.name);
+
+                        command = command.input(acp::AvailableCommandInput::Unstructured(
+                            acp::UnstructuredCommandInput::new(hint),
+                        ));
+                    }
+                    Some([]) | None => {}
+                    Some(_) => {
+                        // skip >1 argument commands since we don't support them yet
+                        return None;
+                    }
+                }
+
+                Some(command)
+            })
+            .collect()
+    }
+
     pub fn load_thread(
         &mut self,
         id: acp::SessionId,
@@ -720,6 +814,102 @@ impl NativeAgent {
             history.update(cx, |history, cx| history.reload(cx)).ok();
         });
     }
+
+    fn send_mcp_prompt(
+        &self,
+        message_id: UserMessageId,
+        session_id: agent_client_protocol::SessionId,
+        prompt_name: String,
+        server_id: ContextServerId,
+        arguments: HashMap<String, String>,
+        original_content: Vec<acp::ContentBlock>,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<acp::PromptResponse>> {
+        let server_store = self.context_server_registry.read(cx).server_store().clone();
+        let path_style = self.project.read(cx).path_style(cx);
+
+        cx.spawn(async move |this, cx| {
+            let prompt =
+                crate::get_prompt(&server_store, &server_id, &prompt_name, arguments, cx).await?;
+
+            let (acp_thread, thread) = this.update(cx, |this, _cx| {
+                let session = this
+                    .sessions
+                    .get(&session_id)
+                    .context("Failed to get session")?;
+                anyhow::Ok((session.acp_thread.clone(), session.thread.clone()))
+            })??;
+
+            let mut last_is_user = true;
+
+            thread.update(cx, |thread, cx| {
+                thread.push_acp_user_block(
+                    message_id,
+                    original_content.into_iter().skip(1),
+                    path_style,
+                    cx,
+                );
+            })?;
+
+            for message in prompt.messages {
+                let context_server::types::PromptMessage { role, content } = message;
+                let block = mcp_message_content_to_acp_content_block(content);
+
+                match role {
+                    context_server::types::Role::User => {
+                        let id = acp_thread::UserMessageId::new();
+
+                        acp_thread.update(cx, |acp_thread, cx| {
+                            acp_thread.push_user_content_block_with_indent(
+                                Some(id.clone()),
+                                block.clone(),
+                                true,
+                                cx,
+                            );
+                            anyhow::Ok(())
+                        })??;
+
+                        thread.update(cx, |thread, cx| {
+                            thread.push_acp_user_block(id, [block], path_style, cx);
+                            anyhow::Ok(())
+                        })??;
+                    }
+                    context_server::types::Role::Assistant => {
+                        acp_thread.update(cx, |acp_thread, cx| {
+                            acp_thread.push_assistant_content_block_with_indent(
+                                block.clone(),
+                                false,
+                                true,
+                                cx,
+                            );
+                            anyhow::Ok(())
+                        })??;
+
+                        thread.update(cx, |thread, cx| {
+                            thread.push_acp_agent_block(block, cx);
+                            anyhow::Ok(())
+                        })??;
+                    }
+                }
+
+                last_is_user = role == context_server::types::Role::User;
+            }
+
+            let response_stream = thread.update(cx, |thread, cx| {
+                if last_is_user {
+                    thread.send_existing(cx)
+                } else {
+                    // Resume if MCP prompt did not end with a user message
+                    thread.resume(cx)
+                }
+            })??;
+
+            cx.update(|cx| {
+                NativeAgentConnection::handle_thread_events(response_stream, acp_thread, cx)
+            })?
+            .await
+        })
+    }
 }
 
 /// Wrapper struct that implements the AgentConnection trait
@@ -854,6 +1044,39 @@ impl NativeAgentConnection {
     }
 }
 
+struct Command<'a> {
+    prompt_name: &'a str,
+    arg_value: &'a str,
+    explicit_server_id: Option<&'a str>,
+}
+
+impl<'a> Command<'a> {
+    fn parse(prompt: &'a [acp::ContentBlock]) -> Option<Self> {
+        let acp::ContentBlock::Text(text_content) = prompt.first()? else {
+            return None;
+        };
+        let text = text_content.text.trim();
+        let command = text.strip_prefix('/')?;
+        let (command, arg_value) = command
+            .split_once(char::is_whitespace)
+            .unwrap_or((command, ""));
+
+        if let Some((server_id, prompt_name)) = command.split_once('.') {
+            Some(Self {
+                prompt_name,
+                arg_value,
+                explicit_server_id: Some(server_id),
+            })
+        } else {
+            Some(Self {
+                prompt_name: command,
+                arg_value,
+                explicit_server_id: None,
+            })
+        }
+    }
+}
+
 struct NativeAgentModelSelector {
     session_id: acp::SessionId,
     connection: NativeAgentConnection,
@@ -944,6 +1167,10 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
     fn should_render_footer(&self) -> bool {
         true
     }
+
+    fn supports_favorites(&self) -> bool {
+        true
+    }
 }
 
 impl acp_thread::AgentConnection for NativeAgentConnection {
@@ -1019,6 +1246,47 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
         let session_id = params.session_id.clone();
         log::info!("Received prompt request for session: {}", session_id);
         log::debug!("Prompt blocks count: {}", params.prompt.len());
+
+        if let Some(parsed_command) = Command::parse(&params.prompt) {
+            let registry = self.0.read(cx).context_server_registry.read(cx);
+
+            let explicit_server_id = parsed_command
+                .explicit_server_id
+                .map(|server_id| ContextServerId(server_id.into()));
+
+            if let Some(prompt) =
+                registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name)
+            {
+                let arguments = if !parsed_command.arg_value.is_empty()
+                    && let Some(arg_name) = prompt
+                        .prompt
+                        .arguments
+                        .as_ref()
+                        .and_then(|args| args.first())
+                        .map(|arg| arg.name.clone())
+                {
+                    HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())])
+                } else {
+                    Default::default()
+                };
+
+                let prompt_name = prompt.prompt.name.clone();
+                let server_id = prompt.server_id.clone();
+
+                return self.0.update(cx, |agent, cx| {
+                    agent.send_mcp_prompt(
+                        id,
+                        session_id.clone(),
+                        prompt_name,
+                        server_id,
+                        arguments,
+                        params.prompt,
+                        cx,
+                    )
+                });
+            };
+        };
+
         let path_style = self.0.read(cx).project.read(cx).path_style(cx);
 
         self.run_turn(session_id, cx, move |thread, cx| {
@@ -1365,7 +1633,9 @@ mod internal_tests {
                     id: acp::ModelId::new("fake/fake"),
                     name: "Fake".into(),
                     description: None,
-                    icon: Some(ui::IconName::ZedAssistant),
+                    icon: Some(acp_thread::AgentModelIcon::Named(
+                        ui::IconName::ZedAssistant
+                    )),
                 }]
             )])
         );
@@ -1615,3 +1885,35 @@ mod internal_tests {
         });
     }
 }
+
+fn mcp_message_content_to_acp_content_block(
+    content: context_server::types::MessageContent,
+) -> acp::ContentBlock {
+    match content {
+        context_server::types::MessageContent::Text {
+            text,
+            annotations: _,
+        } => text.into(),
+        context_server::types::MessageContent::Image {
+            data,
+            mime_type,
+            annotations: _,
+        } => acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)),
+        context_server::types::MessageContent::Audio {
+            data,
+            mime_type,
+            annotations: _,
+        } => acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)),
+        context_server::types::MessageContent::Resource {
+            resource,
+            annotations: _,
+        } => {
+            let mut link =
+                acp::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string());
+            if let Some(mime_type) = resource.mime_type {
+                link = link.mime_type(mime_type);
+            }
+            acp::ContentBlock::ResourceLink(link)
+        }
+    }
+}

crates/agent/src/history_store.rs 🔗

@@ -216,14 +216,10 @@ impl HistoryStore {
     }
 
     pub fn reload(&self, cx: &mut Context<Self>) {
-        let database_future = ThreadsDatabase::connect(cx);
+        let database_connection = ThreadsDatabase::connect(cx);
         cx.spawn(async move |this, cx| {
-            let threads = database_future
-                .await
-                .map_err(|err| anyhow!(err))?
-                .list_threads()
-                .await?;
-
+            let database = database_connection.await;
+            let threads = database.map_err(|err| anyhow!(err))?.list_threads().await?;
             this.update(cx, |this, cx| {
                 if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES {
                     for thread in threads
@@ -344,7 +340,8 @@ impl HistoryStore {
     fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<VecDeque<HistoryEntryId>>> {
         cx.background_spawn(async move {
             if cfg!(any(feature = "test-support", test)) {
-                anyhow::bail!("history store does not persist in tests");
+                log::warn!("history store does not persist in tests");
+                return Ok(VecDeque::new());
             }
             let json = KEY_VALUE_STORE
                 .read_kvp(RECENTLY_OPENED_THREADS_KEY)?

crates/agent/src/tests/mod.rs 🔗

@@ -2809,3 +2809,181 @@ fn setup_context_server(
     cx.run_until_parked();
     mcp_tool_calls_rx
 }
+
+#[gpui::test]
+async fn test_tokens_before_message(cx: &mut TestAppContext) {
+    let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+    let fake_model = model.as_fake();
+
+    // First message
+    let message_1_id = UserMessageId::new();
+    thread
+        .update(cx, |thread, cx| {
+            thread.send(message_1_id.clone(), ["First message"], cx)
+        })
+        .unwrap();
+    cx.run_until_parked();
+
+    // Before any response, tokens_before_message should return None for first message
+    thread.read_with(cx, |thread, _| {
+        assert_eq!(
+            thread.tokens_before_message(&message_1_id),
+            None,
+            "First message should have no tokens before it"
+        );
+    });
+
+    // Complete first message with usage
+    fake_model.send_last_completion_stream_text_chunk("Response 1");
+    fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+        language_model::TokenUsage {
+            input_tokens: 100,
+            output_tokens: 50,
+            cache_creation_input_tokens: 0,
+            cache_read_input_tokens: 0,
+        },
+    ));
+    fake_model.end_last_completion_stream();
+    cx.run_until_parked();
+
+    // First message still has no tokens before it
+    thread.read_with(cx, |thread, _| {
+        assert_eq!(
+            thread.tokens_before_message(&message_1_id),
+            None,
+            "First message should still have no tokens before it after response"
+        );
+    });
+
+    // Second message
+    let message_2_id = UserMessageId::new();
+    thread
+        .update(cx, |thread, cx| {
+            thread.send(message_2_id.clone(), ["Second message"], cx)
+        })
+        .unwrap();
+    cx.run_until_parked();
+
+    // Second message should have first message's input tokens before it
+    thread.read_with(cx, |thread, _| {
+        assert_eq!(
+            thread.tokens_before_message(&message_2_id),
+            Some(100),
+            "Second message should have 100 tokens before it (from first request)"
+        );
+    });
+
+    // Complete second message
+    fake_model.send_last_completion_stream_text_chunk("Response 2");
+    fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+        language_model::TokenUsage {
+            input_tokens: 250, // Total for this request (includes previous context)
+            output_tokens: 75,
+            cache_creation_input_tokens: 0,
+            cache_read_input_tokens: 0,
+        },
+    ));
+    fake_model.end_last_completion_stream();
+    cx.run_until_parked();
+
+    // Third message
+    let message_3_id = UserMessageId::new();
+    thread
+        .update(cx, |thread, cx| {
+            thread.send(message_3_id.clone(), ["Third message"], cx)
+        })
+        .unwrap();
+    cx.run_until_parked();
+
+    // Third message should have second message's input tokens (250) before it
+    thread.read_with(cx, |thread, _| {
+        assert_eq!(
+            thread.tokens_before_message(&message_3_id),
+            Some(250),
+            "Third message should have 250 tokens before it (from second request)"
+        );
+        // Second message should still have 100
+        assert_eq!(
+            thread.tokens_before_message(&message_2_id),
+            Some(100),
+            "Second message should still have 100 tokens before it"
+        );
+        // First message still has none
+        assert_eq!(
+            thread.tokens_before_message(&message_1_id),
+            None,
+            "First message should still have no tokens before it"
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_tokens_before_message_after_truncate(cx: &mut TestAppContext) {
+    let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
+    let fake_model = model.as_fake();
+
+    // Set up three messages with responses
+    let message_1_id = UserMessageId::new();
+    thread
+        .update(cx, |thread, cx| {
+            thread.send(message_1_id.clone(), ["Message 1"], cx)
+        })
+        .unwrap();
+    cx.run_until_parked();
+    fake_model.send_last_completion_stream_text_chunk("Response 1");
+    fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+        language_model::TokenUsage {
+            input_tokens: 100,
+            output_tokens: 50,
+            cache_creation_input_tokens: 0,
+            cache_read_input_tokens: 0,
+        },
+    ));
+    fake_model.end_last_completion_stream();
+    cx.run_until_parked();
+
+    let message_2_id = UserMessageId::new();
+    thread
+        .update(cx, |thread, cx| {
+            thread.send(message_2_id.clone(), ["Message 2"], cx)
+        })
+        .unwrap();
+    cx.run_until_parked();
+    fake_model.send_last_completion_stream_text_chunk("Response 2");
+    fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
+        language_model::TokenUsage {
+            input_tokens: 250,
+            output_tokens: 75,
+            cache_creation_input_tokens: 0,
+            cache_read_input_tokens: 0,
+        },
+    ));
+    fake_model.end_last_completion_stream();
+    cx.run_until_parked();
+
+    // Verify initial state
+    thread.read_with(cx, |thread, _| {
+        assert_eq!(thread.tokens_before_message(&message_2_id), Some(100));
+    });
+
+    // Truncate at message 2 (removes message 2 and everything after)
+    thread
+        .update(cx, |thread, cx| thread.truncate(message_2_id.clone(), cx))
+        .unwrap();
+    cx.run_until_parked();
+
+    // After truncation, message_2_id no longer exists, so lookup should return None
+    thread.read_with(cx, |thread, _| {
+        assert_eq!(
+            thread.tokens_before_message(&message_2_id),
+            None,
+            "After truncation, message 2 no longer exists"
+        );
+        // Message 1 still exists but has no tokens before it
+        assert_eq!(
+            thread.tokens_before_message(&message_1_id),
+            None,
+            "First message still has no tokens before it"
+        );
+    });
+}

crates/agent/src/thread.rs 🔗

@@ -2,7 +2,8 @@ use crate::{
     ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
     DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
     ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
-    SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool,
+    RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool,
+    ThinkingTool, WebSearchTool,
 };
 use acp_thread::{MentionUri, UserMessageId};
 use action_log::ActionLog;
@@ -107,7 +108,13 @@ impl Message {
 
     pub fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
         match self {
-            Message::User(message) => vec![message.to_request()],
+            Message::User(message) => {
+                if message.content.is_empty() {
+                    vec![]
+                } else {
+                    vec![message.to_request()]
+                }
+            }
             Message::Agent(message) => message.to_request(),
             Message::Resume => vec![LanguageModelRequestMessage {
                 role: Role::User,
@@ -1002,6 +1009,8 @@ impl Thread {
             self.project.clone(),
             self.action_log.clone(),
         ));
+        self.add_tool(SaveFileTool::new(self.project.clone()));
+        self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
         self.add_tool(TerminalTool::new(self.project.clone(), environment));
         self.add_tool(ThinkingTool);
         self.add_tool(WebSearchTool);
@@ -1086,6 +1095,28 @@ impl Thread {
         })
     }
 
+    /// Get the total input token count as of the message before the given message.
+    ///
+    /// Returns `None` if:
+    /// - `target_id` is the first message (no previous message)
+    /// - The previous message hasn't received a response yet (no usage data)
+    /// - `target_id` is not found in the messages
+    pub fn tokens_before_message(&self, target_id: &UserMessageId) -> Option<u64> {
+        let mut previous_user_message_id: Option<&UserMessageId> = None;
+
+        for message in &self.messages {
+            if let Message::User(user_msg) = message {
+                if &user_msg.id == target_id {
+                    let prev_id = previous_user_message_id?;
+                    let usage = self.request_token_usage.get(prev_id)?;
+                    return Some(usage.input_tokens);
+                }
+                previous_user_message_id = Some(&user_msg.id);
+            }
+        }
+        None
+    }
+
     /// Look up the active profile and resolve its preferred model if one is configured.
     fn resolve_profile_model(
         profile_id: &AgentProfileId,
@@ -1138,20 +1169,64 @@ impl Thread {
     where
         T: Into<UserMessageContent>,
     {
+        let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
+        log::debug!("Thread::send content: {:?}", content);
+
+        self.messages
+            .push(Message::User(UserMessage { id, content }));
+        cx.notify();
+
+        self.send_existing(cx)
+    }
+
+    pub fn send_existing(
+        &mut self,
+        cx: &mut Context<Self>,
+    ) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
         let model = self.model().context("No language model configured")?;
 
         log::info!("Thread::send called with model: {}", model.name().0);
         self.advance_prompt_id();
 
-        let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
-        log::debug!("Thread::send content: {:?}", content);
+        log::debug!("Total messages in thread: {}", self.messages.len());
+        self.run_turn(cx)
+    }
 
+    pub fn push_acp_user_block(
+        &mut self,
+        id: UserMessageId,
+        blocks: impl IntoIterator<Item = acp::ContentBlock>,
+        path_style: PathStyle,
+        cx: &mut Context<Self>,
+    ) {
+        let content = blocks
+            .into_iter()
+            .map(|block| UserMessageContent::from_content_block(block, path_style))
+            .collect::<Vec<_>>();
         self.messages
             .push(Message::User(UserMessage { id, content }));
         cx.notify();
+    }
 
-        log::debug!("Total messages in thread: {}", self.messages.len());
-        self.run_turn(cx)
+    pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context<Self>) {
+        let text = match block {
+            acp::ContentBlock::Text(text_content) => text_content.text,
+            acp::ContentBlock::Image(_) => "[image]".to_string(),
+            acp::ContentBlock::Audio(_) => "[audio]".to_string(),
+            acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri,
+            acp::ContentBlock::Resource(resource) => match resource.resource {
+                acp::EmbeddedResourceResource::TextResourceContents(resource) => resource.uri,
+                acp::EmbeddedResourceResource::BlobResourceContents(resource) => resource.uri,
+                _ => "[resource]".to_string(),
+            },
+            _ => "[unknown]".to_string(),
+        };
+
+        self.messages.push(Message::Agent(AgentMessage {
+            content: vec![AgentMessageContent::Text(text)],
+            ..Default::default()
+        }));
+        cx.notify();
     }
 
     #[cfg(feature = "eval")]
@@ -1650,6 +1725,10 @@ impl Thread {
         self.pending_summary_generation.is_some()
     }
 
+    pub fn is_generating_title(&self) -> bool {
+        self.pending_title_generation.is_some()
+    }
+
     pub fn summary(&mut self, cx: &mut Context<Self>) -> Shared<Task<Option<SharedString>>> {
         if let Some(summary) = self.summary.as_ref() {
             return Task::ready(Some(summary.clone())).shared();
@@ -1717,7 +1796,7 @@ impl Thread {
         task
     }
 
-    fn generate_title(&mut self, cx: &mut Context<Self>) {
+    pub fn generate_title(&mut self, cx: &mut Context<Self>) {
         let Some(model) = self.summarization_model.clone() else {
             return;
         };
@@ -1966,6 +2045,12 @@ impl Thread {
         self.running_turn.as_ref()?.tools.get(name).cloned()
     }
 
+    pub fn has_tool(&self, name: &str) -> bool {
+        self.running_turn
+            .as_ref()
+            .is_some_and(|turn| turn.tools.contains_key(name))
+    }
+
     fn build_request_messages(
         &self,
         available_tools: Vec<SharedString>,
@@ -2659,7 +2744,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.rs 🔗

@@ -4,7 +4,6 @@ mod create_directory_tool;
 mod delete_path_tool;
 mod diagnostics_tool;
 mod edit_file_tool;
-
 mod fetch_tool;
 mod find_path_tool;
 mod grep_tool;
@@ -13,6 +12,8 @@ mod move_path_tool;
 mod now_tool;
 mod open_tool;
 mod read_file_tool;
+mod restore_file_from_disk_tool;
+mod save_file_tool;
 
 mod terminal_tool;
 mod thinking_tool;
@@ -27,7 +28,6 @@ pub use create_directory_tool::*;
 pub use delete_path_tool::*;
 pub use diagnostics_tool::*;
 pub use edit_file_tool::*;
-
 pub use fetch_tool::*;
 pub use find_path_tool::*;
 pub use grep_tool::*;
@@ -36,6 +36,8 @@ pub use move_path_tool::*;
 pub use now_tool::*;
 pub use open_tool::*;
 pub use read_file_tool::*;
+pub use restore_file_from_disk_tool::*;
+pub use save_file_tool::*;
 
 pub use terminal_tool::*;
 pub use thinking_tool::*;
@@ -92,6 +94,8 @@ tools! {
     NowTool,
     OpenTool,
     ReadFileTool,
+    RestoreFileFromDiskTool,
+    SaveFileTool,
     TerminalTool,
     ThinkingTool,
     WebSearchTool,

crates/agent/src/tools/context_server_registry.rs 🔗

@@ -2,12 +2,24 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream};
 use agent_client_protocol::ToolKind;
 use anyhow::{Result, anyhow, bail};
 use collections::{BTreeMap, HashMap};
-use context_server::ContextServerId;
-use gpui::{App, Context, Entity, SharedString, Task};
+use context_server::{ContextServerId, client::NotificationSubscription};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task};
 use project::context_server_store::{ContextServerStatus, ContextServerStore};
 use std::sync::Arc;
 use util::ResultExt;
 
+pub struct ContextServerPrompt {
+    pub server_id: ContextServerId,
+    pub prompt: context_server::types::Prompt,
+}
+
+pub enum ContextServerRegistryEvent {
+    ToolsChanged,
+    PromptsChanged,
+}
+
+impl EventEmitter<ContextServerRegistryEvent> for ContextServerRegistry {}
+
 pub struct ContextServerRegistry {
     server_store: Entity<ContextServerStore>,
     registered_servers: HashMap<ContextServerId, RegisteredContextServer>,
@@ -16,7 +28,10 @@ pub struct ContextServerRegistry {
 
 struct RegisteredContextServer {
     tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
+    prompts: BTreeMap<SharedString, ContextServerPrompt>,
     load_tools: Task<Result<()>>,
+    load_prompts: Task<Result<()>>,
+    _tools_updated_subscription: Option<NotificationSubscription>,
 }
 
 impl ContextServerRegistry {
@@ -28,6 +43,7 @@ impl ContextServerRegistry {
         };
         for server in server_store.read(cx).running_servers() {
             this.reload_tools_for_server(server.id(), cx);
+            this.reload_prompts_for_server(server.id(), cx);
         }
         this
     }
@@ -56,6 +72,88 @@ impl ContextServerRegistry {
             .map(|(id, server)| (id, &server.tools))
     }
 
+    pub fn prompts(&self) -> impl Iterator<Item = &ContextServerPrompt> {
+        self.registered_servers
+            .values()
+            .flat_map(|server| server.prompts.values())
+    }
+
+    pub fn find_prompt(
+        &self,
+        server_id: Option<&ContextServerId>,
+        name: &str,
+    ) -> Option<&ContextServerPrompt> {
+        if let Some(server_id) = server_id {
+            self.registered_servers
+                .get(server_id)
+                .and_then(|server| server.prompts.get(name))
+        } else {
+            self.registered_servers
+                .values()
+                .find_map(|server| server.prompts.get(name))
+        }
+    }
+
+    pub fn server_store(&self) -> &Entity<ContextServerStore> {
+        &self.server_store
+    }
+
+    fn get_or_register_server(
+        &mut self,
+        server_id: &ContextServerId,
+        cx: &mut Context<Self>,
+    ) -> &mut RegisteredContextServer {
+        self.registered_servers
+            .entry(server_id.clone())
+            .or_insert_with(|| Self::init_registered_server(server_id, &self.server_store, cx))
+    }
+
+    fn init_registered_server(
+        server_id: &ContextServerId,
+        server_store: &Entity<ContextServerStore>,
+        cx: &mut Context<Self>,
+    ) -> RegisteredContextServer {
+        let tools_updated_subscription = server_store
+            .read(cx)
+            .get_running_server(server_id)
+            .and_then(|server| {
+                let client = server.client()?;
+
+                if !client.capable(context_server::protocol::ServerCapability::Tools) {
+                    return None;
+                }
+
+                let server_id = server.id();
+                let this = cx.entity().downgrade();
+
+                Some(client.on_notification(
+                    "notifications/tools/list_changed",
+                    Box::new(move |_params, cx: AsyncApp| {
+                        let server_id = server_id.clone();
+                        let this = this.clone();
+                        cx.spawn(async move |cx| {
+                            this.update(cx, |this, cx| {
+                                log::info!(
+                                    "Received tools/list_changed notification for server {}",
+                                    server_id
+                                );
+                                this.reload_tools_for_server(server_id, cx);
+                            })
+                        })
+                        .detach();
+                    }),
+                ))
+            });
+
+        RegisteredContextServer {
+            tools: BTreeMap::default(),
+            prompts: BTreeMap::default(),
+            load_tools: Task::ready(Ok(())),
+            load_prompts: Task::ready(Ok(())),
+            _tools_updated_subscription: tools_updated_subscription,
+        }
+    }
+
     fn reload_tools_for_server(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
         let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
             return;
@@ -63,17 +161,12 @@ impl ContextServerRegistry {
         let Some(client) = server.client() else {
             return;
         };
+
         if !client.capable(context_server::protocol::ServerCapability::Tools) {
             return;
         }
 
-        let registered_server =
-            self.registered_servers
-                .entry(server_id.clone())
-                .or_insert(RegisteredContextServer {
-                    tools: BTreeMap::default(),
-                    load_tools: Task::ready(Ok(())),
-                });
+        let registered_server = self.get_or_register_server(&server_id, cx);
         registered_server.load_tools = cx.spawn(async move |this, cx| {
             let response = client
                 .request::<context_server::types::requests::ListTools>(())
@@ -94,6 +187,49 @@ impl ContextServerRegistry {
                         ));
                         registered_server.tools.insert(tool.name(), tool);
                     }
+                    cx.emit(ContextServerRegistryEvent::ToolsChanged);
+                    cx.notify();
+                }
+            })
+        });
+    }
+
+    fn reload_prompts_for_server(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
+        let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
+            return;
+        };
+        let Some(client) = server.client() else {
+            return;
+        };
+        if !client.capable(context_server::protocol::ServerCapability::Prompts) {
+            return;
+        }
+
+        let registered_server = self.get_or_register_server(&server_id, cx);
+
+        registered_server.load_prompts = cx.spawn(async move |this, cx| {
+            let response = client
+                .request::<context_server::types::requests::PromptsList>(())
+                .await;
+
+            this.update(cx, |this, cx| {
+                let Some(registered_server) = this.registered_servers.get_mut(&server_id) else {
+                    return;
+                };
+
+                registered_server.prompts.clear();
+                if let Some(response) = response.log_err() {
+                    for prompt in response.prompts {
+                        let name: SharedString = prompt.name.clone().into();
+                        registered_server.prompts.insert(
+                            name,
+                            ContextServerPrompt {
+                                server_id: server_id.clone(),
+                                prompt,
+                            },
+                        );
+                    }
+                    cx.emit(ContextServerRegistryEvent::PromptsChanged);
                     cx.notify();
                 }
             })
@@ -112,9 +248,17 @@ impl ContextServerRegistry {
                     ContextServerStatus::Starting => {}
                     ContextServerStatus::Running => {
                         self.reload_tools_for_server(server_id.clone(), cx);
+                        self.reload_prompts_for_server(server_id.clone(), cx);
                     }
                     ContextServerStatus::Stopped | ContextServerStatus::Error(_) => {
-                        self.registered_servers.remove(server_id);
+                        if let Some(registered_server) = self.registered_servers.remove(server_id) {
+                            if !registered_server.tools.is_empty() {
+                                cx.emit(ContextServerRegistryEvent::ToolsChanged);
+                            }
+                            if !registered_server.prompts.is_empty() {
+                                cx.emit(ContextServerRegistryEvent::PromptsChanged);
+                            }
+                        }
                         cx.notify();
                     }
                 }
@@ -251,3 +395,39 @@ impl AnyAgentTool for ContextServerTool {
         Ok(())
     }
 }
+
+pub fn get_prompt(
+    server_store: &Entity<ContextServerStore>,
+    server_id: &ContextServerId,
+    prompt_name: &str,
+    arguments: HashMap<String, String>,
+    cx: &mut AsyncApp,
+) -> Task<Result<context_server::types::PromptsGetResponse>> {
+    let server = match cx.update(|cx| server_store.read(cx).get_running_server(server_id)) {
+        Ok(server) => server,
+        Err(error) => return Task::ready(Err(error)),
+    };
+    let Some(server) = server else {
+        return Task::ready(Err(anyhow::anyhow!("Context server not found")));
+    };
+
+    let Some(protocol) = server.client() else {
+        return Task::ready(Err(anyhow::anyhow!("Context server not initialized")));
+    };
+
+    let prompt_name = prompt_name.to_string();
+
+    cx.background_spawn(async move {
+        let response = protocol
+            .request::<context_server::types::requests::PromptsGet>(
+                context_server::types::PromptsGetParams {
+                    name: prompt_name,
+                    arguments: (!arguments.is_empty()).then(|| arguments),
+                    meta: None,
+                },
+            )
+            .await?;
+
+        Ok(response)
+    })
+}

crates/agent/src/tools/edit_file_tool.rs 🔗

@@ -306,20 +306,39 @@ impl AgentTool for EditFileTool {
 
             // Check if the file has been modified since the agent last read it
             if let Some(abs_path) = abs_path.as_ref() {
-                let (last_read_mtime, current_mtime, is_dirty) = self.thread.update(cx, |thread, cx| {
+                let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
                     let last_read = thread.file_read_times.get(abs_path).copied();
                     let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
                     let dirty = buffer.read(cx).is_dirty();
-                    (last_read, current, dirty)
+                    let has_save = thread.has_tool("save_file");
+                    let has_restore = thread.has_tool("restore_file_from_disk");
+                    (last_read, current, dirty, has_save, has_restore)
                 })?;
 
                 // Check for unsaved changes first - these indicate modifications we don't know about
                 if is_dirty {
-                    anyhow::bail!(
-                        "This file cannot be written to because it has unsaved changes. \
-                         Please end the current conversation immediately by telling the user you want to write to this file (mention its path explicitly) but you can't write to it because it has unsaved changes. \
-                         Ask the user to save that buffer's changes and to inform you when it's ok to proceed."
-                    );
+                    let message = match (has_save_tool, has_restore_tool) {
+                        (true, true) => {
+                            "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+                             If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
+                             If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
+                        }
+                        (true, false) => {
+                            "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+                             If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
+                             If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
+                        }
+                        (false, true) => {
+                            "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
+                             If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
+                             If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
+                        }
+                        (false, false) => {
+                            "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
+                             then ask them to save or revert the file manually and inform you when it's ok to proceed."
+                        }
+                    };
+                    anyhow::bail!("{}", message);
                 }
 
                 // Check if the file was modified on disk since we last read it
@@ -2202,9 +2221,21 @@ mod tests {
         assert!(result.is_err(), "Edit should fail when buffer is dirty");
         let error_msg = result.unwrap_err().to_string();
         assert!(
-            error_msg.contains("cannot be written to because it has unsaved changes"),
+            error_msg.contains("This file has unsaved changes."),
             "Error should mention unsaved changes, got: {}",
             error_msg
         );
+        assert!(
+            error_msg.contains("keep or discard"),
+            "Error should ask whether to keep or discard changes, got: {}",
+            error_msg
+        );
+        // Since save_file and restore_file_from_disk tools aren't added to the thread,
+        // the error message should ask the user to manually save or revert
+        assert!(
+            error_msg.contains("save or revert the file manually"),
+            "Error should ask user to manually save or revert when tools aren't available, got: {}",
+            error_msg
+        );
     }
 }

crates/agent/src/tools/restore_file_from_disk_tool.rs 🔗

@@ -0,0 +1,352 @@
+use agent_client_protocol as acp;
+use anyhow::Result;
+use collections::FxHashSet;
+use gpui::{App, Entity, SharedString, Task};
+use language::Buffer;
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Discards unsaved changes in open buffers by reloading file contents from disk.
+///
+/// Use this tool when:
+/// - You attempted to edit files but they have unsaved changes the user does not want to keep.
+/// - You want to reset files to the on-disk state before retrying an edit.
+///
+/// Only use this tool after asking the user for permission, because it will discard unsaved changes.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct RestoreFileFromDiskToolInput {
+    /// The paths of the files to restore from disk.
+    pub paths: Vec<PathBuf>,
+}
+
+pub struct RestoreFileFromDiskTool {
+    project: Entity<Project>,
+}
+
+impl RestoreFileFromDiskTool {
+    pub fn new(project: Entity<Project>) -> Self {
+        Self { project }
+    }
+}
+
+impl AgentTool for RestoreFileFromDiskTool {
+    type Input = RestoreFileFromDiskToolInput;
+    type Output = String;
+
+    fn name() -> &'static str {
+        "restore_file_from_disk"
+    }
+
+    fn kind() -> acp::ToolKind {
+        acp::ToolKind::Other
+    }
+
+    fn initial_title(
+        &self,
+        input: Result<Self::Input, serde_json::Value>,
+        _cx: &mut App,
+    ) -> SharedString {
+        match input {
+            Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(),
+            Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(),
+            Err(_) => "Restore files from disk".into(),
+        }
+    }
+
+    fn run(
+        self: Arc<Self>,
+        input: Self::Input,
+        _event_stream: ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<String>> {
+        let project = self.project.clone();
+        let input_paths = input.paths;
+
+        cx.spawn(async move |cx| {
+            let mut buffers_to_reload: FxHashSet<Entity<Buffer>> = FxHashSet::default();
+
+            let mut restored_paths: Vec<PathBuf> = Vec::new();
+            let mut clean_paths: Vec<PathBuf> = Vec::new();
+            let mut not_found_paths: Vec<PathBuf> = Vec::new();
+            let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
+            let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
+            let mut reload_errors: Vec<String> = Vec::new();
+
+            for path in input_paths {
+                let project_path =
+                    project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
+
+                let project_path = match project_path {
+                    Ok(Some(project_path)) => project_path,
+                    Ok(None) => {
+                        not_found_paths.push(path);
+                        continue;
+                    }
+                    Err(error) => {
+                        open_errors.push((path, error.to_string()));
+                        continue;
+                    }
+                };
+
+                let open_buffer_task =
+                    project.update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+                let buffer = match open_buffer_task {
+                    Ok(task) => match task.await {
+                        Ok(buffer) => buffer,
+                        Err(error) => {
+                            open_errors.push((path, error.to_string()));
+                            continue;
+                        }
+                    },
+                    Err(error) => {
+                        open_errors.push((path, error.to_string()));
+                        continue;
+                    }
+                };
+
+                let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
+                    Ok(is_dirty) => is_dirty,
+                    Err(error) => {
+                        dirty_check_errors.push((path, error.to_string()));
+                        continue;
+                    }
+                };
+
+                if is_dirty {
+                    buffers_to_reload.insert(buffer);
+                    restored_paths.push(path);
+                } else {
+                    clean_paths.push(path);
+                }
+            }
+
+            if !buffers_to_reload.is_empty() {
+                let reload_task = project.update(cx, |project, cx| {
+                    project.reload_buffers(buffers_to_reload, true, cx)
+                });
+
+                match reload_task {
+                    Ok(task) => {
+                        if let Err(error) = task.await {
+                            reload_errors.push(error.to_string());
+                        }
+                    }
+                    Err(error) => {
+                        reload_errors.push(error.to_string());
+                    }
+                }
+            }
+
+            let mut lines: Vec<String> = Vec::new();
+
+            if !restored_paths.is_empty() {
+                lines.push(format!("Restored {} file(s).", restored_paths.len()));
+            }
+            if !clean_paths.is_empty() {
+                lines.push(format!("{} clean.", clean_paths.len()));
+            }
+
+            if !not_found_paths.is_empty() {
+                lines.push(format!("Not found ({}):", not_found_paths.len()));
+                for path in &not_found_paths {
+                    lines.push(format!("- {}", path.display()));
+                }
+            }
+            if !open_errors.is_empty() {
+                lines.push(format!("Open failed ({}):", open_errors.len()));
+                for (path, error) in &open_errors {
+                    lines.push(format!("- {}: {}", path.display(), error));
+                }
+            }
+            if !dirty_check_errors.is_empty() {
+                lines.push(format!(
+                    "Dirty check failed ({}):",
+                    dirty_check_errors.len()
+                ));
+                for (path, error) in &dirty_check_errors {
+                    lines.push(format!("- {}: {}", path.display(), error));
+                }
+            }
+            if !reload_errors.is_empty() {
+                lines.push(format!("Reload failed ({}):", reload_errors.len()));
+                for error in &reload_errors {
+                    lines.push(format!("- {}", error));
+                }
+            }
+
+            if lines.is_empty() {
+                Ok("No paths provided.".to_string())
+            } else {
+                Ok(lines.join("\n"))
+            }
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use fs::Fs;
+    use gpui::TestAppContext;
+    use language::LineEnding;
+    use project::FakeFs;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_restore_file_from_disk_output_and_effects(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/root",
+            json!({
+                "dirty.txt": "on disk: dirty\n",
+                "clean.txt": "on disk: clean\n",
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let tool = Arc::new(RestoreFileFromDiskTool::new(project.clone()));
+
+        // Make dirty.txt dirty in-memory by saving different content into the buffer without saving to disk.
+        let dirty_project_path = project.read_with(cx, |project, cx| {
+            project
+                .find_project_path("root/dirty.txt", cx)
+                .expect("dirty.txt should exist in project")
+        });
+
+        let dirty_buffer = project
+            .update(cx, |project, cx| {
+                project.open_buffer(dirty_project_path, cx)
+            })
+            .await
+            .unwrap();
+        dirty_buffer.update(cx, |buffer, cx| {
+            buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
+        });
+        assert!(
+            dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+            "dirty.txt buffer should be dirty before restore"
+        );
+
+        // Ensure clean.txt is opened but remains clean.
+        let clean_project_path = project.read_with(cx, |project, cx| {
+            project
+                .find_project_path("root/clean.txt", cx)
+                .expect("clean.txt should exist in project")
+        });
+
+        let clean_buffer = project
+            .update(cx, |project, cx| {
+                project.open_buffer(clean_project_path, cx)
+            })
+            .await
+            .unwrap();
+        assert!(
+            !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+            "clean.txt buffer should start clean"
+        );
+
+        let output = cx
+            .update(|cx| {
+                tool.clone().run(
+                    RestoreFileFromDiskToolInput {
+                        paths: vec![
+                            PathBuf::from("root/dirty.txt"),
+                            PathBuf::from("root/clean.txt"),
+                        ],
+                    },
+                    ToolCallEventStream::test().0,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        // Output should mention restored + clean.
+        assert!(
+            output.contains("Restored 1 file(s)."),
+            "expected restored count line, got:\n{output}"
+        );
+        assert!(
+            output.contains("1 clean."),
+            "expected clean count line, got:\n{output}"
+        );
+
+        // Effect: dirty buffer should be restored back to disk content and become clean.
+        let dirty_text = dirty_buffer.read_with(cx, |buffer, _| buffer.text());
+        assert_eq!(
+            dirty_text, "on disk: dirty\n",
+            "dirty.txt buffer should be restored to disk contents"
+        );
+        assert!(
+            !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+            "dirty.txt buffer should not be dirty after restore"
+        );
+
+        // Disk contents should be unchanged (restore-from-disk should not write).
+        let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
+        assert_eq!(disk_dirty, "on disk: dirty\n");
+
+        // Sanity: clean buffer should remain clean and unchanged.
+        let clean_text = clean_buffer.read_with(cx, |buffer, _| buffer.text());
+        assert_eq!(clean_text, "on disk: clean\n");
+        assert!(
+            !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+            "clean.txt buffer should remain clean"
+        );
+
+        // Test empty paths case.
+        let output = cx
+            .update(|cx| {
+                tool.clone().run(
+                    RestoreFileFromDiskToolInput { paths: vec![] },
+                    ToolCallEventStream::test().0,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        assert_eq!(output, "No paths provided.");
+
+        // Test not-found path case (path outside the project root).
+        let output = cx
+            .update(|cx| {
+                tool.clone().run(
+                    RestoreFileFromDiskToolInput {
+                        paths: vec![PathBuf::from("nonexistent/path.txt")],
+                    },
+                    ToolCallEventStream::test().0,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        assert!(
+            output.contains("Not found (1):"),
+            "expected not-found header line, got:\n{output}"
+        );
+        assert!(
+            output.contains("- nonexistent/path.txt"),
+            "expected not-found path bullet, got:\n{output}"
+        );
+
+        let _ = LineEnding::Unix; // keep import used if the buffer edit API changes
+    }
+}

crates/agent/src/tools/save_file_tool.rs 🔗

@@ -0,0 +1,351 @@
+use agent_client_protocol as acp;
+use anyhow::Result;
+use collections::FxHashSet;
+use gpui::{App, Entity, SharedString, Task};
+use language::Buffer;
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Saves files that have unsaved changes.
+///
+/// Use this tool when you need to edit files but they have unsaved changes that must be saved first.
+/// Only use this tool after asking the user for permission to save their unsaved changes.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct SaveFileToolInput {
+    /// The paths of the files to save.
+    pub paths: Vec<PathBuf>,
+}
+
+pub struct SaveFileTool {
+    project: Entity<Project>,
+}
+
+impl SaveFileTool {
+    pub fn new(project: Entity<Project>) -> Self {
+        Self { project }
+    }
+}
+
+impl AgentTool for SaveFileTool {
+    type Input = SaveFileToolInput;
+    type Output = String;
+
+    fn name() -> &'static str {
+        "save_file"
+    }
+
+    fn kind() -> acp::ToolKind {
+        acp::ToolKind::Other
+    }
+
+    fn initial_title(
+        &self,
+        input: Result<Self::Input, serde_json::Value>,
+        _cx: &mut App,
+    ) -> SharedString {
+        match input {
+            Ok(input) if input.paths.len() == 1 => "Save file".into(),
+            Ok(input) => format!("Save {} files", input.paths.len()).into(),
+            Err(_) => "Save files".into(),
+        }
+    }
+
+    fn run(
+        self: Arc<Self>,
+        input: Self::Input,
+        _event_stream: ToolCallEventStream,
+        cx: &mut App,
+    ) -> Task<Result<String>> {
+        let project = self.project.clone();
+        let input_paths = input.paths;
+
+        cx.spawn(async move |cx| {
+            let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
+
+            let mut saved_paths: Vec<PathBuf> = Vec::new();
+            let mut clean_paths: Vec<PathBuf> = Vec::new();
+            let mut not_found_paths: Vec<PathBuf> = Vec::new();
+            let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
+            let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
+            let mut save_errors: Vec<(String, String)> = Vec::new();
+
+            for path in input_paths {
+                let project_path =
+                    project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
+
+                let project_path = match project_path {
+                    Ok(Some(project_path)) => project_path,
+                    Ok(None) => {
+                        not_found_paths.push(path);
+                        continue;
+                    }
+                    Err(error) => {
+                        open_errors.push((path, error.to_string()));
+                        continue;
+                    }
+                };
+
+                let open_buffer_task =
+                    project.update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+                let buffer = match open_buffer_task {
+                    Ok(task) => match task.await {
+                        Ok(buffer) => buffer,
+                        Err(error) => {
+                            open_errors.push((path, error.to_string()));
+                            continue;
+                        }
+                    },
+                    Err(error) => {
+                        open_errors.push((path, error.to_string()));
+                        continue;
+                    }
+                };
+
+                let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
+                    Ok(is_dirty) => is_dirty,
+                    Err(error) => {
+                        dirty_check_errors.push((path, error.to_string()));
+                        continue;
+                    }
+                };
+
+                if is_dirty {
+                    buffers_to_save.insert(buffer);
+                    saved_paths.push(path);
+                } else {
+                    clean_paths.push(path);
+                }
+            }
+
+            // Save each buffer individually since there's no batch save API.
+            for buffer in buffers_to_save {
+                let path_for_buffer = match buffer.read_with(cx, |buffer, _| {
+                    buffer
+                        .file()
+                        .map(|file| file.path().to_rel_path_buf())
+                        .map(|path| path.as_rel_path().as_unix_str().to_owned())
+                }) {
+                    Ok(path) => path.unwrap_or_else(|| "<unknown>".to_string()),
+                    Err(error) => {
+                        save_errors.push(("<unknown>".to_string(), error.to_string()));
+                        continue;
+                    }
+                };
+
+                let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));
+
+                match save_task {
+                    Ok(task) => {
+                        if let Err(error) = task.await {
+                            save_errors.push((path_for_buffer, error.to_string()));
+                        }
+                    }
+                    Err(error) => {
+                        save_errors.push((path_for_buffer, error.to_string()));
+                    }
+                }
+            }
+
+            let mut lines: Vec<String> = Vec::new();
+
+            if !saved_paths.is_empty() {
+                lines.push(format!("Saved {} file(s).", saved_paths.len()));
+            }
+            if !clean_paths.is_empty() {
+                lines.push(format!("{} clean.", clean_paths.len()));
+            }
+
+            if !not_found_paths.is_empty() {
+                lines.push(format!("Not found ({}):", not_found_paths.len()));
+                for path in &not_found_paths {
+                    lines.push(format!("- {}", path.display()));
+                }
+            }
+            if !open_errors.is_empty() {
+                lines.push(format!("Open failed ({}):", open_errors.len()));
+                for (path, error) in &open_errors {
+                    lines.push(format!("- {}: {}", path.display(), error));
+                }
+            }
+            if !dirty_check_errors.is_empty() {
+                lines.push(format!(
+                    "Dirty check failed ({}):",
+                    dirty_check_errors.len()
+                ));
+                for (path, error) in &dirty_check_errors {
+                    lines.push(format!("- {}: {}", path.display(), error));
+                }
+            }
+            if !save_errors.is_empty() {
+                lines.push(format!("Save failed ({}):", save_errors.len()));
+                for (path, error) in &save_errors {
+                    lines.push(format!("- {}: {}", path, error));
+                }
+            }
+
+            if lines.is_empty() {
+                Ok("No paths provided.".to_string())
+            } else {
+                Ok(lines.join("\n"))
+            }
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use fs::Fs;
+    use gpui::TestAppContext;
+    use project::FakeFs;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_save_file_output_and_effects(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/root",
+            json!({
+                "dirty.txt": "on disk: dirty\n",
+                "clean.txt": "on disk: clean\n",
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let tool = Arc::new(SaveFileTool::new(project.clone()));
+
+        // Make dirty.txt dirty in-memory.
+        let dirty_project_path = project.read_with(cx, |project, cx| {
+            project
+                .find_project_path("root/dirty.txt", cx)
+                .expect("dirty.txt should exist in project")
+        });
+
+        let dirty_buffer = project
+            .update(cx, |project, cx| {
+                project.open_buffer(dirty_project_path, cx)
+            })
+            .await
+            .unwrap();
+        dirty_buffer.update(cx, |buffer, cx| {
+            buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
+        });
+        assert!(
+            dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+            "dirty.txt buffer should be dirty before save"
+        );
+
+        // Ensure clean.txt is opened but remains clean.
+        let clean_project_path = project.read_with(cx, |project, cx| {
+            project
+                .find_project_path("root/clean.txt", cx)
+                .expect("clean.txt should exist in project")
+        });
+
+        let clean_buffer = project
+            .update(cx, |project, cx| {
+                project.open_buffer(clean_project_path, cx)
+            })
+            .await
+            .unwrap();
+        assert!(
+            !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+            "clean.txt buffer should start clean"
+        );
+
+        let output = cx
+            .update(|cx| {
+                tool.clone().run(
+                    SaveFileToolInput {
+                        paths: vec![
+                            PathBuf::from("root/dirty.txt"),
+                            PathBuf::from("root/clean.txt"),
+                        ],
+                    },
+                    ToolCallEventStream::test().0,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        // Output should mention saved + clean.
+        assert!(
+            output.contains("Saved 1 file(s)."),
+            "expected saved count line, got:\n{output}"
+        );
+        assert!(
+            output.contains("1 clean."),
+            "expected clean count line, got:\n{output}"
+        );
+
+        // Effect: dirty buffer should now be clean and disk should have new content.
+        assert!(
+            !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
+            "dirty.txt buffer should not be dirty after save"
+        );
+
+        let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
+        assert_eq!(
+            disk_dirty, "in memory: dirty\n",
+            "dirty.txt disk content should be updated"
+        );
+
+        // Sanity: clean buffer should remain clean and disk unchanged.
+        let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap();
+        assert_eq!(disk_clean, "on disk: clean\n");
+
+        // Test empty paths case.
+        let output = cx
+            .update(|cx| {
+                tool.clone().run(
+                    SaveFileToolInput { paths: vec![] },
+                    ToolCallEventStream::test().0,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        assert_eq!(output, "No paths provided.");
+
+        // Test not-found path case.
+        let output = cx
+            .update(|cx| {
+                tool.clone().run(
+                    SaveFileToolInput {
+                        paths: vec![PathBuf::from("nonexistent/path.txt")],
+                    },
+                    ToolCallEventStream::test().0,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        assert!(
+            output.contains("Not found (1):"),
+            "expected not-found header line, got:\n{output}"
+        );
+        assert!(
+            output.contains("- nonexistent/path.txt"),
+            "expected not-found path bullet, got:\n{output}"
+        );
+    }
+}

crates/agent_settings/Cargo.toml 🔗

@@ -12,6 +12,7 @@ workspace = true
 path = "src/agent_settings.rs"
 
 [dependencies]
+agent-client-protocol.workspace = true
 anyhow.workspace = true
 cloud_llm_client.workspace = true
 collections.workspace = true

crates/agent_settings/src/agent_settings.rs 🔗

@@ -2,7 +2,8 @@ mod agent_profile;
 
 use std::sync::Arc;
 
-use collections::IndexMap;
+use agent_client_protocol::ModelId;
+use collections::{HashSet, IndexMap};
 use gpui::{App, Pixels, px};
 use language_model::LanguageModel;
 use project::DisableAiSettings;
@@ -33,6 +34,7 @@ pub struct AgentSettings {
     pub commit_message_model: Option<LanguageModelSelection>,
     pub thread_summary_model: Option<LanguageModelSelection>,
     pub inline_alternatives: Vec<LanguageModelSelection>,
+    pub favorite_models: Vec<LanguageModelSelection>,
     pub default_profile: AgentProfileId,
     pub default_view: DefaultAgentView,
     pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
@@ -96,6 +98,13 @@ impl AgentSettings {
     pub fn set_message_editor_max_lines(&self) -> usize {
         self.message_editor_min_lines * 2
     }
+
+    pub fn favorite_model_ids(&self) -> HashSet<ModelId> {
+        self.favorite_models
+            .iter()
+            .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
+            .collect()
+    }
 }
 
 #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
@@ -164,6 +173,7 @@ impl Settings for AgentSettings {
             commit_message_model: agent.commit_message_model,
             thread_summary_model: agent.thread_summary_model,
             inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
+            favorite_models: agent.favorite_models,
             default_profile: AgentProfileId(agent.default_profile.unwrap()),
             default_view: agent.default_view.unwrap(),
             profiles: agent

crates/agent_ui/Cargo.toml 🔗

@@ -13,7 +13,7 @@ path = "src/agent_ui.rs"
 doctest = false
 
 [features]
-test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support"]
+test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support", "agent/test-support"]
 unit-eval = []
 
 [dependencies]

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -34,7 +34,7 @@ use theme::ThemeSettings;
 use ui::prelude::*;
 use util::{ResultExt, debug_panic};
 use workspace::{CollaboratorId, Workspace};
-use zed_actions::agent::Chat;
+use zed_actions::agent::{Chat, PasteRaw};
 
 pub struct MessageEditor {
     mention_set: Entity<MentionSet>,
@@ -543,6 +543,9 @@ impl MessageEditor {
     }
 
     fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
         let editor_clipboard_selections = cx
             .read_from_clipboard()
             .and_then(|item| item.entries().first().cloned())
@@ -553,133 +556,127 @@ impl MessageEditor {
                 _ => None,
             });
 
-        let has_file_context = editor_clipboard_selections
-            .as_ref()
-            .is_some_and(|selections| {
-                selections
-                    .iter()
-                    .any(|sel| sel.file_path.is_some() && sel.line_range.is_some())
-            });
+        // Insert creases for pasted clipboard selections that:
+        // 1. Contain exactly one selection
+        // 2. Have an associated file path
+        // 3. Span multiple lines (not single-line selections)
+        // 4. Belong to a file that exists in the current project
+        let should_insert_creases = util::maybe!({
+            let selections = editor_clipboard_selections.as_ref()?;
+            if selections.len() > 1 {
+                return Some(false);
+            }
+            let selection = selections.first()?;
+            let file_path = selection.file_path.as_ref()?;
+            let line_range = selection.line_range.as_ref()?;
 
-        if has_file_context {
-            if let Some((workspace, selections)) =
-                self.workspace.upgrade().zip(editor_clipboard_selections)
-            {
-                let Some(first_selection) = selections.first() else {
-                    return;
-                };
-                if let Some(file_path) = &first_selection.file_path {
-                    // In case someone pastes selections from another window
-                    // with a different project, we don't want to insert the
-                    // crease (containing the absolute path) since the agent
-                    // cannot access files outside the project.
-                    let is_in_project = workspace
-                        .read(cx)
-                        .project()
-                        .read(cx)
-                        .project_path_for_absolute_path(file_path, cx)
-                        .is_some();
-                    if !is_in_project {
-                        return;
-                    }
-                }
+            if line_range.start() == line_range.end() {
+                return Some(false);
+            }
 
-                cx.stop_propagation();
-                let insertion_target = self
-                    .editor
+            Some(
+                workspace
                     .read(cx)
-                    .selections
-                    .newest_anchor()
-                    .start
-                    .text_anchor;
-
-                let project = workspace.read(cx).project().clone();
-                for selection in selections {
-                    if let (Some(file_path), Some(line_range)) =
-                        (selection.file_path, selection.line_range)
-                    {
-                        let crease_text =
-                            acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
+                    .project()
+                    .read(cx)
+                    .project_path_for_absolute_path(file_path, cx)
+                    .is_some(),
+            )
+        })
+        .unwrap_or(false);
 
-                        let mention_uri = MentionUri::Selection {
-                            abs_path: Some(file_path.clone()),
-                            line_range: line_range.clone(),
-                        };
+        if should_insert_creases && let Some(selections) = editor_clipboard_selections {
+            cx.stop_propagation();
+            let insertion_target = self
+                .editor
+                .read(cx)
+                .selections
+                .newest_anchor()
+                .start
+                .text_anchor;
+
+            let project = workspace.read(cx).project().clone();
+            for selection in selections {
+                if let (Some(file_path), Some(line_range)) =
+                    (selection.file_path, selection.line_range)
+                {
+                    let crease_text =
+                        acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
 
-                        let mention_text = mention_uri.as_link().to_string();
-                        let (excerpt_id, text_anchor, content_len) =
-                            self.editor.update(cx, |editor, cx| {
-                                let buffer = editor.buffer().read(cx);
-                                let snapshot = buffer.snapshot(cx);
-                                let (excerpt_id, _, buffer_snapshot) =
-                                    snapshot.as_singleton().unwrap();
-                                let text_anchor = insertion_target.bias_left(&buffer_snapshot);
-
-                                editor.insert(&mention_text, window, cx);
-                                editor.insert(" ", window, cx);
-
-                                (*excerpt_id, text_anchor, mention_text.len())
-                            });
-
-                        let Some((crease_id, tx)) = insert_crease_for_mention(
-                            excerpt_id,
-                            text_anchor,
-                            content_len,
-                            crease_text.into(),
-                            mention_uri.icon_path(cx),
-                            None,
-                            self.editor.clone(),
-                            window,
-                            cx,
-                        ) else {
-                            continue;
-                        };
-                        drop(tx);
-
-                        let mention_task = cx
-                            .spawn({
-                                let project = project.clone();
-                                async move |_, cx| {
-                                    let project_path = project
-                                        .update(cx, |project, cx| {
-                                            project.project_path_for_absolute_path(&file_path, cx)
-                                        })
-                                        .map_err(|e| e.to_string())?
-                                        .ok_or_else(|| "project path not found".to_string())?;
-
-                                    let buffer = project
-                                        .update(cx, |project, cx| {
-                                            project.open_buffer(project_path, cx)
-                                        })
-                                        .map_err(|e| e.to_string())?
-                                        .await
-                                        .map_err(|e| e.to_string())?;
-
-                                    buffer
-                                        .update(cx, |buffer, cx| {
-                                            let start = Point::new(*line_range.start(), 0)
-                                                .min(buffer.max_point());
-                                            let end = Point::new(*line_range.end() + 1, 0)
-                                                .min(buffer.max_point());
-                                            let content =
-                                                buffer.text_for_range(start..end).collect();
-                                            Mention::Text {
-                                                content,
-                                                tracked_buffers: vec![cx.entity()],
-                                            }
-                                        })
-                                        .map_err(|e| e.to_string())
-                                }
-                            })
-                            .shared();
+                    let mention_uri = MentionUri::Selection {
+                        abs_path: Some(file_path.clone()),
+                        line_range: line_range.clone(),
+                    };
+
+                    let mention_text = mention_uri.as_link().to_string();
+                    let (excerpt_id, text_anchor, content_len) =
+                        self.editor.update(cx, |editor, cx| {
+                            let buffer = editor.buffer().read(cx);
+                            let snapshot = buffer.snapshot(cx);
+                            let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
+                            let text_anchor = insertion_target.bias_left(&buffer_snapshot);
+
+                            editor.insert(&mention_text, window, cx);
+                            editor.insert(" ", window, cx);
 
-                        self.mention_set.update(cx, |mention_set, _cx| {
-                            mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
+                            (*excerpt_id, text_anchor, mention_text.len())
                         });
-                    }
+
+                    let Some((crease_id, tx)) = insert_crease_for_mention(
+                        excerpt_id,
+                        text_anchor,
+                        content_len,
+                        crease_text.into(),
+                        mention_uri.icon_path(cx),
+                        None,
+                        self.editor.clone(),
+                        window,
+                        cx,
+                    ) else {
+                        continue;
+                    };
+                    drop(tx);
+
+                    let mention_task = cx
+                        .spawn({
+                            let project = project.clone();
+                            async move |_, cx| {
+                                let project_path = project
+                                    .update(cx, |project, cx| {
+                                        project.project_path_for_absolute_path(&file_path, cx)
+                                    })
+                                    .map_err(|e| e.to_string())?
+                                    .ok_or_else(|| "project path not found".to_string())?;
+
+                                let buffer = project
+                                    .update(cx, |project, cx| project.open_buffer(project_path, cx))
+                                    .map_err(|e| e.to_string())?
+                                    .await
+                                    .map_err(|e| e.to_string())?;
+
+                                buffer
+                                    .update(cx, |buffer, cx| {
+                                        let start = Point::new(*line_range.start(), 0)
+                                            .min(buffer.max_point());
+                                        let end = Point::new(*line_range.end() + 1, 0)
+                                            .min(buffer.max_point());
+                                        let content = buffer.text_for_range(start..end).collect();
+                                        Mention::Text {
+                                            content,
+                                            tracked_buffers: vec![cx.entity()],
+                                        }
+                                    })
+                                    .map_err(|e| e.to_string())
+                            }
+                        })
+                        .shared();
+
+                    self.mention_set.update(cx, |mention_set, _cx| {
+                        mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
+                    });
                 }
-                return;
             }
+            return;
         }
 
         if self.prompt_capabilities.borrow().image
@@ -690,6 +687,13 @@ impl MessageEditor {
         }
     }
 
+    fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
+        let editor = self.editor.clone();
+        window.defer(cx, move |window, cx| {
+            editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
+        });
+    }
+
     pub fn insert_dragged_files(
         &mut self,
         paths: Vec<project::ProjectPath>,
@@ -967,6 +971,7 @@ impl Render for MessageEditor {
             .on_action(cx.listener(Self::chat))
             .on_action(cx.listener(Self::chat_with_follow))
             .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::paste_raw))
             .capture_action(cx.listener(Self::paste))
             .flex_1()
             .child({
@@ -1365,7 +1370,7 @@ mod tests {
                     cx,
                 );
             });
-            message_editor.read(cx).focus_handle(cx).focus(window);
+            message_editor.read(cx).focus_handle(cx).focus(window, cx);
             message_editor.read(cx).editor().clone()
         });
 
@@ -1587,7 +1592,7 @@ mod tests {
                     cx,
                 );
             });
-            message_editor.read(cx).focus_handle(cx).focus(window);
+            message_editor.read(cx).focus_handle(cx).focus(window, cx);
             let editor = message_editor.read(cx).editor().clone();
             (message_editor, editor)
         });
@@ -2315,7 +2320,7 @@ mod tests {
                     cx,
                 );
             });
-            message_editor.read(cx).focus_handle(cx).focus(window);
+            message_editor.read(cx).focus_handle(cx).focus(window, cx);
             let editor = message_editor.read(cx).editor().clone();
             (message_editor, editor)
         });

crates/agent_ui/src/acp/mode_selector.rs 🔗

@@ -188,25 +188,25 @@ impl Render for ModeSelector {
                             .gap_1()
                             .child(
                                 h_flex()
-                                    .pb_1()
                                     .gap_2()
                                     .justify_between()
-                                    .border_b_1()
-                                    .border_color(cx.theme().colors().border_variant)
-                                    .child(Label::new("Cycle Through Modes"))
+                                    .child(Label::new("Toggle Mode Menu"))
                                     .child(KeyBinding::for_action_in(
-                                        &CycleModeSelector,
+                                        &ToggleProfileSelector,
                                         &focus_handle,
                                         cx,
                                     )),
                             )
                             .child(
                                 h_flex()
+                                    .pb_1()
                                     .gap_2()
                                     .justify_between()
-                                    .child(Label::new("Toggle Mode Menu"))
+                                    .border_b_1()
+                                    .border_color(cx.theme().colors().border_variant)
+                                    .child(Label::new("Cycle Through Modes"))
                                     .child(KeyBinding::for_action_in(
-                                        &ToggleProfileSelector,
+                                        &CycleModeSelector,
                                         &focus_handle,
                                         cx,
                                     )),

crates/agent_ui/src/acp/model_selector.rs 🔗

@@ -1,25 +1,26 @@
 use std::{cmp::Reverse, rc::Rc, sync::Arc};
 
-use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector};
+use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector};
+use agent_client_protocol::ModelId;
 use agent_servers::AgentServer;
+use agent_settings::AgentSettings;
 use anyhow::Result;
-use collections::IndexMap;
+use collections::{HashSet, IndexMap};
 use fs::Fs;
 use futures::FutureExt;
 use fuzzy::{StringMatchCandidate, match_strings};
 use gpui::{
     Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity,
 };
+use itertools::Itertools;
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
-use ui::{
-    DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, KeyBinding, ListItem,
-    ListItemSpacing, prelude::*,
-};
+use settings::Settings;
+use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*};
 use util::ResultExt;
 use zed_actions::agent::OpenSettings;
 
-use crate::ui::HoldForDefault;
+use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
 
 pub type AcpModelSelector = Picker<AcpModelPickerDelegate>;
 
@@ -41,7 +42,7 @@ pub fn acp_model_selector(
 
 enum AcpModelPickerEntry {
     Separator(SharedString),
-    Model(AgentModelInfo),
+    Model(AgentModelInfo, bool),
 }
 
 pub struct AcpModelPickerDelegate {
@@ -118,6 +119,67 @@ impl AcpModelPickerDelegate {
     pub fn active_model(&self) -> Option<&AgentModelInfo> {
         self.selected_model.as_ref()
     }
+
+    pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        if !self.selector.supports_favorites() {
+            return;
+        }
+
+        let favorites = AgentSettings::get_global(cx).favorite_model_ids();
+
+        if favorites.is_empty() {
+            return;
+        }
+
+        let Some(models) = self.models.clone() else {
+            return;
+        };
+
+        let all_models: Vec<AgentModelInfo> = match models {
+            AgentModelList::Flat(list) => list,
+            AgentModelList::Grouped(index_map) => index_map
+                .into_values()
+                .flatten()
+                .collect::<Vec<AgentModelInfo>>(),
+        };
+
+        let favorite_models = all_models
+            .iter()
+            .filter(|model| favorites.contains(&model.id))
+            .unique_by(|model| &model.id)
+            .cloned()
+            .collect::<Vec<_>>();
+
+        let current_id = self.selected_model.as_ref().map(|m| m.id.clone());
+
+        let current_index_in_favorites = current_id
+            .as_ref()
+            .and_then(|id| favorite_models.iter().position(|m| &m.id == id))
+            .unwrap_or(usize::MAX);
+
+        let next_index = if current_index_in_favorites == usize::MAX {
+            0
+        } else {
+            (current_index_in_favorites + 1) % favorite_models.len()
+        };
+
+        let next_model = favorite_models[next_index].clone();
+
+        self.selector
+            .select_model(next_model.id.clone(), cx)
+            .detach_and_log_err(cx);
+
+        self.selected_model = Some(next_model);
+
+        // Keep the picker selection aligned with the newly-selected model
+        if let Some(new_index) = self.filtered_entries.iter().position(|entry| {
+            matches!(entry, AcpModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id))
+        }) {
+            self.set_selected_index(new_index, window, cx);
+        } else {
+            cx.notify();
+        }
+    }
 }
 
 impl PickerDelegate for AcpModelPickerDelegate {
@@ -143,7 +205,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
         _cx: &mut Context<Picker<Self>>,
     ) -> bool {
         match self.filtered_entries.get(ix) {
-            Some(AcpModelPickerEntry::Model(_)) => true,
+            Some(AcpModelPickerEntry::Model(_, _)) => true,
             Some(AcpModelPickerEntry::Separator(_)) | None => false,
         }
     }
@@ -158,6 +220,12 @@ impl PickerDelegate for AcpModelPickerDelegate {
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Task<()> {
+        let favorites = if self.selector.supports_favorites() {
+            AgentSettings::get_global(cx).favorite_model_ids()
+        } else {
+            Default::default()
+        };
+
         cx.spawn_in(window, async move |this, cx| {
             let filtered_models = match this
                 .read_with(cx, |this, cx| {
@@ -174,7 +242,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
 
             this.update_in(cx, |this, window, cx| {
                 this.delegate.filtered_entries =
-                    info_list_to_picker_entries(filtered_models).collect();
+                    info_list_to_picker_entries(filtered_models, &favorites);
                 // Finds the currently selected model in the list
                 let new_index = this
                     .delegate
@@ -182,7 +250,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
                     .as_ref()
                     .and_then(|selected| {
                         this.delegate.filtered_entries.iter().position(|entry| {
-                            if let AcpModelPickerEntry::Model(model_info) = entry {
+                            if let AcpModelPickerEntry::Model(model_info, _) = entry {
                                 model_info.id == selected.id
                             } else {
                                 false
@@ -198,7 +266,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
     }
 
     fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
-        if let Some(AcpModelPickerEntry::Model(model_info)) =
+        if let Some(AcpModelPickerEntry::Model(model_info, _)) =
             self.filtered_entries.get(self.selected_index)
         {
             if window.modifiers().secondary() {
@@ -241,75 +309,60 @@ impl PickerDelegate for AcpModelPickerDelegate {
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         match self.filtered_entries.get(ix)? {
-            AcpModelPickerEntry::Separator(title) => Some(
-                div()
-                    .px_2()
-                    .pb_1()
-                    .when(ix > 1, |this| {
-                        this.mt_1()
-                            .pt_2()
-                            .border_t_1()
-                            .border_color(cx.theme().colors().border_variant)
-                    })
-                    .child(
-                        Label::new(title)
-                            .size(LabelSize::XSmall)
-                            .color(Color::Muted),
-                    )
-                    .into_any_element(),
-            ),
-            AcpModelPickerEntry::Model(model_info) => {
+            AcpModelPickerEntry::Separator(title) => {
+                Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
+            }
+            AcpModelPickerEntry::Model(model_info, is_favorite) => {
                 let is_selected = Some(model_info) == self.selected_model.as_ref();
                 let default_model = self.agent_server.default_model(cx);
                 let is_default = default_model.as_ref() == Some(&model_info.id);
 
-                let model_icon_color = if is_selected {
-                    Color::Accent
-                } else {
-                    Color::Muted
+                let supports_favorites = self.selector.supports_favorites();
+
+                let is_favorite = *is_favorite;
+                let handle_action_click = {
+                    let model_id = model_info.id.clone();
+                    let fs = self.fs.clone();
+
+                    move |cx: &App| {
+                        crate::favorite_models::toggle_model_id_in_settings(
+                            model_id.clone(),
+                            !is_favorite,
+                            fs.clone(),
+                            cx,
+                        );
+                    }
                 };
 
                 Some(
                     div()
                         .id(("model-picker-menu-child", ix))
                         .when_some(model_info.description.clone(), |this, description| {
-                            this
-                                .on_hover(cx.listener(move |menu, hovered, _, cx| {
-                                    if *hovered {
-                                        menu.delegate.selected_description = Some((ix, description.clone(), is_default));
-                                    } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) {
-                                        menu.delegate.selected_description = None;
-                                    }
-                                    cx.notify();
-                                }))
+                            this.on_hover(cx.listener(move |menu, hovered, _, cx| {
+                                if *hovered {
+                                    menu.delegate.selected_description =
+                                        Some((ix, description.clone(), is_default));
+                                } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) {
+                                    menu.delegate.selected_description = None;
+                                }
+                                cx.notify();
+                            }))
                         })
                         .child(
-                            ListItem::new(ix)
-                                .inset(true)
-                                .spacing(ListItemSpacing::Sparse)
-                                .toggle_state(selected)
-                                .child(
-                                    h_flex()
-                                        .w_full()
-                                        .gap_1p5()
-                                        .when_some(model_info.icon, |this, icon| {
-                                            this.child(
-                                                Icon::new(icon)
-                                                    .color(model_icon_color)
-                                                    .size(IconSize::Small)
-                                            )
-                                        })
-                                        .child(Label::new(model_info.name.clone()).truncate()),
-                                )
-                                .end_slot(div().pr_3().when(is_selected, |this| {
-                                    this.child(
-                                        Icon::new(IconName::Check)
-                                            .color(Color::Accent)
-                                            .size(IconSize::Small),
-                                    )
-                                })),
+                            ModelSelectorListItem::new(ix, model_info.name.clone())
+                                .map(|this| match &model_info.icon {
+                                    Some(AgentModelIcon::Path(path)) => this.icon_path(path.clone()),
+                                    Some(AgentModelIcon::Named(icon)) => this.icon(*icon),
+                                    None => this,
+                                })
+                                .is_selected(is_selected)
+                                .is_focused(selected)
+                                .when(supports_favorites, |this| {
+                                    this.is_favorite(is_favorite)
+                                        .on_toggle_favorite(handle_action_click)
+                                }),
                         )
-                        .into_any_element()
+                        .into_any_element(),
                 )
             }
         }
@@ -343,7 +396,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
     fn render_footer(
         &self,
         _window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
+        _cx: &mut Context<Picker<Self>>,
     ) -> Option<AnyElement> {
         let focus_handle = self.focus_handle.clone();
 
@@ -351,43 +404,57 @@ impl PickerDelegate for AcpModelPickerDelegate {
             return None;
         }
 
-        Some(
-            h_flex()
-                .w_full()
-                .p_1p5()
-                .border_t_1()
-                .border_color(cx.theme().colors().border_variant)
-                .child(
-                    Button::new("configure", "Configure")
-                        .full_width()
-                        .style(ButtonStyle::Outlined)
-                        .key_binding(
-                            KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx)
-                                .map(|kb| kb.size(rems_from_px(12.))),
-                        )
-                        .on_click(|_, window, cx| {
-                            window.dispatch_action(OpenSettings.boxed_clone(), cx);
-                        }),
-                )
-                .into_any(),
-        )
+        Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
     }
 }
 
 fn info_list_to_picker_entries(
     model_list: AgentModelList,
-) -> impl Iterator<Item = AcpModelPickerEntry> {
+    favorites: &HashSet<ModelId>,
+) -> Vec<AcpModelPickerEntry> {
+    let mut entries = Vec::new();
+
+    let all_models: Vec<_> = match &model_list {
+        AgentModelList::Flat(list) => list.iter().collect(),
+        AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
+    };
+
+    let favorite_models: Vec<_> = all_models
+        .iter()
+        .filter(|m| favorites.contains(&m.id))
+        .unique_by(|m| &m.id)
+        .collect();
+
+    let has_favorites = !favorite_models.is_empty();
+    if has_favorites {
+        entries.push(AcpModelPickerEntry::Separator("Favorite".into()));
+        for model in favorite_models {
+            entries.push(AcpModelPickerEntry::Model((*model).clone(), true));
+        }
+    }
+
     match model_list {
         AgentModelList::Flat(list) => {
-            itertools::Either::Left(list.into_iter().map(AcpModelPickerEntry::Model))
+            if has_favorites {
+                entries.push(AcpModelPickerEntry::Separator("All".into()));
+            }
+            for model in list {
+                let is_favorite = favorites.contains(&model.id);
+                entries.push(AcpModelPickerEntry::Model(model, is_favorite));
+            }
         }
         AgentModelList::Grouped(index_map) => {
-            itertools::Either::Right(index_map.into_iter().flat_map(|(group_name, models)| {
-                std::iter::once(AcpModelPickerEntry::Separator(group_name.0))
-                    .chain(models.into_iter().map(AcpModelPickerEntry::Model))
-            }))
+            for (group_name, models) in index_map {
+                entries.push(AcpModelPickerEntry::Separator(group_name.0));
+                for model in models {
+                    let is_favorite = favorites.contains(&model.id);
+                    entries.push(AcpModelPickerEntry::Model(model, is_favorite));
+                }
+            }
         }
     }
+
+    entries
 }
 
 async fn fuzzy_search(
@@ -403,9 +470,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,
@@ -511,6 +576,168 @@ mod tests {
         }
     }
 
+    fn create_favorites(models: Vec<&str>) -> HashSet<ModelId> {
+        models
+            .into_iter()
+            .map(|m| ModelId::new(m.to_string()))
+            .collect()
+    }
+
+    fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
+        entries
+            .iter()
+            .filter_map(|entry| match entry {
+                AcpModelPickerEntry::Model(info, _) => Some(info.id.0.as_ref()),
+                _ => None,
+            })
+            .collect()
+    }
+
+    fn get_entry_labels(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
+        entries
+            .iter()
+            .map(|entry| match entry {
+                AcpModelPickerEntry::Model(info, _) => info.id.0.as_ref(),
+                AcpModelPickerEntry::Separator(s) => &s,
+            })
+            .collect()
+    }
+
+    #[gpui::test]
+    fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
+        let models = create_model_list(vec![
+            ("zed", vec!["zed/claude", "zed/gemini"]),
+            ("openai", vec!["openai/gpt-5"]),
+        ]);
+        let favorites = create_favorites(vec!["zed/gemini"]);
+
+        let entries = info_list_to_picker_entries(models, &favorites);
+
+        assert!(matches!(
+            entries.first(),
+            Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
+        ));
+
+        let model_ids = get_entry_model_ids(&entries);
+        assert_eq!(model_ids[0], "zed/gemini");
+    }
+
+    #[gpui::test]
+    fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
+        let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
+        let favorites = create_favorites(vec![]);
+
+        let entries = info_list_to_picker_entries(models, &favorites);
+
+        assert!(matches!(
+            entries.first(),
+            Some(AcpModelPickerEntry::Separator(s)) if s == "zed"
+        ));
+    }
+
+    #[gpui::test]
+    fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
+        let models = create_model_list(vec![
+            ("zed", vec!["zed/claude", "zed/gemini"]),
+            ("openai", vec!["openai/gpt-5"]),
+        ]);
+        let favorites = create_favorites(vec!["zed/claude"]);
+
+        let entries = info_list_to_picker_entries(models, &favorites);
+
+        for entry in &entries {
+            if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
+                if info.id.0.as_ref() == "zed/claude" {
+                    assert!(is_favorite, "zed/claude should be a favorite");
+                } else {
+                    assert!(!is_favorite, "{} should not be a favorite", info.id.0);
+                }
+            }
+        }
+    }
+
+    #[gpui::test]
+    fn test_favorites_appear_in_both_sections(_cx: &mut TestAppContext) {
+        let models = create_model_list(vec![
+            ("zed", vec!["zed/claude", "zed/gemini"]),
+            ("openai", vec!["openai/gpt-5", "openai/gpt-4"]),
+        ]);
+        let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
+
+        let entries = info_list_to_picker_entries(models, &favorites);
+        let model_ids = get_entry_model_ids(&entries);
+
+        assert_eq!(model_ids[0], "zed/gemini");
+        assert_eq!(model_ids[1], "openai/gpt-5");
+
+        assert!(model_ids[2..].contains(&"zed/gemini"));
+        assert!(model_ids[2..].contains(&"openai/gpt-5"));
+    }
+
+    #[gpui::test]
+    fn test_favorites_are_not_duplicated_when_repeated_in_other_sections(_cx: &mut TestAppContext) {
+        let models = create_model_list(vec![
+            ("Recommended", vec!["zed/claude", "anthropic/claude"]),
+            ("Zed", vec!["zed/claude", "zed/gpt-5"]),
+            ("Antropic", vec!["anthropic/claude"]),
+            ("OpenAI", vec!["openai/gpt-5"]),
+        ]);
+
+        let favorites = create_favorites(vec!["zed/claude"]);
+
+        let entries = info_list_to_picker_entries(models, &favorites);
+        let labels = get_entry_labels(&entries);
+
+        assert_eq!(
+            labels,
+            vec![
+                "Favorite",
+                "zed/claude",
+                "Recommended",
+                "zed/claude",
+                "anthropic/claude",
+                "Zed",
+                "zed/claude",
+                "zed/gpt-5",
+                "Antropic",
+                "anthropic/claude",
+                "OpenAI",
+                "openai/gpt-5"
+            ]
+        );
+    }
+
+    #[gpui::test]
+    fn test_flat_model_list_with_favorites(_cx: &mut TestAppContext) {
+        let models = AgentModelList::Flat(vec![
+            acp_thread::AgentModelInfo {
+                id: acp::ModelId::new("zed/claude".to_string()),
+                name: "Claude".into(),
+                description: None,
+                icon: None,
+            },
+            acp_thread::AgentModelInfo {
+                id: acp::ModelId::new("zed/gemini".to_string()),
+                name: "Gemini".into(),
+                description: None,
+                icon: None,
+            },
+        ]);
+        let favorites = create_favorites(vec!["zed/gemini"]);
+
+        let entries = info_list_to_picker_entries(models, &favorites);
+
+        assert!(matches!(
+            entries.first(),
+            Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
+        ));
+
+        assert!(entries.iter().any(|e| matches!(
+            e,
+            AcpModelPickerEntry::Separator(s) if s == "All"
+        )));
+    }
+
     #[gpui::test]
     async fn test_fuzzy_match(cx: &mut TestAppContext) {
         let models = create_model_list(vec![

crates/agent_ui/src/acp/model_selector_popover.rs 🔗

@@ -1,17 +1,17 @@
 use std::rc::Rc;
 use std::sync::Arc;
 
-use acp_thread::{AgentModelInfo, AgentModelSelector};
+use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector};
 use agent_servers::AgentServer;
+use agent_settings::AgentSettings;
 use fs::Fs;
 use gpui::{Entity, FocusHandle};
 use picker::popover_menu::PickerPopoverMenu;
-use ui::{
-    ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window,
-    prelude::*,
-};
+use settings::Settings as _;
+use ui::{ButtonLike, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
 use zed_actions::agent::ToggleModelSelector;
 
+use crate::CycleFavoriteModels;
 use crate::acp::{AcpModelSelector, model_selector::acp_model_selector};
 
 pub struct AcpModelSelectorPopover {
@@ -54,6 +54,12 @@ impl AcpModelSelectorPopover {
     pub fn active_model<'a>(&self, cx: &'a App) -> Option<&'a AgentModelInfo> {
         self.selector.read(cx).delegate.active_model()
     }
+
+    pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context<Self>) {
+        self.selector.update(cx, |selector, cx| {
+            selector.delegate.cycle_favorite_models(window, cx);
+        });
+    }
 }
 
 impl Render for AcpModelSelectorPopover {
@@ -64,7 +70,7 @@ impl Render for AcpModelSelectorPopover {
             .map(|model| model.name.clone())
             .unwrap_or_else(|| SharedString::from("Select a Model"));
 
-        let model_icon = model.as_ref().and_then(|model| model.icon);
+        let model_icon = model.as_ref().and_then(|model| model.icon.clone());
 
         let focus_handle = self.focus_handle.clone();
 
@@ -74,12 +80,59 @@ impl Render for AcpModelSelectorPopover {
             (Color::Muted, IconName::ChevronDown)
         };
 
+        let tooltip = Tooltip::element({
+            move |_, cx| {
+                let focus_handle = focus_handle.clone();
+                let should_show_cycle_row = !AgentSettings::get_global(cx)
+                    .favorite_model_ids()
+                    .is_empty();
+
+                v_flex()
+                    .gap_1()
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .justify_between()
+                            .child(Label::new("Change Model"))
+                            .child(KeyBinding::for_action_in(
+                                &ToggleModelSelector,
+                                &focus_handle,
+                                cx,
+                            )),
+                    )
+                    .when(should_show_cycle_row, |this| {
+                        this.child(
+                            h_flex()
+                                .pt_1()
+                                .gap_2()
+                                .border_t_1()
+                                .border_color(cx.theme().colors().border_variant)
+                                .justify_between()
+                                .child(Label::new("Cycle Favorited Models"))
+                                .child(KeyBinding::for_action_in(
+                                    &CycleFavoriteModels,
+                                    &focus_handle,
+                                    cx,
+                                )),
+                        )
+                    })
+                    .into_any()
+            }
+        });
+
         PickerPopoverMenu::new(
             self.selector.clone(),
             ButtonLike::new("active-model")
                 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                 .when_some(model_icon, |this, icon| {
-                    this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
+                    this.child(
+                        match icon {
+                            AgentModelIcon::Path(path) => Icon::from_external_svg(path),
+                            AgentModelIcon::Named(icon_name) => Icon::new(icon_name),
+                        }
+                        .color(color)
+                        .size(IconSize::XSmall),
+                    )
                 })
                 .child(
                     Label::new(model_name)
@@ -88,9 +141,7 @@ impl Render for AcpModelSelectorPopover {
                         .ml_0p5(),
                 )
                 .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)),
-            move |_window, cx| {
-                Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
-            },
+            tooltip,
             gpui::Corner::BottomRight,
             cx,
         )

crates/agent_ui/src/acp/thread_history.rs 🔗

@@ -1,7 +1,7 @@
 use crate::acp::AcpThreadView;
 use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
 use agent::{HistoryEntry, HistoryStore};
-use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
 use editor::{Editor, EditorEvent};
 use fuzzy::StringMatchCandidate;
 use gpui::{
@@ -402,7 +402,22 @@ impl AcpThreadHistory {
         let selected = ix == self.selected_index;
         let hovered = Some(ix) == self.hovered_index;
         let timestamp = entry.updated_at().timestamp();
-        let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
+
+        let display_text = match format {
+            EntryTimeFormat::DateAndTime => {
+                let entry_time = entry.updated_at();
+                let now = Utc::now();
+                let duration = now.signed_duration_since(entry_time);
+                let days = duration.num_days();
+
+                format!("{}d", days)
+            }
+            EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
+        };
+
+        let title = entry.title().clone();
+        let full_date =
+            EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
 
         h_flex()
             .w_full()
@@ -423,11 +438,14 @@ impl AcpThreadHistory {
                                     .truncate(),
                             )
                             .child(
-                                Label::new(thread_timestamp)
+                                Label::new(display_text)
                                     .color(Color::Muted)
                                     .size(LabelSize::XSmall),
                             ),
                     )
+                    .tooltip(move |_, cx| {
+                        Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
+                    })
                     .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
                         if *is_hovered {
                             this.hovered_index = Some(ix);

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -34,7 +34,7 @@ use language::Buffer;
 
 use language_model::LanguageModelRegistry;
 use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
-use project::{Project, ProjectEntryId};
+use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId};
 use prompt_store::{PromptId, PromptStore};
 use rope::Point;
 use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
@@ -63,14 +63,11 @@ 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,
-    RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector,
+    CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread,
+    OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector,
 };
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -256,13 +253,14 @@ impl ThreadFeedbackState {
             editor
         });
 
-        editor.read(cx).focus_handle(cx).focus(window);
+        editor.read(cx).focus_handle(cx).focus(window, cx);
         editor
     }
 }
 
 pub struct AcpThreadView {
     agent: Rc<dyn AgentServer>,
+    agent_server_store: Entity<AgentServerStore>,
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
     thread_state: ThreadState,
@@ -340,7 +338,13 @@ impl AcpThreadView {
         let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
         let available_commands = Rc::new(RefCell::new(vec![]));
 
-        let placeholder = placeholder_text(agent.name().as_ref(), false);
+        let agent_server_store = project.read(cx).agent_server_store().clone();
+        let agent_display_name = agent_server_store
+            .read(cx)
+            .agent_display_name(&ExternalAgentServerName(agent.name()))
+            .unwrap_or_else(|| agent.name());
+
+        let placeholder = placeholder_text(agent_display_name.as_ref(), false);
 
         let message_editor = cx.new(|cx| {
             let mut editor = MessageEditor::new(
@@ -379,7 +383,6 @@ impl AcpThreadView {
             )
         });
 
-        let agent_server_store = project.read(cx).agent_server_store().clone();
         let subscriptions = [
             cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
             cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
@@ -392,12 +395,24 @@ impl AcpThreadView {
             ),
         ];
 
+        cx.on_release(|this, cx| {
+            for window in this.notifications.drain(..) {
+                window
+                    .update(cx, |_, window, _| {
+                        window.remove_window();
+                    })
+                    .ok();
+            }
+        })
+        .detach();
+
         let show_codex_windows_warning = cfg!(windows)
             && project.read(cx).is_local()
             && agent.clone().downcast::<agent_servers::Codex>().is_some();
 
         Self {
             agent: agent.clone(),
+            agent_server_store,
             workspace: workspace.clone(),
             project: project.clone(),
             entry_view_state,
@@ -674,7 +689,7 @@ impl AcpThreadView {
                             })
                         });
 
-                        this.message_editor.focus_handle(cx).focus(window);
+                        this.message_editor.focus_handle(cx).focus(window, cx);
 
                         cx.notify();
                     }
@@ -693,7 +708,7 @@ impl AcpThreadView {
                         this.new_server_version_available = Some(new_version.into());
                         cx.notify();
                     })
-                    .log_err();
+                    .ok();
                 }
             }
         })
@@ -729,7 +744,7 @@ impl AcpThreadView {
         cx: &mut App,
     ) {
         let agent_name = agent.name();
-        let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id {
+        let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id {
             let registry = LanguageModelRegistry::global(cx);
 
             let sub = window.subscribe(&registry, cx, {
@@ -771,12 +786,11 @@ impl AcpThreadView {
                 configuration_view,
                 description: err
                     .description
-                    .clone()
                     .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))),
                 _subscription: subscription,
             };
             if this.message_editor.focus_handle(cx).is_focused(window) {
-                this.focus_handle.focus(window)
+                this.focus_handle.focus(window, cx)
             }
             cx.notify();
         })
@@ -796,7 +810,7 @@ impl AcpThreadView {
                 ThreadState::LoadError(LoadError::Other(format!("{:#}", err).into()))
         }
         if self.message_editor.focus_handle(cx).is_focused(window) {
-            self.focus_handle.focus(window)
+            self.focus_handle.focus(window, cx)
         }
         cx.notify();
     }
@@ -1080,10 +1094,7 @@ impl AcpThreadView {
                 window.defer(cx, |window, cx| {
                     Self::handle_auth_required(
                         this,
-                        AuthRequired {
-                            description: None,
-                            provider_id: None,
-                        },
+                        AuthRequired::new(),
                         agent,
                         connection,
                         window,
@@ -1262,7 +1273,7 @@ impl AcpThreadView {
                 }
             })
         };
-        self.focus_handle(cx).focus(window);
+        self.focus_handle(cx).focus(window, cx);
         cx.notify();
     }
 
@@ -1314,11 +1325,11 @@ impl AcpThreadView {
                 .await?;
             this.update_in(cx, |this, window, cx| {
                 this.send_impl(message_editor, window, cx);
-                this.focus_handle(cx).focus(window);
+                this.focus_handle(cx).focus(window, cx);
             })?;
             anyhow::Ok(())
         })
-        .detach();
+        .detach_and_log_err(cx);
     }
 
     fn open_edited_buffer(
@@ -1457,7 +1468,7 @@ impl AcpThreadView {
                 self.thread_retry_status.take();
                 self.thread_state = ThreadState::LoadError(error.clone());
                 if self.message_editor.focus_handle(cx).is_focused(window) {
-                    self.focus_handle.focus(window)
+                    self.focus_handle.focus(window, cx)
                 }
             }
             AcpThreadEvent::TitleUpdated => {
@@ -1492,7 +1503,13 @@ impl AcpThreadView {
                 let has_commands = !available_commands.is_empty();
                 self.available_commands.replace(available_commands);
 
-                let new_placeholder = placeholder_text(self.agent.name().as_ref(), has_commands);
+                let agent_display_name = self
+                    .agent_server_store
+                    .read(cx)
+                    .agent_display_name(&ExternalAgentServerName(self.agent.name()))
+                    .unwrap_or_else(|| self.agent.name());
+
+                let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
 
                 self.message_editor.update(cx, |editor, cx| {
                     editor.set_placeholder_text(&new_placeholder, window, cx);
@@ -1655,44 +1672,6 @@ impl AcpThreadView {
                 });
                 return;
             }
-        } else if method.0.as_ref() == "anthropic-api-key" {
-            let registry = LanguageModelRegistry::global(cx);
-            let provider = registry
-                .read(cx)
-                .provider(&language_model::ANTHROPIC_PROVIDER_ID)
-                .unwrap();
-            let this = cx.weak_entity();
-            let agent = self.agent.clone();
-            let connection = connection.clone();
-            window.defer(cx, move |window, cx| {
-                if !provider.is_authenticated(cx) {
-                    Self::handle_auth_required(
-                        this,
-                        AuthRequired {
-                            description: Some("ANTHROPIC_API_KEY must be set".to_owned()),
-                            provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
-                        },
-                        agent,
-                        connection,
-                        window,
-                        cx,
-                    );
-                } else {
-                    this.update(cx, |this, cx| {
-                        this.thread_state = Self::initial_state(
-                            agent,
-                            None,
-                            this.workspace.clone(),
-                            this.project.clone(),
-                            true,
-                            window,
-                            cx,
-                        )
-                    })
-                    .ok();
-                }
-            });
-            return;
         } else if method.0.as_ref() == "vertex-ai"
             && std::env::var("GOOGLE_API_KEY").is_err()
             && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
@@ -1890,6 +1869,17 @@ impl AcpThreadView {
         })
     }
 
+    pub fn has_user_submitted_prompt(&self, cx: &App) -> bool {
+        self.thread().is_some_and(|thread| {
+            thread.read(cx).entries().iter().any(|entry| {
+                matches!(
+                    entry,
+                    AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some()
+                )
+            })
+        })
+    }
+
     fn authorize_tool_call(
         &mut self,
         tool_call_id: acp::ToolCallId,
@@ -1943,6 +1933,16 @@ impl AcpThreadView {
         window: &mut Window,
         cx: &Context<Self>,
     ) -> AnyElement {
+        let is_indented = entry.is_indented();
+        let is_first_indented = is_indented
+            && self.thread().is_some_and(|thread| {
+                thread
+                    .read(cx)
+                    .entries()
+                    .get(entry_ix.saturating_sub(1))
+                    .is_none_or(|entry| !entry.is_indented())
+            });
+
         let primary = match &entry {
             AgentThreadEntry::UserMessage(message) => {
                 let Some(editor) = self
@@ -1975,7 +1975,9 @@ impl AcpThreadView {
                 v_flex()
                     .id(("user_message", entry_ix))
                     .map(|this| {
-                        if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none()  {
+                        if is_first_indented {
+                            this.pt_0p5()
+                        } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none()  {
                             this.pt(rems_from_px(18.))
                         } else if rules_item.is_some() {
                             this.pt_3()
@@ -2021,6 +2023,9 @@ impl AcpThreadView {
                                     .shadow_md()
                                     .bg(cx.theme().colors().editor_background)
                                     .border_1()
+                                    .when(is_indented, |this| {
+                                        this.py_2().px_2().shadow_sm()
+                                    })
                                     .when(editing && !editor_focus, |this| this.border_dashed())
                                     .border_color(cx.theme().colors().border)
                                     .map(|this|{
@@ -2091,10 +2096,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()
+                                                        }
+                                                    }))
                                             )
                                     )
                                 }
@@ -2102,7 +2120,11 @@ impl AcpThreadView {
                     )
                     .into_any()
             }
-            AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
+            AgentThreadEntry::AssistantMessage(AssistantMessage {
+                chunks,
+                indented: _,
+            }) => {
+                let mut is_blank = true;
                 let is_last = entry_ix + 1 == total_entries;
 
                 let style = default_markdown_style(false, false, window, cx);
@@ -2112,52 +2134,101 @@ impl AcpThreadView {
                     .children(chunks.iter().enumerate().filter_map(
                         |(chunk_ix, chunk)| match chunk {
                             AssistantMessageChunk::Message { block } => {
-                                block.markdown().map(|md| {
-                                    self.render_markdown(md.clone(), style.clone())
-                                        .into_any_element()
+                                block.markdown().and_then(|md| {
+                                    let this_is_blank = md.read(cx).source().trim().is_empty();
+                                    is_blank = is_blank && this_is_blank;
+                                    if this_is_blank {
+                                        return None;
+                                    }
+
+                                    Some(
+                                        self.render_markdown(md.clone(), style.clone())
+                                            .into_any_element(),
+                                    )
                                 })
                             }
                             AssistantMessageChunk::Thought { block } => {
-                                block.markdown().map(|md| {
-                                    self.render_thinking_block(
-                                        entry_ix,
-                                        chunk_ix,
-                                        md.clone(),
-                                        window,
-                                        cx,
+                                block.markdown().and_then(|md| {
+                                    let this_is_blank = md.read(cx).source().trim().is_empty();
+                                    is_blank = is_blank && this_is_blank;
+                                    if this_is_blank {
+                                        return None;
+                                    }
+
+                                    Some(
+                                        self.render_thinking_block(
+                                            entry_ix,
+                                            chunk_ix,
+                                            md.clone(),
+                                            window,
+                                            cx,
+                                        )
+                                        .into_any_element(),
                                     )
-                                    .into_any_element()
                                 })
                             }
                         },
                     ))
                     .into_any();
 
-                v_flex()
-                    .px_5()
-                    .py_1p5()
-                    .when(is_last, |this| this.pb_4())
-                    .w_full()
-                    .text_ui(cx)
-                    .child(message_body)
-                    .into_any()
+                if is_blank {
+                    Empty.into_any()
+                } else {
+                    v_flex()
+                        .px_5()
+                        .py_1p5()
+                        .when(is_last, |this| this.pb_4())
+                        .w_full()
+                        .text_ui(cx)
+                        .child(message_body)
+                        .into_any()
+                }
             }
             AgentThreadEntry::ToolCall(tool_call) => {
                 let has_terminals = tool_call.terminals().next().is_some();
 
-                div().w_full().map(|this| {
-                    if has_terminals {
-                        this.children(tool_call.terminals().map(|terminal| {
-                            self.render_terminal_tool_call(
-                                entry_ix, terminal, tool_call, window, cx,
-                            )
-                        }))
-                    } else {
-                        this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
-                    }
-                })
+                div()
+                    .w_full()
+                    .map(|this| {
+                        if has_terminals {
+                            this.children(tool_call.terminals().map(|terminal| {
+                                self.render_terminal_tool_call(
+                                    entry_ix, terminal, tool_call, window, cx,
+                                )
+                            }))
+                        } else {
+                            this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
+                        }
+                    })
+                    .into_any()
             }
-            .into_any(),
+        };
+
+        let primary = if is_indented {
+            let line_top = if is_first_indented {
+                rems_from_px(-12.0)
+            } else {
+                rems_from_px(0.0)
+            };
+
+            div()
+                .relative()
+                .w_full()
+                .pl_5()
+                .bg(cx.theme().colors().panel_background.opacity(0.2))
+                .child(
+                    div()
+                        .absolute()
+                        .left(rems_from_px(18.0))
+                        .top(line_top)
+                        .bottom_0()
+                        .w_px()
+                        .bg(cx.theme().colors().border.opacity(0.6)),
+                )
+                .child(primary)
+                .into_any_element()
+        } else {
+            primary
         };
 
         let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
@@ -2360,6 +2431,12 @@ impl AcpThreadView {
         let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
 
         let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
+        let input_output_header = |label: SharedString| {
+            Label::new(label)
+                .size(LabelSize::XSmall)
+                .color(Color::Muted)
+                .buffer_font(cx)
+        };
 
         let tool_output_display =
             if is_open {
@@ -2401,7 +2478,25 @@ impl AcpThreadView {
                     | ToolCallStatus::Completed
                     | ToolCallStatus::Failed
                     | ToolCallStatus::Canceled => v_flex()
-                        .w_full()
+                        .when(!is_edit && !is_terminal_tool, |this| {
+                            this.mt_1p5().w_full().child(
+                                v_flex()
+                                    .ml(rems(0.4))
+                                    .px_3p5()
+                                    .pb_1()
+                                    .gap_1()
+                                    .border_l_1()
+                                    .border_color(self.tool_card_border_color(cx))
+                                    .child(input_output_header("Raw Input:".into()))
+                                    .children(tool_call.raw_input_markdown.clone().map(|input| {
+                                        self.render_markdown(
+                                            input,
+                                            default_markdown_style(false, false, window, cx),
+                                        )
+                                    }))
+                                    .child(input_output_header("Output:".into())),
+                            )
+                        })
                         .children(tool_call.content.iter().enumerate().map(
                             |(content_ix, content)| {
                                 div().child(self.render_tool_call_content(
@@ -2500,7 +2595,7 @@ impl AcpThreadView {
                                         .gap_px()
                                         .when(is_collapsible, |this| {
                                             this.child(
-                                            Disclosure::new(("expand", entry_ix), is_open)
+                                            Disclosure::new(("expand-output", entry_ix), is_open)
                                                 .opened_icon(IconName::ChevronUp)
                                                 .closed_icon(IconName::ChevronDown)
                                                 .visible_on_hover(&card_header_id)
@@ -2623,7 +2718,7 @@ impl AcpThreadView {
                             ..default_markdown_style(false, true, window, cx)
                         },
                     ))
-                    .tooltip(Tooltip::text("Jump to File"))
+                    .tooltip(Tooltip::text("Go to File"))
                     .on_click(cx.listener(move |this, _, window, cx| {
                         this.open_tool_call_location(entry_ix, 0, window, cx);
                     }))
@@ -2686,20 +2781,20 @@ impl AcpThreadView {
         let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
 
         v_flex()
-            .mt_1p5()
             .gap_2()
-            .when(!card_layout, |this| {
-                this.ml(rems(0.4))
-                    .px_3p5()
-                    .border_l_1()
-                    .border_color(self.tool_card_border_color(cx))
-            })
-            .when(card_layout, |this| {
-                this.px_2().pb_2().when(context_ix > 0, |this| {
-                    this.border_t_1()
-                        .pt_2()
+            .map(|this| {
+                if card_layout {
+                    this.when(context_ix > 0, |this| {
+                        this.pt_2()
+                            .border_t_1()
+                            .border_color(self.tool_card_border_color(cx))
+                    })
+                } else {
+                    this.ml(rems(0.4))
+                        .px_3p5()
+                        .border_l_1()
                         .border_color(self.tool_card_border_color(cx))
-                })
+                }
             })
             .text_xs()
             .text_color(cx.theme().colors().text_muted)
@@ -3420,138 +3515,119 @@ impl AcpThreadView {
         pending_auth_method: Option<&acp::AuthMethodId>,
         window: &mut Window,
         cx: &Context<Self>,
-    ) -> Div {
-        let show_description =
-            configuration_view.is_none() && description.is_none() && pending_auth_method.is_none();
-
+    ) -> impl IntoElement {
         let auth_methods = connection.auth_methods();
 
-        v_flex().flex_1().size_full().justify_end().child(
-            v_flex()
-                .p_2()
-                .pr_3()
-                .w_full()
-                .gap_1()
-                .border_t_1()
-                .border_color(cx.theme().colors().border)
-                .bg(cx.theme().status().warning.opacity(0.04))
-                .child(
-                    h_flex()
-                        .gap_1p5()
-                        .child(
-                            Icon::new(IconName::Warning)
-                                .color(Color::Warning)
-                                .size(IconSize::Small),
-                        )
-                        .child(Label::new("Authentication Required").size(LabelSize::Small)),
-                )
-                .children(description.map(|desc| {
-                    div().text_ui(cx).child(self.render_markdown(
-                        desc.clone(),
-                        default_markdown_style(false, false, window, cx),
-                    ))
-                }))
-                .children(
-                    configuration_view
-                        .cloned()
-                        .map(|view| div().w_full().child(view)),
-                )
-                .when(show_description, |el| {
-                    el.child(
-                        Label::new(format!(
-                            "You are not currently authenticated with {}.{}",
-                            self.agent.name(),
-                            if auth_methods.len() > 1 {
-                                " Please choose one of the following options:"
-                            } else {
-                                ""
-                            }
-                        ))
-                        .size(LabelSize::Small)
-                        .color(Color::Muted)
-                        .mb_1()
-                        .ml_5(),
-                    )
-                })
-                .when_some(pending_auth_method, |el, _| {
-                    el.child(
-                        h_flex()
-                            .py_4()
-                            .w_full()
-                            .justify_center()
-                            .gap_1()
-                            .child(
-                                Icon::new(IconName::ArrowCircle)
-                                    .size(IconSize::Small)
-                                    .color(Color::Muted)
-                                    .with_rotate_animation(2),
-                            )
-                            .child(Label::new("Authenticating…").size(LabelSize::Small)),
-                    )
-                })
-                .when(!auth_methods.is_empty(), |this| {
-                    this.child(
-                        h_flex()
-                            .justify_end()
-                            .flex_wrap()
-                            .gap_1()
-                            .when(!show_description, |this| {
-                                this.border_t_1()
-                                    .mt_1()
-                                    .pt_2()
-                                    .border_color(cx.theme().colors().border.opacity(0.8))
+        let agent_display_name = self
+            .agent_server_store
+            .read(cx)
+            .agent_display_name(&ExternalAgentServerName(self.agent.name()))
+            .unwrap_or_else(|| self.agent.name());
+
+        let show_fallback_description = auth_methods.len() > 1
+            && configuration_view.is_none()
+            && description.is_none()
+            && pending_auth_method.is_none();
+
+        let auth_buttons = || {
+            h_flex().justify_end().flex_wrap().gap_1().children(
+                connection
+                    .auth_methods()
+                    .iter()
+                    .enumerate()
+                    .rev()
+                    .map(|(ix, method)| {
+                        let (method_id, name) = if self.project.read(cx).is_via_remote_server()
+                            && method.id.0.as_ref() == "oauth-personal"
+                            && method.name == "Log in with Google"
+                        {
+                            ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
+                        } else {
+                            (method.id.0.clone(), method.name.clone())
+                        };
+
+                        let agent_telemetry_id = connection.telemetry_id();
+
+                        Button::new(method_id.clone(), name)
+                            .label_size(LabelSize::Small)
+                            .map(|this| {
+                                if ix == 0 {
+                                    this.style(ButtonStyle::Tinted(TintColor::Accent))
+                                } else {
+                                    this.style(ButtonStyle::Outlined)
+                                }
                             })
-                            .children(connection.auth_methods().iter().enumerate().rev().map(
-                                |(ix, method)| {
-                                    let (method_id, name) = if self
-                                        .project
-                                        .read(cx)
-                                        .is_via_remote_server()
-                                        && method.id.0.as_ref() == "oauth-personal"
-                                        && method.name == "Log in with Google"
-                                    {
-                                        ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
-                                    } else {
-                                        (method.id.0.clone(), method.name.clone())
-                                    };
+                            .when_some(method.description.clone(), |this, description| {
+                                this.tooltip(Tooltip::text(description))
+                            })
+                            .on_click({
+                                cx.listener(move |this, _, window, cx| {
+                                    telemetry::event!(
+                                        "Authenticate Agent Started",
+                                        agent = agent_telemetry_id,
+                                        method = method_id
+                                    );
 
-                                    let agent_telemetry_id = connection.telemetry_id();
+                                    this.authenticate(
+                                        acp::AuthMethodId::new(method_id.clone()),
+                                        window,
+                                        cx,
+                                    )
+                                })
+                            })
+                    }),
+            )
+        };
 
-                                    Button::new(method_id.clone(), name)
-                                        .label_size(LabelSize::Small)
-                                        .map(|this| {
-                                            if ix == 0 {
-                                                this.style(ButtonStyle::Tinted(TintColor::Warning))
-                                            } else {
-                                                this.style(ButtonStyle::Outlined)
-                                            }
-                                        })
-                                        .when_some(
-                                            method.description.clone(),
-                                            |this, description| {
-                                                this.tooltip(Tooltip::text(description))
-                                            },
-                                        )
-                                        .on_click({
-                                            cx.listener(move |this, _, window, cx| {
-                                                telemetry::event!(
-                                                    "Authenticate Agent Started",
-                                                    agent = agent_telemetry_id,
-                                                    method = method_id
-                                                );
-
-                                                this.authenticate(
-                                                    acp::AuthMethodId::new(method_id.clone()),
-                                                    window,
-                                                    cx,
-                                                )
-                                            })
-                                        })
-                                },
-                            )),
-                    )
-                }),
-        )
+        if pending_auth_method.is_some() {
+            return Callout::new()
+                .icon(IconName::Info)
+                .title(format!("Authenticating to {}…", agent_display_name))
+                .actions_slot(
+                    Icon::new(IconName::ArrowCircle)
+                        .size(IconSize::Small)
+                        .color(Color::Muted)
+                        .with_rotate_animation(2)
+                        .into_any_element(),
+                )
+                .into_any_element();
+        }
+
+        Callout::new()
+            .icon(IconName::Info)
+            .title(format!("Authenticate to {}", agent_display_name))
+            .when(auth_methods.len() == 1, |this| {
+                this.actions_slot(auth_buttons())
+            })
+            .description_slot(
+                v_flex()
+                    .text_ui(cx)
+                    .map(|this| {
+                        if show_fallback_description {
+                            this.child(
+                                Label::new("Choose one of the following authentication options:")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                        } else {
+                            this.children(
+                                configuration_view
+                                    .cloned()
+                                    .map(|view| div().w_full().child(view)),
+                            )
+                            .children(description.map(|desc| {
+                                self.render_markdown(
+                                    desc.clone(),
+                                    default_markdown_style(false, false, window, cx),
+                                )
+                            }))
+                        }
+                    })
+                    .when(auth_methods.len() > 1, |this| {
+                        this.gap_1().child(auth_buttons())
+                    }),
+            )
+            .into_any_element()
     }
 
     fn render_load_error(
@@ -4041,6 +4117,8 @@ impl AcpThreadView {
                                 .ml_1p5()
                         });
 
+                        let full_path = path.display(path_style).to_string();
+
                         let file_icon = FileIcons::get_icon(path.as_std_path(), cx)
                             .map(Icon::from_path)
                             .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
@@ -4074,7 +4152,6 @@ impl AcpThreadView {
                                     .relative()
                                     .pr_8()
                                     .w_full()
-                                    .overflow_x_scroll()
                                     .child(
                                         h_flex()
                                             .id(("file-name-path", index))
@@ -4086,7 +4163,14 @@ impl AcpThreadView {
                                             .child(file_icon)
                                             .children(file_name)
                                             .children(file_path)
-                                            .tooltip(Tooltip::text("Go to File"))
+                                            .tooltip(move |_, cx| {
+                                                Tooltip::with_meta(
+                                                    "Go to File",
+                                                    None,
+                                                    full_path.clone(),
+                                                    cx,
+                                                )
+                                            })
                                             .on_click({
                                                 let buffer = buffer.clone();
                                                 cx.listener(move |this, _, window, cx| {
@@ -4208,7 +4292,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);
                     });
@@ -4220,6 +4308,13 @@ impl AcpThreadView {
                         .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
                 }
             }))
+            .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
+                if let Some(model_selector) = this.model_selector.as_ref() {
+                    model_selector.update(cx, |model_selector, cx| {
+                        model_selector.cycle_favorite_models(window, cx);
+                    });
+                }
+            }))
             .p_2()
             .gap_2()
             .border_t_1()
@@ -4859,6 +4954,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();
@@ -4954,8 +5075,8 @@ impl AcpThreadView {
         });
 
         if let Some(screen_window) = cx
-            .open_window(options, |_, cx| {
-                cx.new(|_| {
+            .open_window(options, |_window, cx| {
+                cx.new(|_cx| {
                     AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
                 })
             })
@@ -5077,6 +5198,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 +5284,7 @@ impl AcpThreadView {
 
         container
             .child(open_as_markdown)
+            .child(scroll_to_recent_user_prompt)
             .child(scroll_to_top)
             .into_any_element()
     }
@@ -5744,10 +5876,6 @@ impl AcpThreadView {
                     };
 
                     let connection = thread.read(cx).connection().clone();
-                    let err = AuthRequired {
-                        description: None,
-                        provider_id: None,
-                    };
                     this.clear_thread_error(cx);
                     if let Some(message) = this.in_flight_prompt.take() {
                         this.message_editor.update(cx, |editor, cx| {
@@ -5756,7 +5884,14 @@ impl AcpThreadView {
                     }
                     let this = cx.weak_entity();
                     window.defer(cx, |window, cx| {
-                        Self::handle_auth_required(this, err, agent, connection, window, cx);
+                        Self::handle_auth_required(
+                            this,
+                            AuthRequired::new(),
+                            agent,
+                            connection,
+                            window,
+                            cx,
+                        );
                     })
                 }
             }))
@@ -5769,14 +5904,10 @@ impl AcpThreadView {
         };
 
         let connection = thread.read(cx).connection().clone();
-        let err = AuthRequired {
-            description: None,
-            provider_id: None,
-        };
         self.clear_thread_error(cx);
         let this = cx.weak_entity();
         window.defer(cx, |window, cx| {
-            Self::handle_auth_required(this, err, agent, connection, window, cx);
+            Self::handle_auth_required(this, AuthRequired::new(), agent, connection, window, cx);
         })
     }
 
@@ -5879,16 +6010,19 @@ impl Render for AcpThreadView {
                     configuration_view,
                     pending_auth_method,
                     ..
-                } => self
-                    .render_auth_required_state(
+                } => v_flex()
+                    .flex_1()
+                    .size_full()
+                    .justify_end()
+                    .child(self.render_auth_required_state(
                         connection,
                         description.as_ref(),
                         configuration_view.as_ref(),
                         pending_auth_method.as_ref(),
                         window,
                         cx,
-                    )
-                    .into_any(),
+                    ))
+                    .into_any_element(),
                 ThreadState::Loading { .. } => v_flex()
                     .flex_1()
                     .child(self.render_recent_history(cx))

crates/agent_ui/src/agent_configuration.rs 🔗

@@ -22,7 +22,8 @@ use gpui::{
 };
 use language::LanguageRegistry;
 use language_model::{
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
+    IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
+    ZED_CLOUD_PROVIDER_ID,
 };
 use language_models::AllLanguageModelSettings;
 use notifications::status_toast::{StatusToast, ToastIcon};
@@ -117,7 +118,7 @@ impl AgentConfiguration {
     }
 
     fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let providers = LanguageModelRegistry::read_global(cx).providers();
+        let providers = LanguageModelRegistry::read_global(cx).visible_providers();
         for provider in providers {
             self.add_provider_configuration_view(&provider, window, cx);
         }
@@ -261,9 +262,12 @@ impl AgentConfiguration {
                                     .w_full()
                                     .gap_1p5()
                                     .child(
-                                        Icon::new(provider.icon())
-                                            .size(IconSize::Small)
-                                            .color(Color::Muted),
+                                        match provider.icon() {
+                                            IconOrSvg::Svg(path) => Icon::from_external_svg(path),
+                                            IconOrSvg::Icon(name) => Icon::new(name),
+                                        }
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
                                     )
                                     .child(
                                         h_flex()
@@ -416,7 +420,7 @@ impl AgentConfiguration {
         &mut self,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let providers = LanguageModelRegistry::read_global(cx).providers();
+        let providers = LanguageModelRegistry::read_global(cx).visible_providers();
 
         let popover_menu = PopoverMenu::new("add-provider-popover")
             .trigger(

crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs 🔗

@@ -446,17 +446,17 @@ impl AddLlmProviderModal {
             })
     }
 
-    fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context<Self>) {
-        window.focus_next();
+    fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_next(cx);
     }
 
     fn on_tab_prev(
         &mut self,
         _: &menu::SelectPrevious,
         window: &mut Window,
-        _: &mut Context<Self>,
+        cx: &mut Context<Self>,
     ) {
-        window.focus_prev();
+        window.focus_prev(cx);
     }
 }
 
@@ -493,7 +493,7 @@ impl Render for AddLlmProviderModal {
             .on_action(cx.listener(Self::on_tab))
             .on_action(cx.listener(Self::on_tab_prev))
             .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
-                this.focus_handle(cx).focus(window);
+                this.focus_handle(cx).focus(window, cx);
             }))
             .child(
                 Modal::new("configure-context-server", None)

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,18 +151,29 @@ 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);
+                    cx.notify();
+                }
+            });
+
         Self {
             fs,
             active_model,
             context_server_registry,
             focus_handle,
             mode: Mode::choose_profile(window, cx),
+            _settings_subscription: settings_subscription,
         }
     }
 
     fn choose_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.mode = Mode::choose_profile(window, cx);
-        self.focus_handle(cx).focus(window);
+        self.focus_handle(cx).focus(window, cx);
     }
 
     fn new_profile(
@@ -177,7 +191,7 @@ impl ManageProfilesModal {
             name_editor,
             base_profile_id,
         });
-        self.focus_handle(cx).focus(window);
+        self.focus_handle(cx).focus(window, cx);
     }
 
     pub fn view_profile(
@@ -192,9 +206,10 @@ 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);
+        self.focus_handle(cx).focus(window, cx);
     }
 
     fn configure_default_model(
@@ -207,7 +222,6 @@ impl ManageProfilesModal {
         let profile_id_for_closure = profile_id.clone();
 
         let model_picker = cx.new(|cx| {
-            let fs = fs.clone();
             let profile_id = profile_id_for_closure.clone();
 
             language_model_selector(
@@ -235,22 +249,36 @@ impl ManageProfilesModal {
                             })
                     }
                 },
-                move |model, cx| {
-                    let provider = model.provider_id().0.to_string();
-                    let model_id = model.id().0.to_string();
-                    let profile_id = profile_id.clone();
+                {
+                    let fs = fs.clone();
+                    move |model, cx| {
+                        let provider = model.provider_id().0.to_string();
+                        let model_id = model.id().0.to_string();
+                        let profile_id = profile_id.clone();
 
-                    update_settings_file(fs.clone(), cx, move |settings, _cx| {
-                        let agent_settings = settings.agent.get_or_insert_default();
-                        if let Some(profiles) = agent_settings.profiles.as_mut() {
-                            if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) {
-                                profile.default_model = Some(LanguageModelSelection {
-                                    provider: LanguageModelProviderSetting(provider.clone()),
-                                    model: model_id.clone(),
-                                });
+                        update_settings_file(fs.clone(), cx, move |settings, _cx| {
+                            let agent_settings = settings.agent.get_or_insert_default();
+                            if let Some(profiles) = agent_settings.profiles.as_mut() {
+                                if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) {
+                                    profile.default_model = Some(LanguageModelSelection {
+                                        provider: LanguageModelProviderSetting(provider.clone()),
+                                        model: model_id.clone(),
+                                    });
+                                }
                             }
-                        }
-                    });
+                        });
+                    }
+                },
+                {
+                    let fs = fs.clone();
+                    move |model, should_be_favorite, cx| {
+                        crate::favorite_models::toggle_in_settings(
+                            model,
+                            should_be_favorite,
+                            fs.clone(),
+                            cx,
+                        );
+                    }
                 },
                 false, // Do not use popover styles for the model picker
                 self.focus_handle.clone(),
@@ -272,7 +300,7 @@ impl ManageProfilesModal {
             model_picker,
             _subscription: dismiss_subscription,
         };
-        self.focus_handle(cx).focus(window);
+        self.focus_handle(cx).focus(window, cx);
     }
 
     fn configure_mcp_tools(
@@ -308,7 +336,7 @@ impl ManageProfilesModal {
             tool_picker,
             _subscription: dismiss_subscription,
         };
-        self.focus_handle(cx).focus(window);
+        self.focus_handle(cx).focus(window, cx);
     }
 
     fn configure_builtin_tools(
@@ -349,7 +377,7 @@ impl ManageProfilesModal {
             tool_picker,
             _subscription: dismiss_subscription,
         };
-        self.focus_handle(cx).focus(window);
+        self.focus_handle(cx).focus(window, cx);
     }
 
     fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -369,6 +397,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 +820,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 +903,7 @@ impl ManageProfilesModal {
         .entry(mode.configure_default_model)
         .entry(mode.configure_tools)
         .entry(mode.configure_mcps)
+        .entry(mode.delete_profile)
         .entry(mode.cancel_item)
     }
 }
@@ -852,7 +951,7 @@ impl Render for ManageProfilesModal {
             .on_action(cx.listener(|this, _: &menu::Cancel, window, cx| this.cancel(window, cx)))
             .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| this.confirm(window, cx)))
             .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
-                this.focus_handle(cx).focus(window);
+                this.focus_handle(cx).focus(window, cx);
             }))
             .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent)))
             .child(match &self.mode {

crates/agent_ui/src/agent_diff.rs 🔗

@@ -17,7 +17,7 @@ use gpui::{
     Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*,
 };
 
-use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point};
+use language::{Buffer, Capability, OffsetRangeExt, Point};
 use multi_buffer::PathKey;
 use project::{Project, ProjectItem, ProjectPath};
 use settings::{Settings, SettingsStore};
@@ -192,7 +192,7 @@ impl AgentDiffPane {
                     && buffer
                         .read(cx)
                         .file()
-                        .is_some_and(|file| file.disk_state() == DiskState::Deleted)
+                        .is_some_and(|file| file.disk_state().is_deleted())
                 {
                     editor.fold_buffer(snapshot.text.remote_id(), cx)
                 }
@@ -212,10 +212,10 @@ impl AgentDiffPane {
                 .focus_handle(cx)
                 .contains_focused(window, cx)
         {
-            self.focus_handle.focus(window);
+            self.focus_handle.focus(window, cx);
         } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
             self.editor.update(cx, |editor, cx| {
-                editor.focus_handle(cx).focus(window);
+                editor.focus_handle(cx).focus(window, cx);
             });
         }
     }
@@ -874,12 +874,12 @@ impl AgentDiffToolbar {
         match active_item {
             AgentDiffToolbarItem::Pane(agent_diff) => {
                 if let Some(agent_diff) = agent_diff.upgrade() {
-                    agent_diff.focus_handle(cx).focus(window);
+                    agent_diff.focus_handle(cx).focus(window, cx);
                 }
             }
             AgentDiffToolbarItem::Editor { editor, .. } => {
                 if let Some(editor) = editor.upgrade() {
-                    editor.read(cx).focus_handle(cx).focus(window);
+                    editor.read(cx).focus_handle(cx).focus(window, cx);
                 }
             }
         }

crates/agent_ui/src/agent_model_selector.rs 🔗

@@ -4,6 +4,7 @@ use crate::{
 };
 use fs::Fs;
 use gpui::{Entity, FocusHandle, SharedString};
+use language_model::IconOrSvg;
 use picker::popover_menu::PickerPopoverMenu;
 use settings::update_settings_file;
 use std::sync::Arc;
@@ -29,26 +30,39 @@ impl AgentModelSelector {
 
         Self {
             selector: cx.new(move |cx| {
-                let fs = fs.clone();
                 language_model_selector(
                     {
                         let model_context = model_usage_context.clone();
                         move |cx| model_context.configured_model(cx)
                     },
-                    move |model, cx| {
-                        let provider = model.provider_id().0.to_string();
-                        let model_id = model.id().0.to_string();
-                        match &model_usage_context {
-                            ModelUsageContext::InlineAssistant => {
-                                update_settings_file(fs.clone(), cx, move |settings, _cx| {
-                                    settings
-                                        .agent
-                                        .get_or_insert_default()
-                                        .set_inline_assistant_model(provider.clone(), model_id);
-                                });
+                    {
+                        let fs = fs.clone();
+                        move |model, cx| {
+                            let provider = model.provider_id().0.to_string();
+                            let model_id = model.id().0.to_string();
+                            match &model_usage_context {
+                                ModelUsageContext::InlineAssistant => {
+                                    update_settings_file(fs.clone(), cx, move |settings, _cx| {
+                                        settings
+                                            .agent
+                                            .get_or_insert_default()
+                                            .set_inline_assistant_model(provider.clone(), model_id);
+                                    });
+                                }
                             }
                         }
                     },
+                    {
+                        let fs = fs.clone();
+                        move |model, should_be_favorite, cx| {
+                            crate::favorite_models::toggle_in_settings(
+                                model,
+                                should_be_favorite,
+                                fs.clone(),
+                                cx,
+                            );
+                        }
+                    },
                     true, // Use popover styles for picker
                     focus_handle_clone,
                     window,
@@ -90,7 +104,14 @@ impl Render for AgentModelSelector {
             self.selector.clone(),
             ButtonLike::new("active-model")
                 .when_some(provider_icon, |this, icon| {
-                    this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
+                    this.child(
+                        match icon {
+                            IconOrSvg::Svg(path) => Icon::from_external_svg(path),
+                            IconOrSvg::Icon(name) => Icon::new(name),
+                        }
+                        .color(color)
+                        .size(IconSize::XSmall),
+                    )
                 })
                 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                 .child(
@@ -102,7 +123,7 @@ impl Render for AgentModelSelector {
                 .child(
                     Icon::new(IconName::ChevronDown)
                         .color(color)
-                        .size(IconSize::Small),
+                        .size(IconSize::XSmall),
                 ),
             move |_window, cx| {
                 Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)

crates/agent_ui/src/agent_panel.rs 🔗

@@ -2,6 +2,7 @@ use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
 
 use acp_thread::AcpThread;
 use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore};
+use agent_servers::AgentServer;
 use db::kvp::{Dismissable, KEY_VALUE_STORE};
 use project::{
     ExternalAgentServerName,
@@ -287,7 +288,7 @@ impl ActiveView {
         }
     }
 
-    pub fn native_agent(
+    fn native_agent(
         fs: Arc<dyn Fs>,
         prompt_store: Option<Entity<PromptStore>>,
         history_store: Entity<agent::HistoryStore>,
@@ -442,6 +443,7 @@ pub struct AgentPanel {
     pending_serialization: Option<Task<Result<()>>>,
     onboarding: Entity<AgentPanelOnboarding>,
     selected_agent: AgentType,
+    show_trust_workspace_message: bool,
 }
 
 impl AgentPanel {
@@ -692,6 +694,7 @@ impl AgentPanel {
             history_store,
             selected_agent: AgentType::default(),
             loading: false,
+            show_trust_workspace_message: false,
         };
 
         // Initial sync of agent servers from extensions
@@ -819,7 +822,7 @@ impl AgentPanel {
             window,
             cx,
         );
-        text_thread_editor.focus_handle(cx).focus(window);
+        text_thread_editor.focus_handle(cx).focus(window, cx);
     }
 
     fn external_thread(
@@ -885,36 +888,21 @@ impl AgentPanel {
             };
 
             let server = ext_agent.server(fs, history);
-
-            this.update_in(cx, |this, window, cx| {
-                let selected_agent = ext_agent.into();
-                if this.selected_agent != selected_agent {
-                    this.selected_agent = selected_agent;
-                    this.serialize(cx);
-                }
-
-                let thread_view = cx.new(|cx| {
-                    crate::acp::AcpThreadView::new(
-                        server,
-                        resume_thread,
-                        summarize_thread,
-                        workspace.clone(),
-                        project,
-                        this.history_store.clone(),
-                        this.prompt_store.clone(),
-                        !loading,
-                        window,
-                        cx,
-                    )
-                });
-
-                this.set_active_view(
-                    ActiveView::ExternalAgentThread { thread_view },
-                    !loading,
+            this.update_in(cx, |agent_panel, window, cx| {
+                agent_panel._external_thread(
+                    server,
+                    resume_thread,
+                    summarize_thread,
+                    workspace,
+                    project,
+                    loading,
+                    ext_agent,
                     window,
                     cx,
                 );
-            })
+            })?;
+
+            anyhow::Ok(())
         })
         .detach_and_log_err(cx);
     }
@@ -947,7 +935,7 @@ impl AgentPanel {
         if let Some(thread_view) = self.active_thread_view() {
             thread_view.update(cx, |view, cx| {
                 view.expand_message_editor(&ExpandMessageEditor, window, cx);
-                view.focus_handle(cx).focus(window);
+                view.focus_handle(cx).focus(window, cx);
             });
         }
     }
@@ -1028,12 +1016,12 @@ impl AgentPanel {
 
                     match &self.active_view {
                         ActiveView::ExternalAgentThread { thread_view } => {
-                            thread_view.focus_handle(cx).focus(window);
+                            thread_view.focus_handle(cx).focus(window, cx);
                         }
                         ActiveView::TextThread {
                             text_thread_editor, ..
                         } => {
-                            text_thread_editor.focus_handle(cx).focus(window);
+                            text_thread_editor.focus_handle(cx).focus(window, cx);
                         }
                         ActiveView::History | ActiveView::Configuration => {}
                     }
@@ -1181,7 +1169,7 @@ impl AgentPanel {
                 Self::handle_agent_configuration_event,
             ));
 
-            configuration.focus_handle(cx).focus(window);
+            configuration.focus_handle(cx).focus(window, cx);
         }
     }
 
@@ -1317,7 +1305,7 @@ impl AgentPanel {
         }
 
         if focus {
-            self.focus_handle(cx).focus(window);
+            self.focus_handle(cx).focus(window, cx);
         }
     }
 
@@ -1477,6 +1465,47 @@ impl AgentPanel {
             cx,
         );
     }
+
+    fn _external_thread(
+        &mut self,
+        server: Rc<dyn AgentServer>,
+        resume_thread: Option<DbThreadMetadata>,
+        summarize_thread: Option<DbThreadMetadata>,
+        workspace: WeakEntity<Workspace>,
+        project: Entity<Project>,
+        loading: bool,
+        ext_agent: ExternalAgent,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let selected_agent = AgentType::from(ext_agent);
+        if self.selected_agent != selected_agent {
+            self.selected_agent = selected_agent;
+            self.serialize(cx);
+        }
+
+        let thread_view = cx.new(|cx| {
+            crate::acp::AcpThreadView::new(
+                server,
+                resume_thread,
+                summarize_thread,
+                workspace.clone(),
+                project,
+                self.history_store.clone(),
+                self.prompt_store.clone(),
+                !loading,
+                window,
+                cx,
+            )
+        });
+
+        self.set_active_view(
+            ActiveView::ExternalAgentThread { thread_view },
+            !loading,
+            window,
+            cx,
+        );
+    }
 }
 
 impl Focusable for AgentPanel {
@@ -1591,14 +1620,19 @@ impl AgentPanel {
 
         let content = match &self.active_view {
             ActiveView::ExternalAgentThread { thread_view } => {
+                let is_generating_title = thread_view
+                    .read(cx)
+                    .as_native_thread(cx)
+                    .map_or(false, |t| t.read(cx).is_generating_title());
+
                 if let Some(title_editor) = thread_view.read(cx).title_editor() {
-                    div()
+                    let container = div()
                         .w_full()
                         .on_action({
                             let thread_view = thread_view.downgrade();
                             move |_: &menu::Confirm, window, cx| {
                                 if let Some(thread_view) = thread_view.upgrade() {
-                                    thread_view.focus_handle(cx).focus(window);
+                                    thread_view.focus_handle(cx).focus(window, cx);
                                 }
                             }
                         })
@@ -1606,12 +1640,25 @@ impl AgentPanel {
                             let thread_view = thread_view.downgrade();
                             move |_: &editor::actions::Cancel, window, cx| {
                                 if let Some(thread_view) = thread_view.upgrade() {
-                                    thread_view.focus_handle(cx).focus(window);
+                                    thread_view.focus_handle(cx).focus(window, cx);
                                 }
                             }
                         })
-                        .child(title_editor)
-                        .into_any_element()
+                        .child(title_editor);
+
+                    if is_generating_title {
+                        container
+                            .with_animation(
+                                "generating_title",
+                                Animation::new(Duration::from_secs(2))
+                                    .repeat()
+                                    .with_easing(pulsating_between(0.4, 0.8)),
+                                |div, delta| div.opacity(delta),
+                            )
+                            .into_any_element()
+                    } else {
+                        container.into_any_element()
+                    }
                 } else {
                     Label::new(thread_view.read(cx).title(cx))
                         .color(Color::Muted)
@@ -1641,6 +1688,13 @@ impl AgentPanel {
                             Label::new(LOADING_SUMMARY_PLACEHOLDER)
                                 .truncate()
                                 .color(Color::Muted)
+                                .with_animation(
+                                    "generating_title",
+                                    Animation::new(Duration::from_secs(2))
+                                        .repeat()
+                                        .with_easing(pulsating_between(0.4, 0.8)),
+                                    |label, delta| label.alpha(delta),
+                                )
                                 .into_any_element()
                         }
                     }
@@ -1684,6 +1738,25 @@ impl AgentPanel {
             .into_any()
     }
 
+    fn handle_regenerate_thread_title(thread_view: Entity<AcpThreadView>, cx: &mut App) {
+        thread_view.update(cx, |thread_view, cx| {
+            if let Some(thread) = thread_view.as_native_thread(cx) {
+                thread.update(cx, |thread, cx| {
+                    thread.generate_title(cx);
+                });
+            }
+        });
+    }
+
+    fn handle_regenerate_text_thread_title(
+        text_thread_editor: Entity<TextThreadEditor>,
+        cx: &mut App,
+    ) {
+        text_thread_editor.update(cx, |text_thread_editor, cx| {
+            text_thread_editor.regenerate_summary(cx);
+        });
+    }
+
     fn render_panel_options_menu(
         &self,
         window: &mut Window,
@@ -1703,6 +1776,35 @@ impl AgentPanel {
 
         let selected_agent = self.selected_agent.clone();
 
+        let text_thread_view = match &self.active_view {
+            ActiveView::TextThread {
+                text_thread_editor, ..
+            } => Some(text_thread_editor.clone()),
+            _ => None,
+        };
+        let text_thread_with_messages = match &self.active_view {
+            ActiveView::TextThread {
+                text_thread_editor, ..
+            } => text_thread_editor
+                .read(cx)
+                .text_thread()
+                .read(cx)
+                .messages(cx)
+                .any(|message| message.role == language_model::Role::Assistant),
+            _ => false,
+        };
+
+        let thread_view = match &self.active_view {
+            ActiveView::ExternalAgentThread { thread_view } => Some(thread_view.clone()),
+            _ => None,
+        };
+        let thread_with_messages = match &self.active_view {
+            ActiveView::ExternalAgentThread { thread_view } => {
+                thread_view.read(cx).has_user_submitted_prompt(cx)
+            }
+            _ => false,
+        };
+
         PopoverMenu::new("agent-options-menu")
             .trigger_with_tooltip(
                 IconButton::new("agent-options-menu", IconName::Ellipsis)
@@ -1725,6 +1827,7 @@ impl AgentPanel {
                 move |window, cx| {
                     Some(ContextMenu::build(window, cx, |mut menu, _window, _| {
                         menu = menu.context(focus_handle.clone());
+
                         if let Some(usage) = usage {
                             menu = menu
                                 .header_with_link("Prompt Usage", "Manage", account_url.clone())
@@ -1762,6 +1865,38 @@ impl AgentPanel {
                                 .separator()
                         }
 
+                        if thread_with_messages | text_thread_with_messages {
+                            menu = menu.header("Current Thread");
+
+                            if let Some(text_thread_view) = text_thread_view.as_ref() {
+                                menu = menu
+                                    .entry("Regenerate Thread Title", None, {
+                                        let text_thread_view = text_thread_view.clone();
+                                        move |_, cx| {
+                                            Self::handle_regenerate_text_thread_title(
+                                                text_thread_view.clone(),
+                                                cx,
+                                            );
+                                        }
+                                    })
+                                    .separator();
+                            }
+
+                            if let Some(thread_view) = thread_view.as_ref() {
+                                menu = menu
+                                    .entry("Regenerate Thread Title", None, {
+                                        let thread_view = thread_view.clone();
+                                        move |_, cx| {
+                                            Self::handle_regenerate_thread_title(
+                                                thread_view.clone(),
+                                                cx,
+                                            );
+                                        }
+                                    })
+                                    .separator();
+                            }
+                        }
+
                         menu = menu
                             .header("MCP Servers")
                             .action(
@@ -2293,7 +2428,7 @@ impl AgentPanel {
                 let history_is_empty = self.history_store.read(cx).is_empty(cx);
 
                 let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
-                    .providers()
+                    .visible_providers()
                     .iter()
                     .any(|provider| {
                         provider.is_authenticated(cx)
@@ -2557,6 +2692,38 @@ impl AgentPanel {
         }
     }
 
+    fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
+        if !self.show_trust_workspace_message {
+            return None;
+        }
+
+        let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
+
+        Some(
+            Callout::new()
+                .icon(IconName::Warning)
+                .severity(Severity::Warning)
+                .border_position(ui::BorderPosition::Bottom)
+                .title("You're in Restricted Mode")
+                .description(description)
+                .actions_slot(
+                    Button::new("open-trust-modal", "Configure Project Trust")
+                        .label_size(LabelSize::Small)
+                        .style(ButtonStyle::Outlined)
+                        .on_click({
+                            cx.listener(move |this, _, window, cx| {
+                                this.workspace
+                                    .update(cx, |workspace, cx| {
+                                        workspace
+                                            .show_worktree_trust_security_modal(true, window, cx)
+                                    })
+                                    .log_err();
+                            })
+                        }),
+                ),
+        )
+    }
+
     fn key_context(&self) -> KeyContext {
         let mut key_context = KeyContext::new_with_defaults();
         key_context.add("AgentPanel");
@@ -2609,6 +2776,7 @@ impl Render for AgentPanel {
                 }
             }))
             .child(self.render_toolbar(window, cx))
+            .children(self.render_workspace_trust_message(cx))
             .children(self.render_onboarding(window, cx))
             .map(|parent| match &self.active_view {
                 ActiveView::ExternalAgentThread { thread_view, .. } => parent

crates/agent_ui/src/agent_ui.rs 🔗

@@ -7,6 +7,7 @@ mod buffer_codegen;
 mod completion_provider;
 mod context;
 mod context_server_configuration;
+mod favorite_models;
 mod inline_assistant;
 mod inline_prompt_editor;
 mod language_model_selector;
@@ -67,6 +68,8 @@ actions!(
         ToggleProfileSelector,
         /// Cycles through available session modes.
         CycleModeSelector,
+        /// Cycles through favorited models in the ACP model selector.
+        CycleFavoriteModels,
         /// Expands the message editor to full size.
         ExpandMessageEditor,
         /// Removes all thread history.
@@ -345,7 +348,8 @@ fn init_language_model_settings(cx: &mut App) {
         |_, event: &language_model::Event, cx| match event {
             language_model::Event::ProviderStateChanged(_)
             | language_model::Event::AddedProvider(_)
-            | language_model::Event::RemovedProvider(_) => {
+            | language_model::Event::RemovedProvider(_)
+            | language_model::Event::ProvidersChanged => {
                 update_active_language_model_from_settings(cx);
             }
             _ => {}
@@ -457,6 +461,7 @@ mod tests {
             commit_message_model: None,
             thread_summary_model: None,
             inline_alternatives: vec![],
+            favorite_models: vec![],
             default_profile: AgentProfileId::default(),
             default_view: DefaultAgentView::Thread,
             profiles: Default::default(),

crates/agent_ui/src/buffer_codegen.rs 🔗

@@ -75,6 +75,9 @@ pub struct BufferCodegen {
     session_id: Uuid,
 }
 
+pub const REWRITE_SECTION_TOOL_NAME: &str = "rewrite_section";
+pub const FAILURE_MESSAGE_TOOL_NAME: &str = "failure_message";
+
 impl BufferCodegen {
     pub fn new(
         buffer: Entity<MultiBuffer>,
@@ -409,6 +412,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);
@@ -438,7 +444,8 @@ impl CodegenAlternative {
                     })
                     .boxed_local()
                 };
-            self.generation = self.handle_stream(model, stream, cx);
+            self.generation =
+                self.handle_stream(model, /* strip_invalid_spans: */ true, stream, cx);
         }
 
         Ok(())
@@ -518,12 +525,12 @@ impl CodegenAlternative {
 
             let tools = vec![
                 LanguageModelRequestTool {
-                    name: "rewrite_section".to_string(),
+                    name: REWRITE_SECTION_TOOL_NAME.to_string(),
                     description: "Replaces text in <rewrite_this></rewrite_this> tags with your replacement_text.".to_string(),
                     input_schema: language_model::tool_schema::root_schema_for::<RewriteSectionInput>(tool_input_format).to_value(),
                 },
                 LanguageModelRequestTool {
-                    name: "failure_message".to_string(),
+                    name: FAILURE_MESSAGE_TOOL_NAME.to_string(),
                     description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(),
                     input_schema: language_model::tool_schema::root_schema_for::<FailureMessageInput>(tool_input_format).to_value(),
                 },
@@ -626,6 +633,7 @@ impl CodegenAlternative {
     pub fn handle_stream(
         &mut self,
         model: Arc<dyn LanguageModel>,
+        strip_invalid_spans: bool,
         stream: impl 'static + Future<Output = Result<LanguageModelTextStream>>,
         cx: &mut Context<Self>,
     ) -> Task<()> {
@@ -710,10 +718,16 @@ impl CodegenAlternative {
                         let mut response_latency = None;
                         let request_start = Instant::now();
                         let diff = async {
-                            let chunks = StripInvalidSpans::new(
-                                stream?.stream.map_err(|error| error.into()),
-                            );
-                            futures::pin_mut!(chunks);
+                            let raw_stream = stream?.stream.map_err(|error| error.into());
+
+                            let stripped;
+                            let mut chunks: Pin<Box<dyn Stream<Item = Result<String>> + Send>> =
+                                if strip_invalid_spans {
+                                    stripped = StripInvalidSpans::new(raw_stream);
+                                    Box::pin(stripped)
+                                } else {
+                                    Box::pin(raw_stream)
+                                };
 
                             let mut diff = StreamingDiff::new(selected_text.to_string());
                             let mut line_diff = LineDiff::default();
@@ -1156,7 +1170,7 @@ impl CodegenAlternative {
             let process_tool_use = move |tool_use: LanguageModelToolUse| -> Option<ToolUseOutput> {
                 let mut chars_read_so_far = chars_read_so_far.lock();
                 match tool_use.name.as_ref() {
-                    "rewrite_section" => {
+                    REWRITE_SECTION_TOOL_NAME => {
                         let Ok(input) =
                             serde_json::from_value::<RewriteSectionInput>(tool_use.input)
                         else {
@@ -1169,7 +1183,7 @@ impl CodegenAlternative {
                             description: None,
                         })
                     }
-                    "failure_message" => {
+                    FAILURE_MESSAGE_TOOL_NAME => {
                         let Ok(mut input) =
                             serde_json::from_value::<FailureMessageInput>(tool_use.input)
                         else {
@@ -1304,7 +1318,12 @@ impl CodegenAlternative {
 
             let Some(task) = codegen
                 .update(cx, move |codegen, cx| {
-                    codegen.handle_stream(model, async { Ok(language_model_text_stream) }, cx)
+                    codegen.handle_stream(
+                        model,
+                        /* strip_invalid_spans: */ false,
+                        async { Ok(language_model_text_stream) },
+                        cx,
+                    )
                 })
                 .ok()
             else {
@@ -1477,7 +1496,10 @@ mod tests {
     use indoc::indoc;
     use language::{Buffer, Point};
     use language_model::fake_provider::FakeLanguageModel;
-    use language_model::{LanguageModelRegistry, TokenUsage};
+    use language_model::{
+        LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRegistry,
+        LanguageModelToolUse, StopReason, TokenUsage,
+    };
     use languages::rust_lang;
     use rand::prelude::*;
     use settings::SettingsStore;
@@ -1789,6 +1811,51 @@ mod tests {
         );
     }
 
+    // When not streaming tool calls, we strip backticks as part of parsing the model's
+    // plain text response. This is a regression test for a bug where we stripped
+    // backticks incorrectly.
+    #[gpui::test]
+    async fn test_allows_model_to_output_backticks(cx: &mut TestAppContext) {
+        init_test(cx);
+        let text = "- Improved; `cmd+click` behavior. Now requires `cmd` to be pressed before the click starts or it doesn't run. ([#44579](https://github.com/zed-industries/zed/pull/44579); thanks [Zachiah](https://github.com/Zachiah))";
+        let buffer = cx.new(|cx| Buffer::local("", cx));
+        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+        let range = buffer.read_with(cx, |buffer, cx| {
+            let snapshot = buffer.snapshot(cx);
+            snapshot.anchor_before(Point::new(0, 0))..snapshot.anchor_after(Point::new(0, 0))
+        });
+        let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap());
+        let codegen = cx.new(|cx| {
+            CodegenAlternative::new(
+                buffer.clone(),
+                range.clone(),
+                true,
+                prompt_builder,
+                Uuid::new_v4(),
+                cx,
+            )
+        });
+
+        let events_tx = simulate_tool_based_completion(&codegen, cx);
+        let chunk_len = text.find('`').unwrap();
+        events_tx
+            .unbounded_send(rewrite_tool_use("tool_1", &text[..chunk_len], false))
+            .unwrap();
+        events_tx
+            .unbounded_send(rewrite_tool_use("tool_2", &text, true))
+            .unwrap();
+        events_tx
+            .unbounded_send(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))
+            .unwrap();
+        drop(events_tx);
+        cx.run_until_parked();
+
+        assert_eq!(
+            buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()),
+            text
+        );
+    }
+
     #[gpui::test]
     async fn test_strip_invalid_spans_from_codeblock() {
         assert_chunks("Lorem ipsum dolor", "Lorem ipsum dolor").await;
@@ -1843,6 +1910,7 @@ mod tests {
         codegen.update(cx, |codegen, cx| {
             codegen.generation = codegen.handle_stream(
                 model,
+                /* strip_invalid_spans: */ false,
                 future::ready(Ok(LanguageModelTextStream {
                     message_id: None,
                     stream: chunks_rx.map(Ok).boxed(),
@@ -1853,4 +1921,39 @@ mod tests {
         });
         chunks_tx
     }
+
+    fn simulate_tool_based_completion(
+        codegen: &Entity<CodegenAlternative>,
+        cx: &mut TestAppContext,
+    ) -> mpsc::UnboundedSender<LanguageModelCompletionEvent> {
+        let (events_tx, events_rx) = mpsc::unbounded();
+        let model = Arc::new(FakeLanguageModel::default());
+        codegen.update(cx, |codegen, cx| {
+            let completion_stream = Task::ready(Ok(events_rx.map(Ok).boxed()
+                as BoxStream<
+                    'static,
+                    Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
+                >));
+            codegen.generation = codegen.handle_completion(model, completion_stream, cx);
+        });
+        events_tx
+    }
+
+    fn rewrite_tool_use(
+        id: &str,
+        replacement_text: &str,
+        is_complete: bool,
+    ) -> LanguageModelCompletionEvent {
+        let input = RewriteSectionInput {
+            replacement_text: replacement_text.into(),
+        };
+        LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
+            id: id.into(),
+            name: REWRITE_SECTION_TOOL_NAME.into(),
+            raw_input: serde_json::to_string(&input).unwrap(),
+            input: serde_json::to_value(&input).unwrap(),
+            is_input_complete: is_complete,
+            thought_signature: None,
+        })
+    }
 }

crates/agent_ui/src/completion_provider.rs 🔗

@@ -20,7 +20,7 @@ use project::{
     Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse,
     PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
 };
-use prompt_store::{PromptId, PromptStore, UserPromptId};
+use prompt_store::{PromptStore, UserPromptId};
 use rope::Point;
 use text::{Anchor, ToPoint as _};
 use ui::prelude::*;
@@ -1585,13 +1585,10 @@ pub(crate) fn search_rules(
                 if metadata.default {
                     None
                 } else {
-                    match metadata.id {
-                        PromptId::EditWorkflow => None,
-                        PromptId::User { uuid } => Some(RulesContextEntry {
-                            prompt_id: uuid,
-                            title: metadata.title?,
-                        }),
-                    }
+                    Some(RulesContextEntry {
+                        prompt_id: metadata.id.as_user()?,
+                        title: metadata.title?,
+                    })
                 }
             })
             .collect::<Vec<_>>()

crates/agent_ui/src/favorite_models.rs 🔗

@@ -0,0 +1,57 @@
+use std::sync::Arc;
+
+use agent_client_protocol::ModelId;
+use fs::Fs;
+use language_model::LanguageModel;
+use settings::{LanguageModelSelection, update_settings_file};
+use ui::App;
+
+fn language_model_to_selection(model: &Arc<dyn LanguageModel>) -> LanguageModelSelection {
+    LanguageModelSelection {
+        provider: model.provider_id().to_string().into(),
+        model: model.id().0.to_string(),
+    }
+}
+
+fn model_id_to_selection(model_id: &ModelId) -> LanguageModelSelection {
+    let id = model_id.0.as_ref();
+    let (provider, model) = id.split_once('/').unwrap_or(("", id));
+    LanguageModelSelection {
+        provider: provider.to_owned().into(),
+        model: model.to_owned(),
+    }
+}
+
+pub fn toggle_in_settings(
+    model: Arc<dyn LanguageModel>,
+    should_be_favorite: bool,
+    fs: Arc<dyn Fs>,
+    cx: &App,
+) {
+    let selection = language_model_to_selection(&model);
+    update_settings_file(fs, cx, move |settings, _| {
+        let agent = settings.agent.get_or_insert_default();
+        if should_be_favorite {
+            agent.add_favorite_model(selection.clone());
+        } else {
+            agent.remove_favorite_model(&selection);
+        }
+    });
+}
+
+pub fn toggle_model_id_in_settings(
+    model_id: ModelId,
+    should_be_favorite: bool,
+    fs: Arc<dyn Fs>,
+    cx: &App,
+) {
+    let selection = model_id_to_selection(&model_id);
+    update_settings_file(fs, cx, move |settings, _| {
+        let agent = settings.agent.get_or_insert_default();
+        if should_be_favorite {
+            agent.add_favorite_model(selection.clone());
+        } else {
+            agent.remove_favorite_model(&selection);
+        }
+    });
+}

crates/agent_ui/src/inline_assistant.rs 🔗

@@ -1197,7 +1197,7 @@ impl InlineAssistant {
 
         assist
             .editor
-            .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
+            .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
             .ok();
     }
 
@@ -1209,7 +1209,7 @@ impl InlineAssistant {
         if let Some(decorations) = assist.decorations.as_ref() {
             decorations.prompt_editor.update(cx, |prompt_editor, cx| {
                 prompt_editor.editor.update(cx, |editor, cx| {
-                    window.focus(&editor.focus_handle(cx));
+                    window.focus(&editor.focus_handle(cx), cx);
                     editor.select_all(&SelectAll, window, cx);
                 })
             });
@@ -1259,28 +1259,26 @@ impl InlineAssistant {
                 let bottom = top + 1.0;
                 (top, bottom)
             });
-            let mut scroll_target_top = scroll_target_range.0;
-            let mut scroll_target_bottom = scroll_target_range.1;
-
-            scroll_target_top -= editor.vertical_scroll_margin() as ScrollOffset;
-            scroll_target_bottom += editor.vertical_scroll_margin() as ScrollOffset;
-
             let height_in_lines = editor.visible_line_count().unwrap_or(0.);
+            let vertical_scroll_margin = editor.vertical_scroll_margin() as ScrollOffset;
+            let scroll_target_top = (scroll_target_range.0 - vertical_scroll_margin)
+                // Don't scroll up too far in the case of a large vertical_scroll_margin.
+                .max(scroll_target_range.0 - height_in_lines / 2.0);
+            let scroll_target_bottom = (scroll_target_range.1 + vertical_scroll_margin)
+                // Don't scroll down past where the top would still be visible.
+                .min(scroll_target_top + height_in_lines);
+
             let scroll_top = editor.scroll_position(cx).y;
             let scroll_bottom = scroll_top + height_in_lines;
 
             if scroll_target_top < scroll_top {
                 editor.set_scroll_position(point(0., scroll_target_top), window, cx);
             } else if scroll_target_bottom > scroll_bottom {
-                if (scroll_target_bottom - scroll_target_top) <= height_in_lines {
-                    editor.set_scroll_position(
-                        point(0., scroll_target_bottom - height_in_lines),
-                        window,
-                        cx,
-                    );
-                } else {
-                    editor.set_scroll_position(point(0., scroll_target_top), window, cx);
-                }
+                editor.set_scroll_position(
+                    point(0., scroll_target_bottom - height_in_lines),
+                    window,
+                    cx,
+                );
             }
         });
     }
@@ -2271,6 +2269,36 @@ pub mod evals {
         );
     }
 
+    #[test]
+    #[cfg_attr(not(feature = "unit-eval"), ignore)]
+    fn eval_empty_buffer() {
+        run_eval(
+            20,
+            1.0,
+            "Write a Python hello, world program".to_string(),
+            "ˇ".to_string(),
+            |output| match output {
+                InlineAssistantOutput::Success {
+                    full_buffer_text, ..
+                } => {
+                    if full_buffer_text.is_empty() {
+                        EvalOutput::failed("expected some output".to_string())
+                    } else {
+                        EvalOutput::passed(format!("Produced {full_buffer_text}"))
+                    }
+                }
+                o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!(
+                    "Assistant output does not match expected output: {:?}",
+                    o
+                )),
+                o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!(
+                    "Assistant output does not match expected output: {:?}",
+                    o
+                )),
+            },
+        );
+    }
+
     fn run_eval(
         iterations: usize,
         expected_pass_ratio: f32,

crates/agent_ui/src/inline_prompt_editor.rs 🔗

@@ -357,7 +357,7 @@ impl<T: 'static> PromptEditor<T> {
             creases = insert_message_creases(&mut editor, &existing_creases, window, cx);
 
             if focus {
-                window.focus(&editor.focus_handle(cx));
+                window.focus(&editor.focus_handle(cx), cx);
             }
             editor
         });
@@ -844,26 +844,59 @@ impl<T: 'static> PromptEditor<T> {
 
                     if show_rating_buttons {
                         buttons.push(
-                            IconButton::new("thumbs-down", IconName::ThumbsDown)
-                                .icon_color(if rated { Color::Muted } else { Color::Default })
-                                .shape(IconButtonShape::Square)
-                                .disabled(rated)
-                                .tooltip(Tooltip::text("Bad result"))
-                                .on_click(cx.listener(|this, _, window, cx| {
-                                    this.thumbs_down(&ThumbsDownResult, window, cx);
-                                }))
-                                .into_any_element(),
-                        );
-
-                        buttons.push(
-                            IconButton::new("thumbs-up", IconName::ThumbsUp)
-                                .icon_color(if rated { Color::Muted } else { Color::Default })
-                                .shape(IconButtonShape::Square)
-                                .disabled(rated)
-                                .tooltip(Tooltip::text("Good result"))
-                                .on_click(cx.listener(|this, _, window, cx| {
-                                    this.thumbs_up(&ThumbsUpResult, window, cx);
-                                }))
+                            h_flex()
+                                .pl_1()
+                                .gap_1()
+                                .border_l_1()
+                                .border_color(cx.theme().colors().border_variant)
+                                .child(
+                                    IconButton::new("thumbs-up", IconName::ThumbsUp)
+                                        .shape(IconButtonShape::Square)
+                                        .map(|this| {
+                                            if rated {
+                                                this.disabled(true)
+                                                    .icon_color(Color::Ignored)
+                                                    .tooltip(move |_, cx| {
+                                                        Tooltip::with_meta(
+                                                            "Good Result",
+                                                            None,
+                                                            "You already rated this result",
+                                                            cx,
+                                                        )
+                                                    })
+                                            } else {
+                                                this.icon_color(Color::Muted)
+                                                    .tooltip(Tooltip::text("Good Result"))
+                                            }
+                                        })
+                                        .on_click(cx.listener(|this, _, window, cx| {
+                                            this.thumbs_up(&ThumbsUpResult, window, cx);
+                                        })),
+                                )
+                                .child(
+                                    IconButton::new("thumbs-down", IconName::ThumbsDown)
+                                        .shape(IconButtonShape::Square)
+                                        .map(|this| {
+                                            if rated {
+                                                this.disabled(true)
+                                                    .icon_color(Color::Ignored)
+                                                    .tooltip(move |_, cx| {
+                                                        Tooltip::with_meta(
+                                                            "Bad Result",
+                                                            None,
+                                                            "You already rated this result",
+                                                            cx,
+                                                        )
+                                                    })
+                                            } else {
+                                                this.icon_color(Color::Muted)
+                                                    .tooltip(Tooltip::text("Bad Result"))
+                                            }
+                                        })
+                                        .on_click(cx.listener(|this, _, window, cx| {
+                                            this.thumbs_down(&ThumbsDownResult, window, cx);
+                                        })),
+                                )
                                 .into_any_element(),
                         );
                     }
@@ -927,10 +960,21 @@ impl<T: 'static> PromptEditor<T> {
     }
 
     fn render_close_button(&self, cx: &mut Context<Self>) -> AnyElement {
+        let focus_handle = self.editor.focus_handle(cx);
+
         IconButton::new("cancel", IconName::Close)
             .icon_color(Color::Muted)
             .shape(IconButtonShape::Square)
-            .tooltip(Tooltip::text("Close Assistant"))
+            .tooltip({
+                move |_window, cx| {
+                    Tooltip::for_action_in(
+                        "Close Assistant",
+                        &editor::actions::Cancel,
+                        &focus_handle,
+                        cx,
+                    )
+                }
+            })
             .on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::CancelRequested)))
             .into_any_element()
     }

crates/agent_ui/src/language_model_selector.rs 🔗

@@ -1,27 +1,33 @@
 use std::{cmp::Reverse, sync::Arc};
 
-use collections::IndexMap;
+use agent_settings::AgentSettings;
+use collections::{HashMap, HashSet, IndexMap};
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
 use gpui::{
     Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
 };
 use language_model::{
-    AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
-    LanguageModelRegistry,
+    AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
 };
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
-use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*};
+use settings::Settings;
+use ui::prelude::*;
 use zed_actions::agent::OpenSettings;
 
+use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
+
 type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
 type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
+type OnToggleFavorite = Arc<dyn Fn(Arc<dyn LanguageModel>, bool, &App) + 'static>;
 
 pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
 
 pub fn language_model_selector(
     get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
     on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
+    on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
     popover_styles: bool,
     focus_handle: FocusHandle,
     window: &mut Window,
@@ -30,6 +36,7 @@ pub fn language_model_selector(
     let delegate = LanguageModelPickerDelegate::new(
         get_active_model,
         on_model_changed,
+        on_toggle_favorite,
         popover_styles,
         focus_handle,
         window,
@@ -47,7 +54,17 @@ pub fn language_model_selector(
 }
 
 fn all_models(cx: &App) -> GroupedModels {
-    let providers = LanguageModelRegistry::global(cx).read(cx).providers();
+    let lm_registry = LanguageModelRegistry::global(cx).read(cx);
+    let providers = lm_registry.visible_providers();
+
+    let mut favorites_index = FavoritesIndex::default();
+
+    for sel in &AgentSettings::get_global(cx).favorite_models {
+        favorites_index
+            .entry(sel.provider.0.clone().into())
+            .or_default()
+            .insert(sel.model.clone().into());
+    }
 
     let recommended = providers
         .iter()
@@ -55,10 +72,7 @@ fn all_models(cx: &App) -> GroupedModels {
             provider
                 .recommended_models(cx)
                 .into_iter()
-                .map(|model| ModelInfo {
-                    model,
-                    icon: provider.icon(),
-                })
+                .map(|model| ModelInfo::new(&**provider, model, &favorites_index))
         })
         .collect();
 
@@ -68,25 +82,44 @@ fn all_models(cx: &App) -> GroupedModels {
             provider
                 .provided_models(cx)
                 .into_iter()
-                .map(|model| ModelInfo {
-                    model,
-                    icon: provider.icon(),
-                })
+                .map(|model| ModelInfo::new(&**provider, model, &favorites_index))
         })
         .collect();
 
     GroupedModels::new(all, recommended)
 }
 
+type FavoritesIndex = HashMap<LanguageModelProviderId, HashSet<LanguageModelId>>;
+
 #[derive(Clone)]
 struct ModelInfo {
     model: Arc<dyn LanguageModel>,
-    icon: IconName,
+    icon: IconOrSvg,
+    is_favorite: bool,
+}
+
+impl ModelInfo {
+    fn new(
+        provider: &dyn LanguageModelProvider,
+        model: Arc<dyn LanguageModel>,
+        favorites_index: &FavoritesIndex,
+    ) -> Self {
+        let is_favorite = favorites_index
+            .get(&provider.id())
+            .map_or(false, |set| set.contains(&model.id()));
+
+        Self {
+            model,
+            icon: provider.icon(),
+            is_favorite,
+        }
+    }
 }
 
 pub struct LanguageModelPickerDelegate {
     on_model_changed: OnModelChanged,
     get_active_model: GetActiveModel,
+    on_toggle_favorite: OnToggleFavorite,
     all_models: Arc<GroupedModels>,
     filtered_entries: Vec<LanguageModelPickerEntry>,
     selected_index: usize,
@@ -100,6 +133,7 @@ impl LanguageModelPickerDelegate {
     fn new(
         get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
         on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
+        on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
         popover_styles: bool,
         focus_handle: FocusHandle,
         window: &mut Window,
@@ -115,6 +149,7 @@ impl LanguageModelPickerDelegate {
             selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
             filtered_entries: entries,
             get_active_model: Arc::new(get_active_model),
+            on_toggle_favorite: Arc::new(on_toggle_favorite),
             _authenticate_all_providers_task: Self::authenticate_all_providers(cx),
             _subscriptions: vec![cx.subscribe_in(
                 &LanguageModelRegistry::global(cx),
@@ -168,7 +203,7 @@ impl LanguageModelPickerDelegate {
     fn authenticate_all_providers(cx: &mut App) -> Task<()> {
         let authenticate_all_providers = LanguageModelRegistry::global(cx)
             .read(cx)
-            .providers()
+            .visible_providers()
             .iter()
             .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
             .collect::<Vec<_>>();
@@ -214,15 +249,57 @@ impl LanguageModelPickerDelegate {
     pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
         (self.get_active_model)(cx)
     }
+
+    pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        if self.all_models.favorites.is_empty() {
+            return;
+        }
+
+        let active_model = (self.get_active_model)(cx);
+        let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
+        let active_model_id = active_model.as_ref().map(|m| m.model.id());
+
+        let current_index = self
+            .all_models
+            .favorites
+            .iter()
+            .position(|info| {
+                Some(info.model.provider_id()) == active_provider_id
+                    && Some(info.model.id()) == active_model_id
+            })
+            .unwrap_or(usize::MAX);
+
+        let next_index = if current_index == usize::MAX {
+            0
+        } else {
+            (current_index + 1) % self.all_models.favorites.len()
+        };
+
+        let next_model = self.all_models.favorites[next_index].model.clone();
+
+        (self.on_model_changed)(next_model, cx);
+
+        // Align the picker selection with the newly-active model
+        let new_index =
+            Self::get_active_model_index(&self.filtered_entries, (self.get_active_model)(cx));
+        self.set_selected_index(new_index, window, cx);
+    }
 }
 
 struct GroupedModels {
+    favorites: Vec<ModelInfo>,
     recommended: Vec<ModelInfo>,
     all: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
 }
 
 impl GroupedModels {
     pub fn new(all: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
+        let favorites = all
+            .iter()
+            .filter(|info| info.is_favorite)
+            .cloned()
+            .collect();
+
         let mut all_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
         for model in all {
             let provider = model.model.provider_id();
@@ -234,6 +311,7 @@ impl GroupedModels {
         }
 
         Self {
+            favorites,
             recommended,
             all: all_by_provider,
         }
@@ -242,13 +320,18 @@ impl GroupedModels {
     fn entries(&self) -> Vec<LanguageModelPickerEntry> {
         let mut entries = Vec::new();
 
+        if !self.favorites.is_empty() {
+            entries.push(LanguageModelPickerEntry::Separator("Favorite".into()));
+            for info in &self.favorites {
+                entries.push(LanguageModelPickerEntry::Model(info.clone()));
+            }
+        }
+
         if !self.recommended.is_empty() {
             entries.push(LanguageModelPickerEntry::Separator("Recommended".into()));
-            entries.extend(
-                self.recommended
-                    .iter()
-                    .map(|info| LanguageModelPickerEntry::Model(info.clone())),
-            );
+            for info in &self.recommended {
+                entries.push(LanguageModelPickerEntry::Model(info.clone()));
+            }
         }
 
         for models in self.all.values() {
@@ -258,12 +341,11 @@ impl GroupedModels {
             entries.push(LanguageModelPickerEntry::Separator(
                 models[0].model.provider_name().0,
             ));
-            entries.extend(
-                models
-                    .iter()
-                    .map(|info| LanguageModelPickerEntry::Model(info.clone())),
-            );
+            for info in models {
+                entries.push(LanguageModelPickerEntry::Model(info.clone()));
+            }
         }
+
         entries
     }
 }
@@ -392,7 +474,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
 
         let configured_providers = language_model_registry
             .read(cx)
-            .providers()
+            .visible_providers()
             .into_iter()
             .filter(|provider| provider.is_authenticated(cx))
             .collect::<Vec<_>>();
@@ -464,23 +546,9 @@ impl PickerDelegate for LanguageModelPickerDelegate {
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         match self.filtered_entries.get(ix)? {
-            LanguageModelPickerEntry::Separator(title) => Some(
-                div()
-                    .px_2()
-                    .pb_1()
-                    .when(ix > 1, |this| {
-                        this.mt_1()
-                            .pt_2()
-                            .border_t_1()
-                            .border_color(cx.theme().colors().border_variant)
-                    })
-                    .child(
-                        Label::new(title)
-                            .size(LabelSize::XSmall)
-                            .color(Color::Muted),
-                    )
-                    .into_any_element(),
-            ),
+            LanguageModelPickerEntry::Separator(title) => {
+                Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
+            }
             LanguageModelPickerEntry::Model(model_info) => {
                 let active_model = (self.get_active_model)(cx);
                 let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
@@ -489,35 +557,23 @@ impl PickerDelegate for LanguageModelPickerDelegate {
                 let is_selected = Some(model_info.model.provider_id()) == active_provider_id
                     && Some(model_info.model.id()) == active_model_id;
 
-                let model_icon_color = if is_selected {
-                    Color::Accent
-                } else {
-                    Color::Muted
+                let is_favorite = model_info.is_favorite;
+                let handle_action_click = {
+                    let model = model_info.model.clone();
+                    let on_toggle_favorite = self.on_toggle_favorite.clone();
+                    move |cx: &App| on_toggle_favorite(model.clone(), !is_favorite, cx)
                 };
 
                 Some(
-                    ListItem::new(ix)
-                        .inset(true)
-                        .spacing(ListItemSpacing::Sparse)
-                        .toggle_state(selected)
-                        .child(
-                            h_flex()
-                                .w_full()
-                                .gap_1p5()
-                                .child(
-                                    Icon::new(model_info.icon)
-                                        .color(model_icon_color)
-                                        .size(IconSize::Small),
-                                )
-                                .child(Label::new(model_info.model.name().0).truncate()),
-                        )
-                        .end_slot(div().pr_3().when(is_selected, |this| {
-                            this.child(
-                                Icon::new(IconName::Check)
-                                    .color(Color::Accent)
-                                    .size(IconSize::Small),
-                            )
-                        }))
+                    ModelSelectorListItem::new(ix, model_info.model.name().0)
+                        .map(|this| match &model_info.icon {
+                            IconOrSvg::Icon(icon_name) => this.icon(*icon_name),
+                            IconOrSvg::Svg(icon_path) => this.icon_path(icon_path.clone()),
+                        })
+                        .is_selected(is_selected)
+                        .is_focused(selected)
+                        .is_favorite(is_favorite)
+                        .on_toggle_favorite(handle_action_click)
                         .into_any_element(),
                 )
             }
@@ -527,7 +583,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
     fn render_footer(
         &self,
         _window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
+        _cx: &mut Context<Picker<Self>>,
     ) -> Option<gpui::AnyElement> {
         let focus_handle = self.focus_handle.clone();
 
@@ -535,26 +591,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
             return None;
         }
 
-        Some(
-            h_flex()
-                .w_full()
-                .p_1p5()
-                .border_t_1()
-                .border_color(cx.theme().colors().border_variant)
-                .child(
-                    Button::new("configure", "Configure")
-                        .full_width()
-                        .style(ButtonStyle::Outlined)
-                        .key_binding(
-                            KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx)
-                                .map(|kb| kb.size(rems_from_px(12.))),
-                        )
-                        .on_click(|_, window, cx| {
-                            window.dispatch_action(OpenSettings.boxed_clone(), cx);
-                        }),
-                )
-                .into_any(),
-        )
+        Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
     }
 }
 
@@ -653,11 +690,24 @@ mod tests {
     }
 
     fn create_models(model_specs: Vec<(&str, &str)>) -> Vec<ModelInfo> {
+        create_models_with_favorites(model_specs, vec![])
+    }
+
+    fn create_models_with_favorites(
+        model_specs: Vec<(&str, &str)>,
+        favorites: Vec<(&str, &str)>,
+    ) -> Vec<ModelInfo> {
         model_specs
             .into_iter()
-            .map(|(provider, name)| ModelInfo {
-                model: Arc::new(TestLanguageModel::new(name, provider)),
-                icon: IconName::Ai,
+            .map(|(provider, name)| {
+                let is_favorite = favorites
+                    .iter()
+                    .any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name);
+                ModelInfo {
+                    model: Arc::new(TestLanguageModel::new(name, provider)),
+                    icon: IconOrSvg::Icon(IconName::Ai),
+                    is_favorite,
+                }
             })
             .collect()
     }
@@ -795,4 +845,93 @@ mod tests {
             vec!["zed/claude", "zed/gemini", "copilot/claude"],
         );
     }
+
+    #[gpui::test]
+    fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
+        let recommended_models = create_models(vec![("zed", "claude")]);
+        let all_models = create_models_with_favorites(
+            vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
+            vec![("zed", "gemini")],
+        );
+
+        let grouped_models = GroupedModels::new(all_models, recommended_models);
+        let entries = grouped_models.entries();
+
+        assert!(matches!(
+            entries.first(),
+            Some(LanguageModelPickerEntry::Separator(s)) if s == "Favorite"
+        ));
+
+        assert_models_eq(grouped_models.favorites, vec!["zed/gemini"]);
+    }
+
+    #[gpui::test]
+    fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
+        let recommended_models = create_models(vec![("zed", "claude")]);
+        let all_models = create_models(vec![("zed", "claude"), ("zed", "gemini")]);
+
+        let grouped_models = GroupedModels::new(all_models, recommended_models);
+        let entries = grouped_models.entries();
+
+        assert!(matches!(
+            entries.first(),
+            Some(LanguageModelPickerEntry::Separator(s)) if s == "Recommended"
+        ));
+
+        assert!(grouped_models.favorites.is_empty());
+    }
+
+    #[gpui::test]
+    fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
+        let recommended_models =
+            create_models_with_favorites(vec![("zed", "claude")], vec![("zed", "claude")]);
+        let all_models = create_models_with_favorites(
+            vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
+            vec![("zed", "claude")],
+        );
+
+        let grouped_models = GroupedModels::new(all_models, recommended_models);
+        let entries = grouped_models.entries();
+
+        for entry in &entries {
+            if let LanguageModelPickerEntry::Model(info) = entry {
+                if info.model.telemetry_id() == "zed/claude" {
+                    assert!(info.is_favorite, "zed/claude should be a favorite");
+                } else {
+                    assert!(
+                        !info.is_favorite,
+                        "{} should not be a favorite",
+                        info.model.telemetry_id()
+                    );
+                }
+            }
+        }
+    }
+
+    #[gpui::test]
+    fn test_favorites_appear_in_other_sections(_cx: &mut TestAppContext) {
+        let favorites = vec![("zed", "gemini"), ("openai", "gpt-4")];
+
+        let recommended_models =
+            create_models_with_favorites(vec![("zed", "claude")], favorites.clone());
+
+        let all_models = create_models_with_favorites(
+            vec![
+                ("zed", "claude"),
+                ("zed", "gemini"),
+                ("openai", "gpt-4"),
+                ("openai", "gpt-3.5"),
+            ],
+            favorites,
+        );
+
+        let grouped_models = GroupedModels::new(all_models, recommended_models);
+
+        assert_models_eq(grouped_models.favorites, vec!["zed/gemini", "openai/gpt-4"]);
+        assert_models_eq(grouped_models.recommended, vec!["zed/claude"]);
+        assert_models_eq(
+            grouped_models.all.values().flatten().cloned().collect(),
+            vec!["zed/claude", "zed/gemini", "openai/gpt-4", "openai/gpt-3.5"],
+        );
+    }
 }

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().child(Label::new("Toggle Profile Menu")).child(
+                            KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx),
+                        ))
+                        .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,
+                                )),
+                        )
+                        .into_any()
+                }
+            }),
             gpui::Corner::BottomRight,
             cx,
         )

crates/agent_ui/src/terminal_inline_assistant.rs 🔗

@@ -127,7 +127,7 @@ impl TerminalInlineAssistant {
         if let Some(prompt_editor) = assist.prompt_editor.as_ref() {
             prompt_editor.update(cx, |this, cx| {
                 this.editor.update(cx, |editor, cx| {
-                    window.focus(&editor.focus_handle(cx));
+                    window.focus(&editor.focus_handle(cx), cx);
                     editor.select_all(&SelectAll, window, cx);
                 });
             });
@@ -292,7 +292,7 @@ impl TerminalInlineAssistant {
                 .terminal
                 .update(cx, |this, cx| {
                     this.clear_block_below_cursor(cx);
-                    this.focus_handle(cx).focus(window);
+                    this.focus_handle(cx).focus(window, cx);
                 })
                 .log_err();
 
@@ -369,7 +369,7 @@ impl TerminalInlineAssistant {
             .terminal
             .update(cx, |this, cx| {
                 this.clear_block_below_cursor(cx);
-                this.focus_handle(cx).focus(window);
+                this.focus_handle(cx).focus(window, cx);
             })
             .is_ok()
     }

crates/agent_ui/src/text_thread_editor.rs 🔗

@@ -2,7 +2,7 @@ use crate::{
     language_model_selector::{LanguageModelSelector, language_model_selector},
     ui::BurnModeTooltip,
 };
-use agent_settings::CompletionMode;
+use agent_settings::{AgentSettings, CompletionMode};
 use anyhow::Result;
 use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet};
 use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases};
@@ -33,7 +33,8 @@ use language::{
     language_settings::{SoftWrap, all_language_settings},
 };
 use language_model::{
-    ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role,
+    ConfigurationError, IconOrSvg, LanguageModelExt, LanguageModelImage, LanguageModelRegistry,
+    Role,
 };
 use multi_buffer::MultiBufferRow;
 use picker::{Picker, popover_menu::PickerPopoverMenu};
@@ -71,7 +72,9 @@ use workspace::{
     pane,
     searchable::{SearchEvent, SearchableItem},
 };
-use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector};
+use zed_actions::agent::{AddSelectionToThread, PasteRaw, ToggleModelSelector};
+
+use crate::CycleFavoriteModels;
 
 use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
 use assistant_text_thread::{
@@ -304,17 +307,31 @@ impl TextThreadEditor {
             language_model_selector: cx.new(|cx| {
                 language_model_selector(
                     |cx| LanguageModelRegistry::read_global(cx).default_model(),
-                    move |model, cx| {
-                        update_settings_file(fs.clone(), cx, move |settings, _| {
-                            let provider = model.provider_id().0.to_string();
-                            let model = model.id().0.to_string();
-                            settings.agent.get_or_insert_default().set_model(
-                                LanguageModelSelection {
-                                    provider: LanguageModelProviderSetting(provider),
-                                    model,
-                                },
-                            )
-                        });
+                    {
+                        let fs = fs.clone();
+                        move |model, cx| {
+                            update_settings_file(fs.clone(), cx, move |settings, _| {
+                                let provider = model.provider_id().0.to_string();
+                                let model = model.id().0.to_string();
+                                settings.agent.get_or_insert_default().set_model(
+                                    LanguageModelSelection {
+                                        provider: LanguageModelProviderSetting(provider),
+                                        model,
+                                    },
+                                )
+                            });
+                        }
+                    },
+                    {
+                        let fs = fs.clone();
+                        move |model, should_be_favorite, cx| {
+                            crate::favorite_models::toggle_in_settings(
+                                model,
+                                should_be_favorite,
+                                fs.clone(),
+                                cx,
+                            );
+                        }
                     },
                     true, // Use popover styles for picker
                     focus_handle,
@@ -1325,7 +1342,7 @@ impl TextThreadEditor {
         if let Some((text, _)) = Self::get_selection_or_code_block(&context_editor_view, cx) {
             active_editor_view.update(cx, |editor, cx| {
                 editor.insert(&text, window, cx);
-                editor.focus_handle(cx).focus(window);
+                editor.focus_handle(cx).focus(window, cx);
             })
         }
     }
@@ -1682,6 +1699,9 @@ impl TextThreadEditor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
         let editor_clipboard_selections = cx
             .read_from_clipboard()
             .and_then(|item| item.entries().first().cloned())
@@ -1692,84 +1712,101 @@ impl TextThreadEditor {
                 _ => None,
             });
 
-        let has_file_context = editor_clipboard_selections
-            .as_ref()
-            .is_some_and(|selections| {
-                selections
-                    .iter()
-                    .any(|sel| sel.file_path.is_some() && sel.line_range.is_some())
-            });
-
-        if has_file_context {
-            if let Some(clipboard_item) = cx.read_from_clipboard() {
-                if let Some(ClipboardEntry::String(clipboard_text)) =
-                    clipboard_item.entries().first()
-                {
-                    if let Some(selections) = editor_clipboard_selections {
-                        cx.stop_propagation();
-
-                        let text = clipboard_text.text();
-                        self.editor.update(cx, |editor, cx| {
-                            let mut current_offset = 0;
-                            let weak_editor = cx.entity().downgrade();
-
-                            for selection in selections {
-                                if let (Some(file_path), Some(line_range)) =
-                                    (selection.file_path, selection.line_range)
-                                {
-                                    let selected_text =
-                                        &text[current_offset..current_offset + selection.len];
-                                    let fence = assistant_slash_commands::codeblock_fence_for_path(
-                                        file_path.to_str(),
-                                        Some(line_range.clone()),
-                                    );
-                                    let formatted_text = format!("{fence}{selected_text}\n```");
-
-                                    let insert_point = editor
-                                        .selections
-                                        .newest::<Point>(&editor.display_snapshot(cx))
-                                        .head();
-                                    let start_row = MultiBufferRow(insert_point.row);
-
-                                    editor.insert(&formatted_text, window, cx);
+        // Insert creases for pasted clipboard selections that:
+        // 1. Contain exactly one selection
+        // 2. Have an associated file path
+        // 3. Span multiple lines (not single-line selections)
+        // 4. Belong to a file that exists in the current project
+        let should_insert_creases = util::maybe!({
+            let selections = editor_clipboard_selections.as_ref()?;
+            if selections.len() > 1 {
+                return Some(false);
+            }
+            let selection = selections.first()?;
+            let file_path = selection.file_path.as_ref()?;
+            let line_range = selection.line_range.as_ref()?;
 
-                                    let snapshot = editor.buffer().read(cx).snapshot(cx);
-                                    let anchor_before = snapshot.anchor_after(insert_point);
-                                    let anchor_after = editor
-                                        .selections
-                                        .newest_anchor()
-                                        .head()
-                                        .bias_left(&snapshot);
+            if line_range.start() == line_range.end() {
+                return Some(false);
+            }
 
-                                    editor.insert("\n", window, cx);
+            Some(
+                workspace
+                    .read(cx)
+                    .project()
+                    .read(cx)
+                    .project_path_for_absolute_path(file_path, cx)
+                    .is_some(),
+            )
+        })
+        .unwrap_or(false);
 
-                                    let crease_text = acp_thread::selection_name(
-                                        Some(file_path.as_ref()),
-                                        &line_range,
-                                    );
+        if should_insert_creases && let Some(clipboard_item) = cx.read_from_clipboard() {
+            if let Some(ClipboardEntry::String(clipboard_text)) = clipboard_item.entries().first() {
+                if let Some(selections) = editor_clipboard_selections {
+                    cx.stop_propagation();
 
-                                    let fold_placeholder = quote_selection_fold_placeholder(
-                                        crease_text,
-                                        weak_editor.clone(),
-                                    );
-                                    let crease = Crease::inline(
-                                        anchor_before..anchor_after,
-                                        fold_placeholder,
-                                        render_quote_selection_output_toggle,
-                                        |_, _, _, _| Empty.into_any(),
-                                    );
-                                    editor.insert_creases(vec![crease], cx);
-                                    editor.fold_at(start_row, window, cx);
+                    let text = clipboard_text.text();
+                    self.editor.update(cx, |editor, cx| {
+                        let mut current_offset = 0;
+                        let weak_editor = cx.entity().downgrade();
 
-                                    current_offset += selection.len;
-                                    if !selection.is_entire_line && current_offset < text.len() {
-                                        current_offset += 1;
-                                    }
+                        for selection in selections {
+                            if let (Some(file_path), Some(line_range)) =
+                                (selection.file_path, selection.line_range)
+                            {
+                                let selected_text =
+                                    &text[current_offset..current_offset + selection.len];
+                                let fence = assistant_slash_commands::codeblock_fence_for_path(
+                                    file_path.to_str(),
+                                    Some(line_range.clone()),
+                                );
+                                let formatted_text = format!("{fence}{selected_text}\n```");
+
+                                let insert_point = editor
+                                    .selections
+                                    .newest::<Point>(&editor.display_snapshot(cx))
+                                    .head();
+                                let start_row = MultiBufferRow(insert_point.row);
+
+                                editor.insert(&formatted_text, window, cx);
+
+                                let snapshot = editor.buffer().read(cx).snapshot(cx);
+                                let anchor_before = snapshot.anchor_after(insert_point);
+                                let anchor_after = editor
+                                    .selections
+                                    .newest_anchor()
+                                    .head()
+                                    .bias_left(&snapshot);
+
+                                editor.insert("\n", window, cx);
+
+                                let crease_text = acp_thread::selection_name(
+                                    Some(file_path.as_ref()),
+                                    &line_range,
+                                );
+
+                                let fold_placeholder = quote_selection_fold_placeholder(
+                                    crease_text,
+                                    weak_editor.clone(),
+                                );
+                                let crease = Crease::inline(
+                                    anchor_before..anchor_after,
+                                    fold_placeholder,
+                                    render_quote_selection_output_toggle,
+                                    |_, _, _, _| Empty.into_any(),
+                                );
+                                editor.insert_creases(vec![crease], cx);
+                                editor.fold_at(start_row, window, cx);
+
+                                current_offset += selection.len;
+                                if !selection.is_entire_line && current_offset < text.len() {
+                                    current_offset += 1;
                                 }
                             }
-                        });
-                        return;
-                    }
+                        }
+                    });
+                    return;
                 }
             }
         }
@@ -1928,6 +1965,12 @@ impl TextThreadEditor {
         }
     }
 
+    fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
+        self.editor.update(cx, |editor, cx| {
+            editor.paste(&editor::actions::Paste, window, cx);
+        });
+    }
+
     fn update_image_blocks(&mut self, cx: &mut Context<Self>) {
         self.editor.update(cx, |editor, cx| {
             let buffer = editor.buffer().read(cx).snapshot(cx);
@@ -2189,18 +2232,66 @@ impl TextThreadEditor {
             .default_model()
             .map(|default| default.provider);
 
-        let provider_icon = match active_provider {
-            Some(provider) => provider.icon(),
-            None => IconName::Ai,
-        };
+        let provider_icon = active_provider
+            .as_ref()
+            .map(|p| p.icon())
+            .unwrap_or(IconOrSvg::Icon(IconName::Ai));
 
         let focus_handle = self.editor().focus_handle(cx);
+
         let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() {
             (Color::Accent, IconName::ChevronUp)
         } else {
             (Color::Muted, IconName::ChevronDown)
         };
 
+        let provider_icon_element = match provider_icon {
+            IconOrSvg::Svg(path) => Icon::from_external_svg(path),
+            IconOrSvg::Icon(name) => Icon::new(name),
+        }
+        .color(color)
+        .size(IconSize::XSmall);
+
+        let tooltip = Tooltip::element({
+            move |_, cx| {
+                let focus_handle = focus_handle.clone();
+                let should_show_cycle_row = !AgentSettings::get_global(cx)
+                    .favorite_model_ids()
+                    .is_empty();
+
+                v_flex()
+                    .gap_1()
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .justify_between()
+                            .child(Label::new("Change Model"))
+                            .child(KeyBinding::for_action_in(
+                                &ToggleModelSelector,
+                                &focus_handle,
+                                cx,
+                            )),
+                    )
+                    .when(should_show_cycle_row, |this| {
+                        this.child(
+                            h_flex()
+                                .pt_1()
+                                .gap_2()
+                                .border_t_1()
+                                .border_color(cx.theme().colors().border_variant)
+                                .justify_between()
+                                .child(Label::new("Cycle Favorited Models"))
+                                .child(KeyBinding::for_action_in(
+                                    &CycleFavoriteModels,
+                                    &focus_handle,
+                                    cx,
+                                )),
+                        )
+                    })
+                    .into_any()
+            }
+        });
+
         PickerPopoverMenu::new(
             self.language_model_selector.clone(),
             ButtonLike::new("active-model")
@@ -2208,7 +2299,7 @@ impl TextThreadEditor {
                 .child(
                     h_flex()
                         .gap_0p5()
-                        .child(Icon::new(provider_icon).color(color).size(IconSize::XSmall))
+                        .child(provider_icon_element)
                         .child(
                             Label::new(model_name)
                                 .color(color)
@@ -2217,9 +2308,7 @@ impl TextThreadEditor {
                         )
                         .child(Icon::new(icon).color(color).size(IconSize::XSmall)),
                 ),
-            move |_window, cx| {
-                Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
-            },
+            tooltip,
             gpui::Corner::BottomRight,
             cx,
         )
@@ -2572,6 +2661,7 @@ impl Render for TextThreadEditor {
             .capture_action(cx.listener(TextThreadEditor::copy))
             .capture_action(cx.listener(TextThreadEditor::cut))
             .capture_action(cx.listener(TextThreadEditor::paste))
+            .on_action(cx.listener(TextThreadEditor::paste_raw))
             .capture_action(cx.listener(TextThreadEditor::cycle_message_role))
             .capture_action(cx.listener(TextThreadEditor::confirm_command))
             .on_action(cx.listener(TextThreadEditor::assist))
@@ -2579,6 +2669,11 @@ impl Render for TextThreadEditor {
             .on_action(move |_: &ToggleModelSelector, window, cx| {
                 language_model_selector.toggle(window, cx);
             })
+            .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
+                this.language_model_selector.update(cx, |selector, cx| {
+                    selector.delegate.cycle_favorite_models(window, cx);
+                });
+            }))
             .size_full()
             .child(
                 div()

crates/agent_ui/src/ui.rs 🔗

@@ -4,8 +4,8 @@ mod burn_mode_tooltip;
 mod claude_code_onboarding_modal;
 mod end_trial_upsell;
 mod hold_for_default;
+mod model_selector_components;
 mod onboarding_modal;
-mod unavailable_editing_tooltip;
 mod usage_callout;
 
 pub use acp_onboarding_modal::*;
@@ -14,6 +14,6 @@ pub use burn_mode_tooltip::*;
 pub use claude_code_onboarding_modal::*;
 pub use end_trial_upsell::*;
 pub use hold_for_default::*;
+pub use model_selector_components::*;
 pub use onboarding_modal::*;
-pub use unavailable_editing_tooltip::*;
 pub use usage_callout::*;

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

@@ -222,8 +222,8 @@ impl Render for AcpOnboardingModal {
                 acp_onboarding_event!("Canceled", trigger = "Action");
                 cx.emit(DismissEvent);
             }))
-            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
-                this.focus_handle.focus(window);
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+                this.focus_handle.focus(window, cx);
             }))
             .child(illustration)
             .child(

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

@@ -230,8 +230,8 @@ impl Render for ClaudeCodeOnboardingModal {
                 claude_code_onboarding_event!("Canceled", trigger = "Action");
                 cx.emit(DismissEvent);
             }))
-            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
-                this.focus_handle.focus(window);
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+                this.focus_handle.focus(window, cx);
             }))
             .child(illustration)
             .child(

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

@@ -0,0 +1,189 @@
+use gpui::{Action, FocusHandle, prelude::*};
+use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
+
+enum ModelIcon {
+    Name(IconName),
+    Path(SharedString),
+}
+
+#[derive(IntoElement)]
+pub struct ModelSelectorHeader {
+    title: SharedString,
+    has_border: bool,
+}
+
+impl ModelSelectorHeader {
+    pub fn new(title: impl Into<SharedString>, has_border: bool) -> Self {
+        Self {
+            title: title.into(),
+            has_border,
+        }
+    }
+}
+
+impl RenderOnce for ModelSelectorHeader {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        div()
+            .px_2()
+            .pb_1()
+            .when(self.has_border, |this| {
+                this.mt_1()
+                    .pt_2()
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border_variant)
+            })
+            .child(
+                Label::new(self.title)
+                    .size(LabelSize::XSmall)
+                    .color(Color::Muted),
+            )
+    }
+}
+
+#[derive(IntoElement)]
+pub struct ModelSelectorListItem {
+    index: usize,
+    title: SharedString,
+    icon: Option<ModelIcon>,
+    is_selected: bool,
+    is_focused: bool,
+    is_favorite: bool,
+    on_toggle_favorite: Option<Box<dyn Fn(&App) + 'static>>,
+}
+
+impl ModelSelectorListItem {
+    pub fn new(index: usize, title: impl Into<SharedString>) -> Self {
+        Self {
+            index,
+            title: title.into(),
+            icon: None,
+            is_selected: false,
+            is_focused: false,
+            is_favorite: false,
+            on_toggle_favorite: None,
+        }
+    }
+
+    pub fn icon(mut self, icon: IconName) -> Self {
+        self.icon = Some(ModelIcon::Name(icon));
+        self
+    }
+
+    pub fn icon_path(mut self, path: SharedString) -> Self {
+        self.icon = Some(ModelIcon::Path(path));
+        self
+    }
+
+    pub fn is_selected(mut self, is_selected: bool) -> Self {
+        self.is_selected = is_selected;
+        self
+    }
+
+    pub fn is_focused(mut self, is_focused: bool) -> Self {
+        self.is_focused = is_focused;
+        self
+    }
+
+    pub fn is_favorite(mut self, is_favorite: bool) -> Self {
+        self.is_favorite = is_favorite;
+        self
+    }
+
+    pub fn on_toggle_favorite(mut self, handler: impl Fn(&App) + 'static) -> Self {
+        self.on_toggle_favorite = Some(Box::new(handler));
+        self
+    }
+}
+
+impl RenderOnce for ModelSelectorListItem {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let model_icon_color = if self.is_selected {
+            Color::Accent
+        } else {
+            Color::Muted
+        };
+
+        let is_favorite = self.is_favorite;
+
+        ListItem::new(self.index)
+            .inset(true)
+            .spacing(ListItemSpacing::Sparse)
+            .toggle_state(self.is_focused)
+            .child(
+                h_flex()
+                    .w_full()
+                    .gap_1p5()
+                    .when_some(self.icon, |this, icon| {
+                        this.child(
+                            match icon {
+                                ModelIcon::Name(icon_name) => Icon::new(icon_name),
+                                ModelIcon::Path(icon_path) => Icon::from_external_svg(icon_path),
+                            }
+                            .color(model_icon_color)
+                            .size(IconSize::Small),
+                        )
+                    })
+                    .child(Label::new(self.title).truncate()),
+            )
+            .end_slot(div().pr_2().when(self.is_selected, |this| {
+                this.child(Icon::new(IconName::Check).color(Color::Accent))
+            }))
+            .end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, {
+                |this, handle_click| {
+                    let (icon, color, tooltip) = if is_favorite {
+                        (IconName::StarFilled, Color::Accent, "Unfavorite Model")
+                    } else {
+                        (IconName::Star, Color::Default, "Favorite Model")
+                    };
+                    this.child(
+                        IconButton::new(("toggle-favorite", self.index), icon)
+                            .layer(ElevationIndex::ElevatedSurface)
+                            .icon_color(color)
+                            .icon_size(IconSize::Small)
+                            .tooltip(Tooltip::text(tooltip))
+                            .on_click(move |_, _, cx| (handle_click)(cx)),
+                    )
+                }
+            }))
+    }
+}
+
+#[derive(IntoElement)]
+pub struct ModelSelectorFooter {
+    action: Box<dyn Action>,
+    focus_handle: FocusHandle,
+}
+
+impl ModelSelectorFooter {
+    pub fn new(action: Box<dyn Action>, focus_handle: FocusHandle) -> Self {
+        Self {
+            action,
+            focus_handle,
+        }
+    }
+}
+
+impl RenderOnce for ModelSelectorFooter {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let action = self.action;
+        let focus_handle = self.focus_handle;
+
+        h_flex()
+            .w_full()
+            .p_1p5()
+            .border_t_1()
+            .border_color(cx.theme().colors().border_variant)
+            .child(
+                Button::new("configure", "Configure")
+                    .full_width()
+                    .style(ButtonStyle::Outlined)
+                    .key_binding(
+                        KeyBinding::for_action_in(action.as_ref(), &focus_handle, cx)
+                            .map(|kb| kb.size(rems_from_px(12.))),
+                    )
+                    .on_click(move |_, window, cx| {
+                        window.dispatch_action(action.boxed_clone(), cx);
+                    }),
+            )
+    }
+}

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

@@ -83,8 +83,8 @@ impl Render for AgentOnboardingModal {
                 agent_onboarding_event!("Canceled", trigger = "Action");
                 cx.emit(DismissEvent);
             }))
-            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
-                this.focus_handle.focus(window);
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+                this.focus_handle.focus(window, cx);
             }))
             .child(
                 div()

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/Cargo.toml 🔗

@@ -12,6 +12,10 @@ workspace = true
 path = "src/agent_ui_v2.rs"
 doctest = false
 
+[features]
+test-support = ["agent/test-support"]
+
+
 [dependencies]
 agent.workspace = true
 agent_servers.workspace = true
@@ -38,3 +42,6 @@ time_format.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
+
+[dev-dependencies]
+agent = { workspace = true, features = ["test-support"] }

crates/agent_ui_v2/src/thread_history.rs 🔗

@@ -1,5 +1,5 @@
 use agent::{HistoryEntry, HistoryStore};
-use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
 use editor::{Editor, EditorEvent};
 use fuzzy::StringMatchCandidate;
 use gpui::{
@@ -411,7 +411,22 @@ impl AcpThreadHistory {
         let selected = ix == self.selected_index;
         let hovered = Some(ix) == self.hovered_index;
         let timestamp = entry.updated_at().timestamp();
-        let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
+
+        let display_text = match format {
+            EntryTimeFormat::DateAndTime => {
+                let entry_time = entry.updated_at();
+                let now = Utc::now();
+                let duration = now.signed_duration_since(entry_time);
+                let days = duration.num_days();
+
+                format!("{}d", days)
+            }
+            EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
+        };
+
+        let title = entry.title().clone();
+        let full_date =
+            EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
 
         h_flex()
             .w_full()
@@ -432,11 +447,14 @@ impl AcpThreadHistory {
                                     .truncate(),
                             )
                             .child(
-                                Label::new(thread_timestamp)
+                                Label::new(display_text)
                                     .color(Color::Muted)
                                     .size(LabelSize::XSmall),
                             ),
                     )
+                    .tooltip(move |_, cx| {
+                        Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
+                    })
                     .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
                         if *is_hovered {
                             this.hovered_index = Some(ix);

crates/ai_onboarding/src/agent_api_keys_onboarding.rs 🔗

@@ -1,9 +1,9 @@
 use gpui::{Action, IntoElement, ParentElement, RenderOnce, point};
-use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
+use language_model::{IconOrSvg, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
 use ui::{Divider, List, ListBulletItem, prelude::*};
 
 pub struct ApiKeysWithProviders {
-    configured_providers: Vec<(IconName, SharedString)>,
+    configured_providers: Vec<(IconOrSvg, SharedString)>,
 }
 
 impl ApiKeysWithProviders {
@@ -13,7 +13,8 @@ impl ApiKeysWithProviders {
             |this: &mut Self, _registry, event: &language_model::Event, cx| match event {
                 language_model::Event::ProviderStateChanged(_)
                 | language_model::Event::AddedProvider(_)
-                | language_model::Event::RemovedProvider(_) => {
+                | language_model::Event::RemovedProvider(_)
+                | language_model::Event::ProvidersChanged => {
                     this.configured_providers = Self::compute_configured_providers(cx)
                 }
                 _ => {}
@@ -26,9 +27,9 @@ impl ApiKeysWithProviders {
         }
     }
 
-    fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> {
+    fn compute_configured_providers(cx: &App) -> Vec<(IconOrSvg, SharedString)> {
         LanguageModelRegistry::read_global(cx)
-            .providers()
+            .visible_providers()
             .iter()
             .filter(|provider| {
                 provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
@@ -47,7 +48,14 @@ impl Render for ApiKeysWithProviders {
                 .map(|(icon, name)| {
                     h_flex()
                         .gap_1p5()
-                        .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
+                        .child(
+                            match icon {
+                                IconOrSvg::Icon(icon_name) => Icon::new(icon_name),
+                                IconOrSvg::Svg(icon_path) => Icon::from_external_svg(icon_path),
+                            }
+                            .size(IconSize::XSmall)
+                            .color(Color::Muted),
+                        )
                         .child(Label::new(name))
                 });
         div()

crates/ai_onboarding/src/agent_panel_onboarding_content.rs 🔗

@@ -11,7 +11,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding};
 pub struct AgentPanelOnboarding {
     user_store: Entity<UserStore>,
     client: Arc<Client>,
-    configured_providers: Vec<(IconName, SharedString)>,
+    has_configured_providers: bool,
     continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
 }
 
@@ -27,8 +27,9 @@ impl AgentPanelOnboarding {
             |this: &mut Self, _registry, event: &language_model::Event, cx| match event {
                 language_model::Event::ProviderStateChanged(_)
                 | language_model::Event::AddedProvider(_)
-                | language_model::Event::RemovedProvider(_) => {
-                    this.configured_providers = Self::compute_available_providers(cx)
+                | language_model::Event::RemovedProvider(_)
+                | language_model::Event::ProvidersChanged => {
+                    this.has_configured_providers = Self::has_configured_providers(cx)
                 }
                 _ => {}
             },
@@ -38,20 +39,16 @@ impl AgentPanelOnboarding {
         Self {
             user_store,
             client,
-            configured_providers: Self::compute_available_providers(cx),
+            has_configured_providers: Self::has_configured_providers(cx),
             continue_with_zed_ai: Arc::new(continue_with_zed_ai),
         }
     }
 
-    fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> {
+    fn has_configured_providers(cx: &App) -> bool {
         LanguageModelRegistry::read_global(cx)
-            .providers()
+            .visible_providers()
             .iter()
-            .filter(|provider| {
-                provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
-            })
-            .map(|provider| (provider.icon(), provider.name().0))
-            .collect()
+            .any(|provider| provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID)
     }
 }
 
@@ -81,7 +78,7 @@ impl Render for AgentPanelOnboarding {
                 }),
             )
             .map(|this| {
-                if enrolled_in_trial || is_pro_user || !self.configured_providers.is_empty() {
+                if enrolled_in_trial || is_pro_user || self.has_configured_providers {
                     this
                 } else {
                     this.child(ApiKeysWithoutProviders::new())

crates/anthropic/src/anthropic.rs 🔗

@@ -1052,6 +1052,71 @@ pub fn parse_prompt_too_long(message: &str) -> Option<u64> {
         .ok()
 }
 
+/// Request body for the token counting API.
+/// Similar to `Request` but without `max_tokens` since it's not needed for counting.
+#[derive(Debug, Serialize)]
+pub struct CountTokensRequest {
+    pub model: String,
+    pub messages: Vec<Message>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub system: Option<StringOrContents>,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub tools: Vec<Tool>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub thinking: Option<Thinking>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub tool_choice: Option<ToolChoice>,
+}
+
+/// Response from the token counting API.
+#[derive(Debug, Deserialize)]
+pub struct CountTokensResponse {
+    pub input_tokens: u64,
+}
+
+/// Count the number of tokens in a message without creating it.
+pub async fn count_tokens(
+    client: &dyn HttpClient,
+    api_url: &str,
+    api_key: &str,
+    request: CountTokensRequest,
+) -> Result<CountTokensResponse, AnthropicError> {
+    let uri = format!("{api_url}/v1/messages/count_tokens");
+
+    let request_builder = HttpRequest::builder()
+        .method(Method::POST)
+        .uri(uri)
+        .header("Anthropic-Version", "2023-06-01")
+        .header("X-Api-Key", api_key.trim())
+        .header("Content-Type", "application/json");
+
+    let serialized_request =
+        serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?;
+    let http_request = request_builder
+        .body(AsyncBody::from(serialized_request))
+        .map_err(AnthropicError::BuildRequestBody)?;
+
+    let mut response = client
+        .send(http_request)
+        .await
+        .map_err(AnthropicError::HttpSend)?;
+
+    let rate_limits = RateLimitInfo::from_headers(response.headers());
+
+    if response.status().is_success() {
+        let mut body = String::new();
+        response
+            .body_mut()
+            .read_to_string(&mut body)
+            .await
+            .map_err(AnthropicError::ReadResponse)?;
+
+        serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse)
+    } else {
+        Err(handle_error_response(response, rate_limits).await)
+    }
+}
+
 #[test]
 fn test_match_window_exceeded() {
     let error = ApiError {

crates/bedrock/src/bedrock.rs 🔗

@@ -87,7 +87,7 @@ pub async fn stream_completion(
                 Ok(None) => None,
                 Err(err) => Some((
                     Err(BedrockError::ClientError(anyhow!(
-                        "{:?}",
+                        "{}",
                         aws_sdk_bedrockruntime::error::DisplayErrorContext(err)
                     ))),
                     stream,

crates/buffer_diff/src/buffer_diff.rs 🔗

@@ -1159,6 +1159,34 @@ impl BufferDiff {
         new_index_text
     }
 
+    pub fn stage_or_unstage_all_hunks(
+        &mut self,
+        stage: bool,
+        buffer: &text::BufferSnapshot,
+        file_exists: bool,
+        cx: &mut Context<Self>,
+    ) {
+        let hunks = self
+            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx)
+            .collect::<Vec<_>>();
+        let Some(secondary) = self.secondary_diff.as_ref() else {
+            return;
+        };
+        self.inner.stage_or_unstage_hunks_impl(
+            &secondary.read(cx).inner,
+            stage,
+            &hunks,
+            buffer,
+            file_exists,
+        );
+        if let Some((first, last)) = hunks.first().zip(hunks.last()) {
+            let changed_range = first.buffer_range.start..last.buffer_range.end;
+            cx.emit(BufferDiffEvent::DiffChanged {
+                changed_range: Some(changed_range),
+            });
+        }
+    }
+
     pub fn range_to_hunk_range(
         &self,
         range: Range<Anchor>,
@@ -2155,7 +2183,7 @@ mod tests {
         let range = diff_1.inner.compare(&empty_diff.inner, &buffer).unwrap();
         assert_eq!(range.to_point(&buffer), Point::new(0, 0)..Point::new(8, 0));
 
-        // Edit does not affect the diff.
+        // Edit does affects the diff because it recalculates word diffs.
         buffer.edit_via_marked_text(
             &"
                 one
@@ -2170,7 +2198,14 @@ mod tests {
             .unindent(),
         );
         let diff_2 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx);
-        assert_eq!(None, diff_2.inner.compare(&diff_1.inner, &buffer));
+        assert_eq!(
+            Point::new(4, 0)..Point::new(5, 0),
+            diff_2
+                .inner
+                .compare(&diff_1.inner, &buffer)
+                .unwrap()
+                .to_point(&buffer)
+        );
 
         // Edit turns a deletion hunk into a modification.
         buffer.edit_via_marked_text(

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/codestral/src/codestral.rs 🔗

@@ -1,6 +1,6 @@
 use anyhow::{Context as _, Result};
 use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions};
-use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate};
+use edit_prediction_types::{EditPrediction, EditPredictionDelegate};
 use futures::AsyncReadExt;
 use gpui::{App, Context, Entity, Task};
 use http_client::HttpClient;
@@ -300,16 +300,6 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate {
         }));
     }
 
-    fn cycle(
-        &mut self,
-        _buffer: Entity<Buffer>,
-        _cursor_position: Anchor,
-        _direction: Direction,
-        _cx: &mut Context<Self>,
-    ) {
-        // Codestral doesn't support multiple completions, so cycling does nothing
-    }
-
     fn accept(&mut self, _cx: &mut Context<Self>) {
         log::debug!("Codestral: Completion accepted");
         self.pending_request = 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,71 @@ 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;
+    /// The alias of the GitHub copilot user. Although https://api.github.com/users/copilot
+    /// yields a 404, GitHub still refers to the copilot bot user as @Copilot in some cases.
+    const NAME_ALIAS: &'static str = "copilot";
+
+    /// 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 || github_login == Self::NAME_ALIAS
+            }
+            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/collab/src/tests/remote_editing_collaboration_tests.rs 🔗

@@ -4,6 +4,7 @@ use collections::{HashMap, HashSet};
 
 use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling};
 use debugger_ui::debugger_panel::DebugPanel;
+use editor::{Editor, EditorMode, MultiBuffer};
 use extension::ExtensionHostProxy;
 use fs::{FakeFs, Fs as _, RemoveOptions};
 use futures::StreamExt as _;
@@ -12,22 +13,30 @@ use http_client::BlockedHttpClient;
 use language::{
     FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry,
     language_settings::{Formatter, FormatterList, language_settings},
-    tree_sitter_typescript,
+    rust_lang, tree_sitter_typescript,
 };
 use node_runtime::NodeRuntime;
 use project::{
     ProjectPath,
     debugger::session::ThreadId,
     lsp_store::{FormatTrigger, LspFormatTarget},
+    trusted_worktrees::{PathTrust, TrustedWorktrees},
 };
 use remote::RemoteClient;
 use remote_server::{HeadlessAppState, HeadlessProject};
 use rpc::proto;
 use serde_json::json;
-use settings::{LanguageServerFormatterSpecifier, PrettierSettingsContent, SettingsStore};
+use settings::{
+    InlayHintSettingsContent, LanguageServerFormatterSpecifier, PrettierSettingsContent,
+    SettingsStore,
+};
 use std::{
     path::Path,
-    sync::{Arc, atomic::AtomicUsize},
+    sync::{
+        Arc,
+        atomic::{AtomicUsize, Ordering},
+    },
+    time::Duration,
 };
 use task::TcpArgumentsTemplate;
 use util::{path, rel_path::rel_path};
@@ -90,13 +99,14 @@ async fn test_sharing_an_ssh_remote_project(
                 languages,
                 extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
             },
+            false,
             cx,
         )
     });
 
     let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
     let (project_a, worktree_id) = client_a
-        .build_ssh_project(path!("/code/project1"), client_ssh, cx_a)
+        .build_ssh_project(path!("/code/project1"), client_ssh, false, cx_a)
         .await;
 
     // While the SSH worktree is being scanned, user A shares the remote project.
@@ -250,13 +260,14 @@ async fn test_ssh_collaboration_git_branches(
                 languages,
                 extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
             },
+            false,
             cx,
         )
     });
 
     let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
     let (project_a, _) = client_a
-        .build_ssh_project("/project", client_ssh, cx_a)
+        .build_ssh_project("/project", client_ssh, false, cx_a)
         .await;
 
     // While the SSH worktree is being scanned, user A shares the remote project.
@@ -454,13 +465,14 @@ async fn test_ssh_collaboration_formatting_with_prettier(
                 languages,
                 extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
             },
+            false,
             cx,
         )
     });
 
     let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
     let (project_a, worktree_id) = client_a
-        .build_ssh_project(path!("/project"), client_ssh, cx_a)
+        .build_ssh_project(path!("/project"), client_ssh, false, cx_a)
         .await;
 
     // While the SSH worktree is being scanned, user A shares the remote project.
@@ -615,6 +627,7 @@ async fn test_remote_server_debugger(
                 languages,
                 extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
             },
+            false,
             cx,
         )
     });
@@ -627,7 +640,7 @@ async fn test_remote_server_debugger(
         command_palette_hooks::init(cx);
     });
     let (project_a, _) = client_a
-        .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
+        .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
         .await;
 
     let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
@@ -723,6 +736,7 @@ async fn test_slow_adapter_startup_retries(
                 languages,
                 extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
             },
+            false,
             cx,
         )
     });
@@ -735,7 +749,7 @@ async fn test_slow_adapter_startup_retries(
         command_palette_hooks::init(cx);
     });
     let (project_a, _) = client_a
-        .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
+        .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
         .await;
 
     let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
@@ -838,3 +852,261 @@ async fn test_slow_adapter_startup_retries(
 
     shutdown_session.await.unwrap();
 }
+
+#[gpui::test]
+async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &mut TestAppContext) {
+    use project::trusted_worktrees::RemoteHostLocation;
+
+    cx_a.update(|cx| {
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+        project::trusted_worktrees::init(HashMap::default(), None, None, cx);
+    });
+    server_cx.update(|cx| {
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+        project::trusted_worktrees::init(HashMap::default(), None, None, cx);
+    });
+
+    let mut server = TestServer::start(cx_a.executor().clone()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+
+    let server_name = "override-rust-analyzer";
+    let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0));
+
+    let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
+    let remote_fs = FakeFs::new(server_cx.executor());
+    remote_fs
+        .insert_tree(
+            path!("/projects"),
+            json!({
+                "project_a": {
+                    ".zed": {
+                        "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
+                    },
+                    "main.rs": "fn main() {}"
+                },
+                "project_b": { "lib.rs": "pub fn lib() {}" }
+            }),
+        )
+        .await;
+
+    server_cx.update(HeadlessProject::init);
+    let remote_http_client = Arc::new(BlockedHttpClient);
+    let node = NodeRuntime::unavailable();
+    let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
+    languages.add(rust_lang());
+
+    let capabilities = lsp::ServerCapabilities {
+        inlay_hint_provider: Some(lsp::OneOf::Left(true)),
+        ..lsp::ServerCapabilities::default()
+    };
+    let mut fake_language_servers = languages.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            name: server_name,
+            capabilities: capabilities.clone(),
+            initializer: Some(Box::new({
+                let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+                move |fake_server| {
+                    let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+                    fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>(
+                        move |_params, _| {
+                            lsp_inlay_hint_request_count.fetch_add(1, Ordering::Release);
+                            async move {
+                                Ok(Some(vec![lsp::InlayHint {
+                                    position: lsp::Position::new(0, 0),
+                                    label: lsp::InlayHintLabel::String("hint".to_string()),
+                                    kind: None,
+                                    text_edits: None,
+                                    tooltip: None,
+                                    padding_left: None,
+                                    padding_right: None,
+                                    data: None,
+                                }]))
+                            }
+                        },
+                    );
+                }
+            })),
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    let _headless_project = server_cx.new(|cx| {
+        HeadlessProject::new(
+            HeadlessAppState {
+                session: server_ssh,
+                fs: remote_fs.clone(),
+                http_client: remote_http_client,
+                node_runtime: node,
+                languages,
+                extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
+            },
+            true,
+            cx,
+        )
+    });
+
+    let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
+    let (project_a, worktree_id_a) = client_a
+        .build_ssh_project(path!("/projects/project_a"), client_ssh.clone(), true, cx_a)
+        .await;
+
+    cx_a.update(|cx| {
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |settings| {
+                let language_settings = &mut settings.project.all_languages.defaults;
+                language_settings.inlay_hints = Some(InlayHintSettingsContent {
+                    enabled: Some(true),
+                    ..InlayHintSettingsContent::default()
+                })
+            });
+        });
+    });
+
+    project_a
+        .update(cx_a, |project, cx| {
+            project.languages().add(rust_lang());
+            project.languages().register_fake_lsp_adapter(
+                "Rust",
+                FakeLspAdapter {
+                    name: server_name,
+                    capabilities,
+                    ..FakeLspAdapter::default()
+                },
+            );
+            project.find_or_create_worktree(path!("/projects/project_b"), true, cx)
+        })
+        .await
+        .unwrap();
+
+    cx_a.run_until_parked();
+
+    let worktree_ids = project_a.read_with(cx_a, |project, cx| {
+        project
+            .worktrees(cx)
+            .map(|wt| wt.read(cx).id())
+            .collect::<Vec<_>>()
+    });
+    assert_eq!(worktree_ids.len(), 2);
+
+    let remote_host = project_a.read_with(cx_a, |project, cx| {
+        project
+            .remote_connection_options(cx)
+            .map(RemoteHostLocation::from)
+    });
+
+    let trusted_worktrees =
+        cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
+
+    let can_trust_a =
+        trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
+    let can_trust_b =
+        trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
+    assert!(!can_trust_a, "project_a should be restricted initially");
+    assert!(!can_trust_b, "project_b should be restricted initially");
+
+    let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store());
+    let has_restricted = trusted_worktrees.read_with(cx_a, |store, cx| {
+        store.has_restricted_worktrees(&worktree_store, cx)
+    });
+    assert!(has_restricted, "should have restricted worktrees");
+
+    let buffer_before_approval = project_a
+        .update(cx_a, |project, cx| {
+            project.open_buffer((worktree_id_a, rel_path("main.rs")), cx)
+        })
+        .await
+        .unwrap();
+
+    let (editor, cx_a) = cx_a.add_window_view(|window, cx| {
+        Editor::new(
+            EditorMode::full(),
+            cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)),
+            Some(project_a.clone()),
+            window,
+            cx,
+        )
+    });
+    cx_a.run_until_parked();
+    let fake_language_server = fake_language_servers.next();
+
+    cx_a.read(|cx| {
+        let file = buffer_before_approval.read(cx).file();
+        assert_eq!(
+            language_settings(Some("Rust".into()), file, cx).language_servers,
+            ["...".to_string()],
+            "remote .zed/settings.json must not sync before trust approval"
+        )
+    });
+
+    editor.update_in(cx_a, |editor, window, cx| {
+        editor.handle_input("1", window, cx);
+    });
+    cx_a.run_until_parked();
+    cx_a.executor().advance_clock(Duration::from_secs(1));
+    assert_eq!(
+        lsp_inlay_hint_request_count.load(Ordering::Acquire),
+        0,
+        "inlay hints must not be queried before trust approval"
+    );
+
+    trusted_worktrees.update(cx_a, |store, cx| {
+        store.trust(
+            HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]),
+            remote_host.clone(),
+            cx,
+        );
+    });
+    cx_a.run_until_parked();
+
+    cx_a.read(|cx| {
+        let file = buffer_before_approval.read(cx).file();
+        assert_eq!(
+            language_settings(Some("Rust".into()), file, cx).language_servers,
+            ["override-rust-analyzer".to_string()],
+            "remote .zed/settings.json should sync after trust approval"
+        )
+    });
+    let _fake_language_server = fake_language_server.await.unwrap();
+    editor.update_in(cx_a, |editor, window, cx| {
+        editor.handle_input("1", window, cx);
+    });
+    cx_a.run_until_parked();
+    cx_a.executor().advance_clock(Duration::from_secs(1));
+    assert!(
+        lsp_inlay_hint_request_count.load(Ordering::Acquire) > 0,
+        "inlay hints should be queried after trust approval"
+    );
+
+    let can_trust_a =
+        trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
+    let can_trust_b =
+        trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
+    assert!(can_trust_a, "project_a should be trusted after trust()");
+    assert!(!can_trust_b, "project_b should still be restricted");
+
+    trusted_worktrees.update(cx_a, |store, cx| {
+        store.trust(
+            HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
+            remote_host.clone(),
+            cx,
+        );
+    });
+
+    let can_trust_a =
+        trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
+    let can_trust_b =
+        trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
+    assert!(can_trust_a, "project_a should remain trusted");
+    assert!(can_trust_b, "project_b should now be trusted");
+
+    let has_restricted_after = trusted_worktrees.read_with(cx_a, |store, cx| {
+        store.has_restricted_worktrees(&worktree_store, cx)
+    });
+    assert!(
+        !has_restricted_after,
+        "should have no restricted worktrees after trusting both"
+    );
+}

crates/collab/src/tests/test_server.rs 🔗

@@ -761,6 +761,7 @@ impl TestClient {
         &self,
         root_path: impl AsRef<Path>,
         ssh: Entity<RemoteClient>,
+        init_worktree_trust: bool,
         cx: &mut TestAppContext,
     ) -> (Entity<Project>, WorktreeId) {
         let project = cx.update(|cx| {
@@ -771,6 +772,7 @@ impl TestClient {
                 self.app_state.user_store.clone(),
                 self.app_state.languages.clone(),
                 self.app_state.fs.clone(),
+                init_worktree_trust,
                 cx,
             )
         });
@@ -839,6 +841,7 @@ impl TestClient {
                 self.app_state.languages.clone(),
                 self.app_state.fs.clone(),
                 None,
+                false,
                 cx,
             )
         })

crates/collab_ui/src/collab_panel.rs 🔗

@@ -1252,7 +1252,7 @@ impl CollabPanel {
             context_menu
         });
 
-        window.focus(&context_menu.focus_handle(cx));
+        window.focus(&context_menu.focus_handle(cx), cx);
         let subscription = cx.subscribe_in(
             &context_menu,
             window,
@@ -1424,7 +1424,7 @@ impl CollabPanel {
             context_menu
         });
 
-        window.focus(&context_menu.focus_handle(cx));
+        window.focus(&context_menu.focus_handle(cx), cx);
         let subscription = cx.subscribe_in(
             &context_menu,
             window,
@@ -1487,7 +1487,7 @@ impl CollabPanel {
             })
         });
 
-        window.focus(&context_menu.focus_handle(cx));
+        window.focus(&context_menu.focus_handle(cx), cx);
         let subscription = cx.subscribe_in(
             &context_menu,
             window,
@@ -1521,9 +1521,9 @@ impl CollabPanel {
         if cx.stop_active_drag(window) {
             return;
         } else if self.take_editing_state(window, cx) {
-            window.focus(&self.filter_editor.focus_handle(cx));
+            window.focus(&self.filter_editor.focus_handle(cx), cx);
         } else if !self.reset_filter_editor_text(window, cx) {
-            self.focus_handle.focus(window);
+            self.focus_handle.focus(window, cx);
         }
 
         if self.context_menu.is_some() {
@@ -1826,7 +1826,7 @@ impl CollabPanel {
         });
         self.update_entries(false, cx);
         self.select_channel_editor();
-        window.focus(&self.channel_name_editor.focus_handle(cx));
+        window.focus(&self.channel_name_editor.focus_handle(cx), cx);
         cx.notify();
     }
 
@@ -1851,7 +1851,7 @@ impl CollabPanel {
         });
         self.update_entries(false, cx);
         self.select_channel_editor();
-        window.focus(&self.channel_name_editor.focus_handle(cx));
+        window.focus(&self.channel_name_editor.focus_handle(cx), cx);
         cx.notify();
     }
 
@@ -1900,7 +1900,7 @@ impl CollabPanel {
                 editor.set_text(channel.name.clone(), window, cx);
                 editor.select_all(&Default::default(), window, cx);
             });
-            window.focus(&self.channel_name_editor.focus_handle(cx));
+            window.focus(&self.channel_name_editor.focus_handle(cx), cx);
             self.update_entries(false, cx);
             self.select_channel_editor();
         }

crates/collab_ui/src/collab_panel/channel_modal.rs 🔗

@@ -642,7 +642,7 @@ impl ChannelModalDelegate {
             });
             menu
         });
-        window.focus(&context_menu.focus_handle(cx));
+        window.focus(&context_menu.focus_handle(cx), cx);
         let subscription = cx.subscribe_in(
             &context_menu,
             window,

crates/command_palette/src/command_palette.rs 🔗

@@ -588,7 +588,7 @@ impl PickerDelegate for CommandPaletteDelegate {
         })
         .detach_and_log_err(cx);
         let action = command.action;
-        window.focus(&self.previous_focus_handle);
+        window.focus(&self.previous_focus_handle, cx);
         self.dismissed(window, cx);
         window.dispatch_action(action, cx);
     }
@@ -784,7 +784,7 @@ mod tests {
 
         workspace.update_in(cx, |workspace, window, cx| {
             workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
-            editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
+            editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
         });
 
         cx.simulate_keystrokes("cmd-shift-p");
@@ -855,7 +855,7 @@ mod tests {
 
         workspace.update_in(cx, |workspace, window, cx| {
             workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
-            editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
+            editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
         });
 
         // Test normalize (trimming whitespace and double colons)

crates/component_preview/Cargo.toml 🔗

@@ -0,0 +1,45 @@
+[package]
+name = "component_preview"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/component_preview.rs"
+
+[features]
+default = []
+preview = []
+test-support = ["db/test-support"]
+
+[dependencies]
+anyhow.workspace = true
+client.workspace = true
+collections.workspace = true
+component.workspace = true
+db.workspace = true
+fs.workspace = true
+gpui.workspace = true
+language.workspace = true
+log.workspace = true
+node_runtime.workspace = true
+notifications.workspace = true
+project.workspace = true
+release_channel.workspace = true
+reqwest_client.workspace = true
+session.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+ui_input.workspace = true
+uuid.workspace = true
+workspace.workspace = true
+
+[[example]]
+name = "component_preview"
+path = "examples/component_preview.rs"
+required-features = ["preview"]

crates/component_preview/examples/component_preview.rs 🔗

@@ -0,0 +1,18 @@
+//! Component Preview Example
+//!
+//! Run with: `cargo run -p component_preview --example component_preview --features="preview"`
+//!
+//! To use this in other projects, add the following to your `Cargo.toml`:
+//!
+//! ```toml
+//! [dependencies]
+//! component_preview = { path = "../component_preview", features = ["preview"] }
+//!
+//! [[example]]
+//! name = "component_preview"
+//! path = "examples/component_preview.rs"
+//! ```
+
+fn main() {
+    component_preview::run_component_preview();
+}

crates/zed/src/zed/component_preview.rs → crates/component_preview/src/component_preview.rs 🔗

@@ -1,7 +1,4 @@
-//! # Component Preview
-//!
-//! A view for exploring Zed components.
-
+mod component_preview_example;
 mod persistence;
 
 use client::UserStore;
@@ -11,18 +8,21 @@ use gpui::{
     App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
 };
 use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
-use languages::LanguageRegistry;
+use language::LanguageRegistry;
 use notifications::status_toast::{StatusToast, ToastIcon};
 use persistence::COMPONENT_PREVIEW_DB;
 use project::Project;
 use std::{iter::Iterator, ops::Range, sync::Arc};
 use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*};
 use ui_input::InputField;
+use workspace::AppState;
 use workspace::{
-    AppState, Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items,
-    item::ItemEvent,
+    Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent,
 };
 
+#[allow(unused_imports)]
+pub use component_preview_example::*;
+
 pub fn init(app_state: Arc<AppState>, cx: &mut App) {
     workspace::register_serializable_item::<ComponentPreview>(cx);
 
@@ -161,7 +161,7 @@ impl ComponentPreview {
         component_preview.update_component_list(cx);
 
         let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx);
-        window.focus(&focus_handle);
+        window.focus(&focus_handle, cx);
 
         Ok(component_preview)
     }
@@ -770,7 +770,7 @@ impl Item for ComponentPreview {
         self.workspace_id = workspace.database_id();
 
         let focus_handle = self.filter_editor.read(cx).focus_handle(cx);
-        window.focus(&focus_handle);
+        window.focus(&focus_handle, cx);
     }
 }
 

crates/component_preview/src/component_preview_example.rs 🔗

@@ -0,0 +1,145 @@
+/// Run the component preview application.
+///
+/// This initializes the application with minimal required infrastructure
+/// and opens a workspace with the ComponentPreview item.
+#[cfg(feature = "preview")]
+pub fn run_component_preview() {
+    use fs::RealFs;
+    use gpui::{
+        AppContext as _, Application, Bounds, KeyBinding, WindowBounds, WindowOptions, actions,
+        size,
+    };
+
+    use client::{Client, UserStore};
+    use language::LanguageRegistry;
+    use node_runtime::NodeRuntime;
+    use project::Project;
+    use reqwest_client::ReqwestClient;
+    use session::{AppSession, Session};
+    use std::sync::Arc;
+    use ui::{App, px};
+    use workspace::{AppState, Workspace, WorkspaceStore};
+
+    use crate::{ComponentPreview, init};
+
+    actions!(zed, [Quit]);
+
+    fn quit(_: &Quit, cx: &mut App) {
+        cx.quit();
+    }
+
+    Application::new().run(|cx| {
+        component::init();
+
+        cx.on_action(quit);
+        cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
+        let version = release_channel::AppVersion::load(env!("CARGO_PKG_VERSION"), None, None);
+        release_channel::init(version, cx);
+
+        let http_client =
+            ReqwestClient::user_agent("component_preview").expect("Failed to create HTTP client");
+        cx.set_http_client(Arc::new(http_client));
+
+        let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
+        <dyn fs::Fs>::set_global(fs.clone(), cx);
+
+        settings::init(cx);
+        theme::init(theme::LoadThemes::JustBase, cx);
+
+        let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
+        let client = Client::production(cx);
+        client::init(&client, cx);
+
+        let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
+        let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx));
+        let session_id = uuid::Uuid::new_v4().to_string();
+        let session = cx.background_executor().block(Session::new(session_id));
+        let session = cx.new(|cx| AppSession::new(session, cx));
+        let node_runtime = NodeRuntime::unavailable();
+
+        let app_state = Arc::new(AppState {
+            languages,
+            client,
+            user_store,
+            workspace_store,
+            fs,
+            build_window_options: |_, _| Default::default(),
+            node_runtime,
+            session,
+        });
+        AppState::set_global(Arc::downgrade(&app_state), cx);
+
+        workspace::init(app_state.clone(), cx);
+        init(app_state.clone(), cx);
+
+        let size = size(px(1200.), px(800.));
+        let bounds = Bounds::centered(None, size, cx);
+
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            {
+                move |window, cx| {
+                    let app_state = app_state;
+                    theme::setup_ui_font(window, cx);
+
+                    let project = Project::local(
+                        app_state.client.clone(),
+                        app_state.node_runtime.clone(),
+                        app_state.user_store.clone(),
+                        app_state.languages.clone(),
+                        app_state.fs.clone(),
+                        None,
+                        false,
+                        cx,
+                    );
+
+                    let workspace = cx.new(|cx| {
+                        Workspace::new(
+                            Default::default(),
+                            project.clone(),
+                            app_state.clone(),
+                            window,
+                            cx,
+                        )
+                    });
+
+                    workspace.update(cx, |workspace, cx| {
+                        let weak_workspace = cx.entity().downgrade();
+                        let language_registry = app_state.languages.clone();
+                        let user_store = app_state.user_store.clone();
+
+                        let component_preview = cx.new(|cx| {
+                            ComponentPreview::new(
+                                weak_workspace,
+                                project,
+                                language_registry,
+                                user_store,
+                                None,
+                                None,
+                                window,
+                                cx,
+                            )
+                            .expect("Failed to create component preview")
+                        });
+
+                        workspace.add_item_to_active_pane(
+                            Box::new(component_preview),
+                            None,
+                            true,
+                            window,
+                            cx,
+                        );
+                    });
+
+                    workspace
+                }
+            },
+        )
+        .expect("Failed to open component preview window");
+
+        cx.activate(true);
+    });
+}

crates/context_server/Cargo.toml 🔗

@@ -29,6 +29,7 @@ schemars.workspace = true
 serde_json.workspace = true
 serde.workspace = true
 settings.workspace = true
+slotmap.workspace = true
 smol.workspace = true
 tempfile.workspace = true
 url = { workspace = true, features = ["serde"] }

crates/context_server/src/client.rs 🔗

@@ -6,6 +6,7 @@ use parking_lot::Mutex;
 use postage::barrier;
 use serde::{Deserialize, Serialize, de::DeserializeOwned};
 use serde_json::{Value, value::RawValue};
+use slotmap::SlotMap;
 use smol::channel;
 use std::{
     fmt,
@@ -50,7 +51,7 @@ pub(crate) struct Client {
     next_id: AtomicI32,
     outbound_tx: channel::Sender<String>,
     name: Arc<str>,
-    notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
+    subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
     response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
     #[allow(clippy::type_complexity)]
     #[allow(dead_code)]
@@ -191,21 +192,20 @@ impl Client {
         let (outbound_tx, outbound_rx) = channel::unbounded::<String>();
         let (output_done_tx, output_done_rx) = barrier::channel();
 
-        let notification_handlers =
-            Arc::new(Mutex::new(HashMap::<_, NotificationHandler>::default()));
+        let subscription_set = Arc::new(Mutex::new(NotificationSubscriptionSet::default()));
         let response_handlers =
             Arc::new(Mutex::new(Some(HashMap::<_, ResponseHandler>::default())));
         let request_handlers = Arc::new(Mutex::new(HashMap::<_, RequestHandler>::default()));
 
         let receive_input_task = cx.spawn({
-            let notification_handlers = notification_handlers.clone();
+            let subscription_set = subscription_set.clone();
             let response_handlers = response_handlers.clone();
             let request_handlers = request_handlers.clone();
             let transport = transport.clone();
             async move |cx| {
                 Self::handle_input(
                     transport,
-                    notification_handlers,
+                    subscription_set,
                     request_handlers,
                     response_handlers,
                     cx,
@@ -236,7 +236,7 @@ impl Client {
 
         Ok(Self {
             server_id,
-            notification_handlers,
+            subscription_set,
             response_handlers,
             name: server_name,
             next_id: Default::default(),
@@ -257,7 +257,7 @@ impl Client {
     /// to pending requests) and notifications (which trigger registered handlers).
     async fn handle_input(
         transport: Arc<dyn Transport>,
-        notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
+        subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
         request_handlers: Arc<Mutex<HashMap<&'static str, RequestHandler>>>,
         response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
         cx: &mut AsyncApp,
@@ -282,10 +282,11 @@ impl Client {
                     handler(Ok(message.to_string()));
                 }
             } else if let Ok(notification) = serde_json::from_str::<AnyNotification>(&message) {
-                let mut notification_handlers = notification_handlers.lock();
-                if let Some(handler) = notification_handlers.get_mut(notification.method.as_str()) {
-                    handler(notification.params.unwrap_or(Value::Null), cx.clone());
-                }
+                subscription_set.lock().notify(
+                    &notification.method,
+                    notification.params.unwrap_or(Value::Null),
+                    cx,
+                )
             } else {
                 log::error!("Unhandled JSON from context_server: {}", message);
             }
@@ -451,12 +452,18 @@ impl Client {
         Ok(())
     }
 
+    #[must_use]
     pub fn on_notification(
         &self,
         method: &'static str,
         f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
-    ) {
-        self.notification_handlers.lock().insert(method, f);
+    ) -> NotificationSubscription {
+        let mut notification_subscriptions = self.subscription_set.lock();
+
+        NotificationSubscription {
+            id: notification_subscriptions.add_handler(method, f),
+            set: self.subscription_set.clone(),
+        }
     }
 }
 
@@ -485,3 +492,73 @@ impl fmt::Debug for Client {
             .finish_non_exhaustive()
     }
 }
+
+slotmap::new_key_type! {
+    struct NotificationSubscriptionId;
+}
+
+#[derive(Default)]
+pub struct NotificationSubscriptionSet {
+    // we have very few subscriptions at the moment
+    methods: Vec<(&'static str, Vec<NotificationSubscriptionId>)>,
+    handlers: SlotMap<NotificationSubscriptionId, NotificationHandler>,
+}
+
+impl NotificationSubscriptionSet {
+    #[must_use]
+    fn add_handler(
+        &mut self,
+        method: &'static str,
+        handler: NotificationHandler,
+    ) -> NotificationSubscriptionId {
+        let id = self.handlers.insert(handler);
+        if let Some((_, handler_ids)) = self
+            .methods
+            .iter_mut()
+            .find(|(probe_method, _)| method == *probe_method)
+        {
+            debug_assert!(
+                handler_ids.len() < 20,
+                "Too many MCP handlers for {}. Consider using a different data structure.",
+                method
+            );
+
+            handler_ids.push(id);
+        } else {
+            self.methods.push((method, vec![id]));
+        };
+        id
+    }
+
+    fn notify(&mut self, method: &str, payload: Value, cx: &mut AsyncApp) {
+        let Some((_, handler_ids)) = self
+            .methods
+            .iter_mut()
+            .find(|(probe_method, _)| method == *probe_method)
+        else {
+            return;
+        };
+
+        for handler_id in handler_ids {
+            if let Some(handler) = self.handlers.get_mut(*handler_id) {
+                handler(payload.clone(), cx.clone());
+            }
+        }
+    }
+}
+
+pub struct NotificationSubscription {
+    id: NotificationSubscriptionId,
+    set: Arc<Mutex<NotificationSubscriptionSet>>,
+}
+
+impl Drop for NotificationSubscription {
+    fn drop(&mut self) {
+        let mut set = self.set.lock();
+        set.handlers.remove(self.id);
+        set.methods.retain_mut(|(_, handler_ids)| {
+            handler_ids.retain(|id| *id != self.id);
+            !handler_ids.is_empty()
+        });
+    }
+}

crates/context_server/src/context_server.rs 🔗

@@ -96,22 +96,6 @@ impl ContextServer {
         self.initialize(self.new_client(cx)?).await
     }
 
-    /// Starts the context server, making sure handlers are registered before initialization happens
-    pub async fn start_with_handlers(
-        &self,
-        notification_handlers: Vec<(
-            &'static str,
-            Box<dyn 'static + Send + FnMut(serde_json::Value, AsyncApp)>,
-        )>,
-        cx: &AsyncApp,
-    ) -> Result<()> {
-        let client = self.new_client(cx)?;
-        for (method, handler) in notification_handlers {
-            client.on_notification(method, handler);
-        }
-        self.initialize(client).await
-    }
-
     fn new_client(&self, cx: &AsyncApp) -> Result<Client> {
         Ok(match &self.configuration {
             ContextServerTransport::Stdio(command, working_directory) => Client::stdio(

crates/context_server/src/protocol.rs 🔗

@@ -12,7 +12,7 @@ use futures::channel::oneshot;
 use gpui::AsyncApp;
 use serde_json::Value;
 
-use crate::client::Client;
+use crate::client::{Client, NotificationSubscription};
 use crate::types::{self, Notification, Request};
 
 pub struct ModelContextProtocol {
@@ -119,7 +119,7 @@ impl InitializedContextServerProtocol {
         &self,
         method: &'static str,
         f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
-    ) {
-        self.inner.on_notification(method, f);
+    ) -> NotificationSubscription {
+        self.inner.on_notification(method, f)
     }
 }

crates/context_server/src/types.rs 🔗

@@ -330,7 +330,7 @@ pub struct PromptMessage {
     pub content: MessageContent,
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
 #[serde(rename_all = "lowercase")]
 pub enum Role {
     User,

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

@@ -4,6 +4,7 @@ pub mod copilot_responses;
 pub mod request;
 mod sign_in;
 
+use crate::request::NextEditSuggestions;
 use crate::sign_in::initiate_sign_out;
 use ::fs::Fs;
 use anyhow::{Context as _, Result, anyhow};
@@ -18,7 +19,7 @@ use http_client::HttpClient;
 use language::language_settings::CopilotSettings;
 use language::{
     Anchor, Bias, Buffer, BufferSnapshot, Language, PointUtf16, ToPointUtf16,
-    language_settings::{EditPredictionProvider, all_language_settings, language_settings},
+    language_settings::{EditPredictionProvider, all_language_settings},
     point_from_lsp, point_to_lsp,
 };
 use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServerName};
@@ -40,7 +41,7 @@ use std::{
     sync::Arc,
 };
 use sum_tree::Dimensions;
-use util::{ResultExt, fs::remove_matching, rel_path::RelPath};
+use util::{ResultExt, fs::remove_matching};
 use workspace::Workspace;
 
 pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate;
@@ -315,6 +316,15 @@ struct GlobalCopilot(Entity<Copilot>);
 
 impl Global for GlobalCopilot {}
 
+/// Copilot's NextEditSuggestion response, with coordinates converted to Anchors.
+struct CopilotEditPrediction {
+    buffer: Entity<Buffer>,
+    range: Range<Anchor>,
+    text: String,
+    command: Option<lsp::Command>,
+    snapshot: BufferSnapshot,
+}
+
 impl Copilot {
     pub fn global(cx: &App) -> Option<Entity<Self>> {
         cx.try_global::<GlobalCopilot>()
@@ -873,101 +883,19 @@ impl Copilot {
         }
     }
 
-    pub fn completions<T>(
-        &mut self,
-        buffer: &Entity<Buffer>,
-        position: T,
-        cx: &mut Context<Self>,
-    ) -> Task<Result<Vec<Completion>>>
-    where
-        T: ToPointUtf16,
-    {
-        self.request_completions::<request::GetCompletions, _>(buffer, position, cx)
-    }
-
-    pub fn completions_cycling<T>(
-        &mut self,
-        buffer: &Entity<Buffer>,
-        position: T,
-        cx: &mut Context<Self>,
-    ) -> Task<Result<Vec<Completion>>>
-    where
-        T: ToPointUtf16,
-    {
-        self.request_completions::<request::GetCompletionsCycling, _>(buffer, position, cx)
-    }
-
-    pub fn accept_completion(
-        &mut self,
-        completion: &Completion,
-        cx: &mut Context<Self>,
-    ) -> Task<Result<()>> {
-        let server = match self.server.as_authenticated() {
-            Ok(server) => server,
-            Err(error) => return Task::ready(Err(error)),
-        };
-        let request =
-            server
-                .lsp
-                .request::<request::NotifyAccepted>(request::NotifyAcceptedParams {
-                    uuid: completion.uuid.clone(),
-                });
-        cx.background_spawn(async move {
-            request
-                .await
-                .into_response()
-                .context("copilot: notify accepted")?;
-            Ok(())
-        })
-    }
-
-    pub fn discard_completions(
-        &mut self,
-        completions: &[Completion],
-        cx: &mut Context<Self>,
-    ) -> Task<Result<()>> {
-        let server = match self.server.as_authenticated() {
-            Ok(server) => server,
-            Err(_) => return Task::ready(Ok(())),
-        };
-        let request =
-            server
-                .lsp
-                .request::<request::NotifyRejected>(request::NotifyRejectedParams {
-                    uuids: completions
-                        .iter()
-                        .map(|completion| completion.uuid.clone())
-                        .collect(),
-                });
-        cx.background_spawn(async move {
-            request
-                .await
-                .into_response()
-                .context("copilot: notify rejected")?;
-            Ok(())
-        })
-    }
-
-    fn request_completions<R, T>(
+    pub(crate) fn completions(
         &mut self,
         buffer: &Entity<Buffer>,
-        position: T,
+        position: Anchor,
         cx: &mut Context<Self>,
-    ) -> Task<Result<Vec<Completion>>>
-    where
-        R: 'static
-            + lsp::request::Request<
-                Params = request::GetCompletionsParams,
-                Result = request::GetCompletionsResult,
-            >,
-        T: ToPointUtf16,
-    {
+    ) -> Task<Result<Vec<CopilotEditPrediction>>> {
         self.register_buffer(buffer, cx);
 
         let server = match self.server.as_authenticated() {
             Ok(server) => server,
             Err(error) => return Task::ready(Err(error)),
         };
+        let buffer_entity = buffer.clone();
         let lsp = server.lsp.clone();
         let registered_buffer = server
             .registered_buffers
@@ -977,46 +905,31 @@ impl Copilot {
         let buffer = buffer.read(cx);
         let uri = registered_buffer.uri.clone();
         let position = position.to_point_utf16(buffer);
-        let settings = language_settings(
-            buffer.language_at(position).map(|l| l.name()),
-            buffer.file(),
-            cx,
-        );
-        let tab_size = settings.tab_size;
-        let hard_tabs = settings.hard_tabs;
-        let relative_path = buffer
-            .file()
-            .map_or(RelPath::empty().into(), |file| file.path().clone());
 
         cx.background_spawn(async move {
             let (version, snapshot) = snapshot.await?;
             let result = lsp
-                .request::<R>(request::GetCompletionsParams {
-                    doc: request::GetCompletionsDocument {
-                        uri,
-                        tab_size: tab_size.into(),
-                        indent_size: 1,
-                        insert_spaces: !hard_tabs,
-                        relative_path: relative_path.to_proto(),
-                        position: point_to_lsp(position),
-                        version: version.try_into().unwrap(),
-                    },
+                .request::<NextEditSuggestions>(request::NextEditSuggestionsParams {
+                    text_document: lsp::VersionedTextDocumentIdentifier { uri, version },
+                    position: point_to_lsp(position),
                 })
                 .await
                 .into_response()
                 .context("copilot: get completions")?;
             let completions = result
-                .completions
+                .edits
                 .into_iter()
                 .map(|completion| {
                     let start = snapshot
                         .clip_point_utf16(point_from_lsp(completion.range.start), Bias::Left);
                     let end =
                         snapshot.clip_point_utf16(point_from_lsp(completion.range.end), Bias::Left);
-                    Completion {
-                        uuid: completion.uuid,
+                    CopilotEditPrediction {
+                        buffer: buffer_entity.clone(),
                         range: snapshot.anchor_before(start)..snapshot.anchor_after(end),
                         text: completion.text,
+                        command: completion.command,
+                        snapshot: snapshot.clone(),
                     }
                 })
                 .collect();
@@ -1024,6 +937,35 @@ impl Copilot {
         })
     }
 
+    pub(crate) fn accept_completion(
+        &mut self,
+        completion: &CopilotEditPrediction,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        let server = match self.server.as_authenticated() {
+            Ok(server) => server,
+            Err(error) => return Task::ready(Err(error)),
+        };
+        if let Some(command) = &completion.command {
+            let request = server
+                .lsp
+                .request::<lsp::ExecuteCommand>(lsp::ExecuteCommandParams {
+                    command: command.command.clone(),
+                    arguments: command.arguments.clone().unwrap_or_default(),
+                    ..Default::default()
+                });
+            cx.background_spawn(async move {
+                request
+                    .await
+                    .into_response()
+                    .context("copilot: notify accepted")?;
+                Ok(())
+            })
+        } else {
+            Task::ready(Ok(()))
+        }
+    }
+
     pub fn status(&self) -> Status {
         match &self.server {
             CopilotServer::Starting { task } => Status::Starting { task: task.clone() },
@@ -1246,7 +1188,10 @@ async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::
         .await;
     if should_install {
         node_runtime
-            .npm_install_packages(paths::copilot_dir(), &[(PACKAGE_NAME, &latest_version)])
+            .npm_install_packages(
+                paths::copilot_dir(),
+                &[(PACKAGE_NAME, &latest_version.to_string())],
+            )
             .await?;
     }
 
@@ -1257,7 +1202,11 @@ async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::
 mod tests {
     use super::*;
     use gpui::TestAppContext;
-    use util::{path, paths::PathStyle, rel_path::rel_path};
+    use util::{
+        path,
+        paths::PathStyle,
+        rel_path::{RelPath, rel_path},
+    };
 
     #[gpui::test(iterations = 10)]
     async fn test_buffer_management(cx: &mut TestAppContext) {

crates/copilot/src/copilot_edit_prediction_delegate.rs 🔗

@@ -1,49 +1,29 @@
-use crate::{Completion, Copilot};
+use crate::{Copilot, CopilotEditPrediction};
 use anyhow::Result;
-use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate};
-use gpui::{App, Context, Entity, EntityId, Task};
-use language::{Buffer, OffsetRangeExt, ToOffset, language_settings::AllLanguageSettings};
-use settings::Settings;
-use std::{path::Path, time::Duration};
+use edit_prediction_types::{EditPrediction, EditPredictionDelegate, interpolate_edits};
+use gpui::{App, Context, Entity, Task};
+use language::{Anchor, Buffer, EditPreview, OffsetRangeExt};
+use std::{ops::Range, sync::Arc, time::Duration};
 
 pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75);
 
 pub struct CopilotEditPredictionDelegate {
-    cycled: bool,
-    buffer_id: Option<EntityId>,
-    completions: Vec<Completion>,
-    active_completion_index: usize,
-    file_extension: Option<String>,
+    completion: Option<(CopilotEditPrediction, EditPreview)>,
     pending_refresh: Option<Task<Result<()>>>,
-    pending_cycling_refresh: Option<Task<Result<()>>>,
     copilot: Entity<Copilot>,
 }
 
 impl CopilotEditPredictionDelegate {
     pub fn new(copilot: Entity<Copilot>) -> Self {
         Self {
-            cycled: false,
-            buffer_id: None,
-            completions: Vec::new(),
-            active_completion_index: 0,
-            file_extension: None,
+            completion: None,
             pending_refresh: None,
-            pending_cycling_refresh: None,
             copilot,
         }
     }
 
-    fn active_completion(&self) -> Option<&Completion> {
-        self.completions.get(self.active_completion_index)
-    }
-
-    fn push_completion(&mut self, new_completion: Completion) {
-        for completion in &self.completions {
-            if completion.text == new_completion.text && completion.range == new_completion.range {
-                return;
-            }
-        }
-        self.completions.push(new_completion);
+    fn active_completion(&self) -> Option<&(CopilotEditPrediction, EditPreview)> {
+        self.completion.as_ref()
     }
 }
 
@@ -64,12 +44,8 @@ impl EditPredictionDelegate for CopilotEditPredictionDelegate {
         true
     }
 
-    fn supports_jump_to_edit() -> bool {
-        false
-    }
-
     fn is_refreshing(&self, _cx: &App) -> bool {
-        self.pending_refresh.is_some() && self.completions.is_empty()
+        self.pending_refresh.is_some() && self.completion.is_none()
     }
 
     fn is_enabled(
@@ -102,160 +78,96 @@ impl EditPredictionDelegate for CopilotEditPredictionDelegate {
                 })?
                 .await?;
 
-            this.update(cx, |this, cx| {
-                if !completions.is_empty() {
-                    this.cycled = false;
+            if let Some(mut completion) = completions.into_iter().next()
+                && let Some(trimmed_completion) = cx
+                    .update(|cx| trim_completion(&completion, cx))
+                    .ok()
+                    .flatten()
+            {
+                let preview = buffer
+                    .update(cx, |this, cx| {
+                        this.preview_edits(Arc::from(std::slice::from_ref(&trimmed_completion)), cx)
+                    })?
+                    .await;
+                this.update(cx, |this, cx| {
                     this.pending_refresh = None;
-                    this.pending_cycling_refresh = None;
-                    this.completions.clear();
-                    this.active_completion_index = 0;
-                    this.buffer_id = Some(buffer.entity_id());
-                    this.file_extension = buffer.read(cx).file().and_then(|file| {
-                        Some(
-                            Path::new(file.file_name(cx))
-                                .extension()?
-                                .to_str()?
-                                .to_string(),
-                        )
-                    });
-
-                    for completion in completions {
-                        this.push_completion(completion);
-                    }
+                    completion.range = trimmed_completion.0;
+                    completion.text = trimmed_completion.1.to_string();
+                    this.completion = Some((completion, preview));
+
                     cx.notify();
-                }
-            })?;
+                })?;
+            }
 
             Ok(())
         }));
     }
 
-    fn cycle(
-        &mut self,
-        buffer: Entity<Buffer>,
-        cursor_position: language::Anchor,
-        direction: Direction,
-        cx: &mut Context<Self>,
-    ) {
-        if self.cycled {
-            match direction {
-                Direction::Prev => {
-                    self.active_completion_index = if self.active_completion_index == 0 {
-                        self.completions.len().saturating_sub(1)
-                    } else {
-                        self.active_completion_index - 1
-                    };
-                }
-                Direction::Next => {
-                    if self.completions.is_empty() {
-                        self.active_completion_index = 0
-                    } else {
-                        self.active_completion_index =
-                            (self.active_completion_index + 1) % self.completions.len();
-                    }
-                }
-            }
-
-            cx.notify();
-        } else {
-            let copilot = self.copilot.clone();
-            self.pending_cycling_refresh = Some(cx.spawn(async move |this, cx| {
-                let completions = copilot
-                    .update(cx, |copilot, cx| {
-                        copilot.completions_cycling(&buffer, cursor_position, cx)
-                    })?
-                    .await?;
-
-                this.update(cx, |this, cx| {
-                    this.cycled = true;
-                    this.file_extension = buffer.read(cx).file().and_then(|file| {
-                        Some(
-                            Path::new(file.file_name(cx))
-                                .extension()?
-                                .to_str()?
-                                .to_string(),
-                        )
-                    });
-                    for completion in completions {
-                        this.push_completion(completion);
-                    }
-                    this.cycle(buffer, cursor_position, direction, cx);
-                })?;
-
-                Ok(())
-            }));
-        }
-    }
-
     fn accept(&mut self, cx: &mut Context<Self>) {
-        if let Some(completion) = self.active_completion() {
+        if let Some((completion, _)) = self.active_completion() {
             self.copilot
                 .update(cx, |copilot, cx| copilot.accept_completion(completion, cx))
                 .detach_and_log_err(cx);
         }
     }
 
-    fn discard(&mut self, cx: &mut Context<Self>) {
-        let settings = AllLanguageSettings::get_global(cx);
-
-        let copilot_enabled = settings.show_edit_predictions(None, cx);
-
-        if !copilot_enabled {
-            return;
-        }
-
-        self.copilot
-            .update(cx, |copilot, cx| {
-                copilot.discard_completions(&self.completions, cx)
-            })
-            .detach_and_log_err(cx);
-    }
+    fn discard(&mut self, _: &mut Context<Self>) {}
 
     fn suggest(
         &mut self,
         buffer: &Entity<Buffer>,
-        cursor_position: language::Anchor,
+        _: language::Anchor,
         cx: &mut Context<Self>,
     ) -> Option<EditPrediction> {
         let buffer_id = buffer.entity_id();
         let buffer = buffer.read(cx);
-        let completion = self.active_completion()?;
-        if Some(buffer_id) != self.buffer_id
+        let (completion, edit_preview) = self.active_completion()?;
+
+        if Some(buffer_id) != Some(completion.buffer.entity_id())
             || !completion.range.start.is_valid(buffer)
             || !completion.range.end.is_valid(buffer)
         {
             return None;
         }
+        let edits = vec![(
+            completion.range.clone(),
+            Arc::from(completion.text.as_ref()),
+        )];
+        let edits = interpolate_edits(&completion.snapshot, &buffer.snapshot(), &edits)
+            .filter(|edits| !edits.is_empty())?;
+
+        Some(EditPrediction::Local {
+            id: None,
+            edits,
+            edit_preview: Some(edit_preview.clone()),
+        })
+    }
+}
 
-        let mut completion_range = completion.range.to_offset(buffer);
-        let prefix_len = common_prefix(
-            buffer.chars_for_range(completion_range.clone()),
-            completion.text.chars(),
-        );
-        completion_range.start += prefix_len;
-        let suffix_len = common_prefix(
-            buffer.reversed_chars_for_range(completion_range.clone()),
-            completion.text[prefix_len..].chars().rev(),
-        );
-        completion_range.end = completion_range.end.saturating_sub(suffix_len);
-
-        if completion_range.is_empty()
-            && completion_range.start == cursor_position.to_offset(buffer)
-        {
-            let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len];
-            if completion_text.trim().is_empty() {
-                None
-            } else {
-                let position = cursor_position.bias_right(buffer);
-                Some(EditPrediction::Local {
-                    id: None,
-                    edits: vec![(position..position, completion_text.into())],
-                    edit_preview: None,
-                })
-            }
-        } else {
-            None
-        }
+fn trim_completion(
+    completion: &CopilotEditPrediction,
+    cx: &mut App,
+) -> Option<(Range<Anchor>, Arc<str>)> {
+    let buffer = completion.buffer.read(cx);
+    let mut completion_range = completion.range.to_offset(buffer);
+    let prefix_len = common_prefix(
+        buffer.chars_for_range(completion_range.clone()),
+        completion.text.chars(),
+    );
+    completion_range.start += prefix_len;
+    let suffix_len = common_prefix(
+        buffer.reversed_chars_for_range(completion_range.clone()),
+        completion.text[prefix_len..].chars().rev(),
+    );
+    completion_range.end = completion_range.end.saturating_sub(suffix_len);
+    let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len];
+    if completion_text.trim().is_empty() {
+        None
+    } else {
+        let completion_range =
+            buffer.anchor_after(completion_range.start)..buffer.anchor_after(completion_range.end);
+
+        Some((completion_range, Arc::from(completion_text)))
     }
 }
 
@@ -282,6 +194,7 @@ mod tests {
         Point,
         language_settings::{CompletionSettingsContent, LspInsertMode, WordsCompletionMode},
     };
+    use lsp::Uri;
     use project::Project;
     use serde_json::json;
     use settings::{AllLanguageSettingsContent, SettingsStore};
@@ -337,12 +250,15 @@ mod tests {
         ));
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "one.copilot1".into(),
                 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, window, cx| {
@@ -383,12 +299,15 @@ mod tests {
         ));
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "one.copilot1".into(),
                 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, _, cx| {
@@ -412,12 +331,15 @@ mod tests {
         // After debouncing, new Copilot completions should be requested.
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "one.copilot2".into(),
                 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, window, cx| {
@@ -479,45 +401,6 @@ mod tests {
             assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n");
             assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n");
         });
-
-        // Reset the editor to verify how suggestions behave when tabbing on leading indentation.
-        cx.update_editor(|editor, window, cx| {
-            editor.set_text("fn foo() {\n  \n}", window, cx);
-            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
-                s.select_ranges([Point::new(1, 2)..Point::new(1, 2)])
-            });
-        });
-        handle_copilot_completion_request(
-            &copilot_lsp,
-            vec![crate::request::Completion {
-                text: "    let x = 4;".into(),
-                range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)),
-                ..Default::default()
-            }],
-            vec![],
-        );
-
-        cx.update_editor(|editor, window, cx| {
-            editor.next_edit_prediction(&Default::default(), window, cx)
-        });
-        executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
-        cx.update_editor(|editor, window, cx| {
-            assert!(editor.has_active_edit_prediction());
-            assert_eq!(editor.display_text(cx), "fn foo() {\n    let x = 4;\n}");
-            assert_eq!(editor.text(cx), "fn foo() {\n  \n}");
-
-            // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion.
-            editor.tab(&Default::default(), window, cx);
-            assert!(editor.has_active_edit_prediction());
-            assert_eq!(editor.text(cx), "fn foo() {\n    \n}");
-            assert_eq!(editor.display_text(cx), "fn foo() {\n    let x = 4;\n}");
-
-            // Using AcceptEditPrediction again accepts the suggestion.
-            editor.accept_edit_prediction(&Default::default(), window, cx);
-            assert!(!editor.has_active_edit_prediction());
-            assert_eq!(editor.text(cx), "fn foo() {\n    let x = 4;\n}");
-            assert_eq!(editor.display_text(cx), "fn foo() {\n    let x = 4;\n}");
-        });
     }
 
     #[gpui::test(iterations = 10)]
@@ -570,12 +453,15 @@ mod tests {
         ));
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "one.copilot1".into(),
                 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, window, cx| {
@@ -614,12 +500,15 @@ mod tests {
         ));
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "one.123. copilot\n 456".into(),
                 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, window, cx| {
@@ -686,15 +575,18 @@ mod tests {
 
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "two.foo()".into(),
                 range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         cx.update_editor(|editor, window, cx| {
-            editor.next_edit_prediction(&Default::default(), window, cx)
+            editor.show_edit_prediction(&Default::default(), window, cx)
         });
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, window, cx| {
@@ -703,15 +595,22 @@ mod tests {
             assert_eq!(editor.text(cx), "one\ntw\nthree\n");
 
             editor.backspace(&Default::default(), window, cx);
+        });
+        executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
+        cx.run_until_parked();
+        cx.update_editor(|editor, window, cx| {
             assert!(editor.has_active_edit_prediction());
             assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
             assert_eq!(editor.text(cx), "one\nt\nthree\n");
 
             editor.backspace(&Default::default(), window, cx);
+        });
+        executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
+        cx.run_until_parked();
+        cx.update_editor(|editor, window, cx| {
             assert!(editor.has_active_edit_prediction());
             assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
             assert_eq!(editor.text(cx), "one\n\nthree\n");
-
             // Deleting across the original suggestion range invalidates it.
             editor.backspace(&Default::default(), window, cx);
             assert!(!editor.has_active_edit_prediction());
@@ -753,7 +652,7 @@ mod tests {
         editor
             .update(cx, |editor, window, cx| {
                 use gpui::Focusable;
-                window.focus(&editor.focus_handle(cx));
+                window.focus(&editor.focus_handle(cx), cx);
             })
             .unwrap();
         let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot));
@@ -765,19 +664,22 @@ mod tests {
 
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "b = 2 + a".into(),
                 range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         _ = editor.update(cx, |editor, window, cx| {
             // Ensure copilot suggestions are shown for the first excerpt.
             editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_ranges([Point::new(1, 5)..Point::new(1, 5)])
             });
-            editor.next_edit_prediction(&Default::default(), window, cx);
+            editor.show_edit_prediction(&Default::default(), window, cx);
         });
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         _ = editor.update(cx, |editor, _, cx| {
@@ -791,12 +693,15 @@ mod tests {
 
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "d = 4 + c".into(),
                 range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         _ = editor.update(cx, |editor, window, cx| {
             // Move to another excerpt, ensuring the suggestion gets cleared.
@@ -873,15 +778,18 @@ mod tests {
         ));
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "two.foo()".into(),
                 range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         cx.update_editor(|editor, window, cx| {
-            editor.next_edit_prediction(&Default::default(), window, cx)
+            editor.show_edit_prediction(&Default::default(), window, cx)
         });
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, _, cx| {
@@ -903,12 +811,15 @@ mod tests {
         ));
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "two.foo()".into(),
                 range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, _, cx| {
@@ -930,12 +841,15 @@ mod tests {
         ));
         handle_copilot_completion_request(
             &copilot_lsp,
-            vec![crate::request::Completion {
+            vec![crate::request::NextEditSuggestion {
                 text: "two.foo()".into(),
                 range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 4)),
-                ..Default::default()
+                command: None,
+                text_document: lsp::VersionedTextDocumentIdentifier {
+                    uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                    version: 0,
+                },
             }],
-            vec![],
         );
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, _, cx| {
@@ -1000,7 +914,7 @@ mod tests {
         editor
             .update(cx, |editor, window, cx| {
                 use gpui::Focusable;
-                window.focus(&editor.focus_handle(cx))
+                window.focus(&editor.focus_handle(cx), cx)
             })
             .unwrap();
         let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot));
@@ -1011,16 +925,20 @@ mod tests {
             .unwrap();
 
         let mut copilot_requests = copilot_lsp
-            .set_request_handler::<crate::request::GetCompletions, _, _>(
+            .set_request_handler::<crate::request::NextEditSuggestions, _, _>(
                 move |_params, _cx| async move {
-                    Ok(crate::request::GetCompletionsResult {
-                        completions: vec![crate::request::Completion {
+                    Ok(crate::request::NextEditSuggestionsResult {
+                        edits: vec![crate::request::NextEditSuggestion {
                             text: "next line".into(),
                             range: lsp::Range::new(
                                 lsp::Position::new(1, 0),
                                 lsp::Position::new(1, 0),
                             ),
-                            ..Default::default()
+                            command: None,
+                            text_document: lsp::VersionedTextDocumentIdentifier {
+                                uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
+                                version: 0,
+                            },
                         }],
                     })
                 },
@@ -1049,23 +967,14 @@ mod tests {
 
     fn handle_copilot_completion_request(
         lsp: &lsp::FakeLanguageServer,
-        completions: Vec<crate::request::Completion>,
-        completions_cycling: Vec<crate::request::Completion>,
+        completions: Vec<crate::request::NextEditSuggestion>,
     ) {
-        lsp.set_request_handler::<crate::request::GetCompletions, _, _>(move |_params, _cx| {
-            let completions = completions.clone();
-            async move {
-                Ok(crate::request::GetCompletionsResult {
-                    completions: completions.clone(),
-                })
-            }
-        });
-        lsp.set_request_handler::<crate::request::GetCompletionsCycling, _, _>(
+        lsp.set_request_handler::<crate::request::NextEditSuggestions, _, _>(
             move |_params, _cx| {
-                let completions_cycling = completions_cycling.clone();
+                let completions = completions.clone();
                 async move {
-                    Ok(crate::request::GetCompletionsResult {
-                        completions: completions_cycling.clone(),
+                    Ok(crate::request::NextEditSuggestionsResult {
+                        edits: completions.clone(),
                     })
                 }
             },

crates/copilot/src/request.rs 🔗

@@ -1,3 +1,4 @@
+use lsp::VersionedTextDocumentIdentifier;
 use serde::{Deserialize, Serialize};
 
 pub enum CheckStatus {}
@@ -88,72 +89,6 @@ impl lsp::request::Request for SignOut {
     const METHOD: &'static str = "signOut";
 }
 
-pub enum GetCompletions {}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct GetCompletionsParams {
-    pub doc: GetCompletionsDocument,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct GetCompletionsDocument {
-    pub tab_size: u32,
-    pub indent_size: u32,
-    pub insert_spaces: bool,
-    pub uri: lsp::Uri,
-    pub relative_path: String,
-    pub position: lsp::Position,
-    pub version: usize,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct GetCompletionsResult {
-    pub completions: Vec<Completion>,
-}
-
-#[derive(Clone, Debug, Default, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct Completion {
-    pub text: String,
-    pub position: lsp::Position,
-    pub uuid: String,
-    pub range: lsp::Range,
-    pub display_text: String,
-}
-
-impl lsp::request::Request for GetCompletions {
-    type Params = GetCompletionsParams;
-    type Result = GetCompletionsResult;
-    const METHOD: &'static str = "getCompletions";
-}
-
-pub enum GetCompletionsCycling {}
-
-impl lsp::request::Request for GetCompletionsCycling {
-    type Params = GetCompletionsParams;
-    type Result = GetCompletionsResult;
-    const METHOD: &'static str = "getCompletionsCycling";
-}
-
-pub enum LogMessage {}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct LogMessageParams {
-    pub level: u8,
-    pub message: String,
-    pub metadata_str: String,
-    pub extra: Vec<String>,
-}
-
-impl lsp::notification::Notification for LogMessage {
-    type Params = LogMessageParams;
-    const METHOD: &'static str = "LogMessage";
-}
-
 pub enum StatusNotification {}
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -223,3 +158,36 @@ impl lsp::request::Request for NotifyRejected {
     type Result = String;
     const METHOD: &'static str = "notifyRejected";
 }
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NextEditSuggestions;
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NextEditSuggestionsParams {
+    pub(crate) text_document: VersionedTextDocumentIdentifier,
+    pub(crate) position: lsp::Position,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NextEditSuggestion {
+    pub text: String,
+    pub text_document: VersionedTextDocumentIdentifier,
+    pub range: lsp::Range,
+    pub command: Option<lsp::Command>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NextEditSuggestionsResult {
+    pub edits: Vec<NextEditSuggestion>,
+}
+
+impl lsp::request::Request for NextEditSuggestions {
+    type Params = NextEditSuggestionsParams;
+    type Result = NextEditSuggestionsResult;
+
+    const METHOD: &'static str = "textDocument/copilotInlineEdit";
+}

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;
@@ -397,8 +435,8 @@ impl Render for CopilotCodeVerification {
             .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
                 cx.emit(DismissEvent);
             }))
-            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| {
-                window.focus(&this.focus_handle);
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+                window.focus(&this.focus_handle, cx);
             }))
             .child(
                 Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.))

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -577,7 +577,7 @@ impl DebugPanel {
                 menu
             });
 
-            window.focus(&context_menu.focus_handle(cx));
+            window.focus(&context_menu.focus_handle(cx), cx);
             let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
                 this.context_menu.take();
                 cx.notify();
@@ -1052,7 +1052,7 @@ impl DebugPanel {
         cx: &mut Context<Self>,
     ) {
         debug_assert!(self.sessions_with_children.contains_key(&session_item));
-        session_item.focus_handle(cx).focus(window);
+        session_item.focus_handle(cx).focus(window, cx);
         session_item.update(cx, |this, cx| {
             this.running_state().update(cx, |this, cx| {
                 this.go_to_selected_stack_frame(window, cx);

crates/debugger_ui/src/new_process_modal.rs 🔗

@@ -574,7 +574,7 @@ impl Render for NewProcessModal {
                     NewProcessMode::Launch => NewProcessMode::Task,
                 };
 
-                this.mode_focus_handle(cx).focus(window);
+                this.mode_focus_handle(cx).focus(window, cx);
             }))
             .on_action(
                 cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| {
@@ -585,7 +585,7 @@ impl Render for NewProcessModal {
                         NewProcessMode::Launch => NewProcessMode::Attach,
                     };
 
-                    this.mode_focus_handle(cx).focus(window);
+                    this.mode_focus_handle(cx).focus(window, cx);
                 }),
             )
             .child(
@@ -602,7 +602,7 @@ impl Render for NewProcessModal {
                                     NewProcessMode::Task.to_string(),
                                     cx.listener(|this, _, window, cx| {
                                         this.mode = NewProcessMode::Task;
-                                        this.mode_focus_handle(cx).focus(window);
+                                        this.mode_focus_handle(cx).focus(window, cx);
                                         cx.notify();
                                     }),
                                 )
@@ -611,7 +611,7 @@ impl Render for NewProcessModal {
                                     NewProcessMode::Debug.to_string(),
                                     cx.listener(|this, _, window, cx| {
                                         this.mode = NewProcessMode::Debug;
-                                        this.mode_focus_handle(cx).focus(window);
+                                        this.mode_focus_handle(cx).focus(window, cx);
                                         cx.notify();
                                     }),
                                 )
@@ -629,7 +629,7 @@ impl Render for NewProcessModal {
                                                 cx,
                                             );
                                         }
-                                        this.mode_focus_handle(cx).focus(window);
+                                        this.mode_focus_handle(cx).focus(window, cx);
                                         cx.notify();
                                     }),
                                 )
@@ -638,7 +638,7 @@ impl Render for NewProcessModal {
                                     NewProcessMode::Launch.to_string(),
                                     cx.listener(|this, _, window, cx| {
                                         this.mode = NewProcessMode::Launch;
-                                        this.mode_focus_handle(cx).focus(window);
+                                        this.mode_focus_handle(cx).focus(window, cx);
                                         cx.notify();
                                     }),
                                 )
@@ -840,17 +840,17 @@ impl ConfigureMode {
         }
     }
 
-    fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context<Self>) {
-        window.focus_next();
+    fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_next(cx);
     }
 
     fn on_tab_prev(
         &mut self,
         _: &menu::SelectPrevious,
         window: &mut Window,
-        _: &mut Context<Self>,
+        cx: &mut Context<Self>,
     ) {
-        window.focus_prev();
+        window.focus_prev(cx);
     }
 
     fn render(
@@ -923,7 +923,7 @@ impl AttachMode {
                 window,
                 cx,
             );
-            window.focus(&modal.focus_handle(cx));
+            window.focus(&modal.focus_handle(cx), cx);
 
             modal
         });

crates/debugger_ui/src/onboarding_modal.rs 🔗

@@ -83,8 +83,8 @@ impl Render for DebuggerOnboardingModal {
                 debugger_onboarding_event!("Canceled", trigger = "Action");
                 cx.emit(DismissEvent);
             }))
-            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
-                this.focus_handle.focus(window);
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+                this.focus_handle.focus(window, cx);
             }))
             .child(
                 div()

crates/debugger_ui/src/session/running.rs 🔗

@@ -604,7 +604,7 @@ impl DebugTerminal {
         let focus_handle = cx.focus_handle();
         let focus_subscription = cx.on_focus(&focus_handle, window, |this, window, cx| {
             if let Some(terminal) = this.terminal.as_ref() {
-                terminal.focus_handle(cx).focus(window);
+                terminal.focus_handle(cx).focus(window, cx);
             }
         });
 

crates/debugger_ui/src/session/running/breakpoint_list.rs 🔗

@@ -310,7 +310,7 @@ impl BreakpointList {
 
     fn dismiss(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
         if self.input.focus_handle(cx).contains_focused(window, cx) {
-            self.focus_handle.focus(window);
+            self.focus_handle.focus(window, cx);
         } else if self.strip_mode.is_some() {
             self.strip_mode.take();
             cx.notify();
@@ -364,9 +364,9 @@ impl BreakpointList {
                         }
                     }
                 }
-                self.focus_handle.focus(window);
+                self.focus_handle.focus(window, cx);
             } else {
-                handle.focus(window);
+                handle.focus(window, cx);
             }
 
             return;
@@ -627,7 +627,7 @@ impl BreakpointList {
                 .on_click({
                     let focus_handle = focus_handle.clone();
                     move |_, window, cx| {
-                        focus_handle.focus(window);
+                        focus_handle.focus(window, cx);
                         window.dispatch_action(ToggleEnableBreakpoint.boxed_clone(), cx)
                     }
                 }),
@@ -654,7 +654,7 @@ impl BreakpointList {
                     )
                     .on_click({
                         move |_, window, cx| {
-                            focus_handle.focus(window);
+                            focus_handle.focus(window, cx);
                             window.dispatch_action(UnsetBreakpoint.boxed_clone(), cx)
                         }
                     }),

crates/debugger_ui/src/session/running/console.rs 🔗

@@ -105,7 +105,7 @@ impl Console {
             cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events),
             cx.on_focus(&focus_handle, window, |console, window, cx| {
                 if console.is_running(cx) {
-                    console.query_bar.focus_handle(cx).focus(window);
+                    console.query_bar.focus_handle(cx).focus(window, cx);
                 }
             }),
         ];

crates/debugger_ui/src/session/running/memory_view.rs 🔗

@@ -403,7 +403,7 @@ impl MemoryView {
                 this.set_placeholder_text("Write to Selected Memory Range", window, cx);
             });
             self.is_writing_memory = true;
-            self.query_editor.focus_handle(cx).focus(window);
+            self.query_editor.focus_handle(cx).focus(window, cx);
         } else {
             self.query_editor.update(cx, |this, cx| {
                 this.clear(window, cx);

crates/debugger_ui/src/session/running/variable_list.rs 🔗

@@ -529,7 +529,7 @@ impl VariableList {
 
     fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
         self.edited_path.take();
-        self.focus_handle.focus(window);
+        self.focus_handle.focus(window, cx);
         cx.notify();
     }
 
@@ -1067,7 +1067,7 @@ impl VariableList {
             editor.select_all(&editor::actions::SelectAll, window, cx);
             editor
         });
-        editor.focus_handle(cx).focus(window);
+        editor.focus_handle(cx).focus(window, cx);
         editor
     }
 

crates/deepseek/src/deepseek.rs 🔗

@@ -103,8 +103,9 @@ impl Model {
 
     pub fn max_output_tokens(&self) -> Option<u64> {
         match self {
-            Self::Chat => Some(8_192),
-            Self::Reasoner => Some(64_000),
+            // Their API treats this max against the context window, which means we hit the limit a lot
+            // Using the default value of None in the API instead
+            Self::Chat | Self::Reasoner => None,
             Self::Custom {
                 max_output_tokens, ..
             } => *max_output_tokens,

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,
 };
@@ -175,7 +175,7 @@ impl BufferDiagnosticsEditor {
                     // `BufferDiagnosticsEditor` instance.
                     EditorEvent::Focused => {
                         if buffer_diagnostics_editor.multibuffer.read(cx).is_empty() {
-                            window.focus(&buffer_diagnostics_editor.focus_handle);
+                            window.focus(&buffer_diagnostics_editor.focus_handle, cx);
                         }
                     }
                     EditorEvent::Blurred => {
@@ -517,7 +517,7 @@ impl BufferDiagnosticsEditor {
                                 .editor
                                 .read(cx)
                                 .focus_handle(cx)
-                                .focus(window);
+                                .focus(window, cx);
                         }
                     }
                 }
@@ -617,7 +617,7 @@ impl BufferDiagnosticsEditor {
         // not empty, focus on the editor instead, which will allow the user to
         // start interacting and editing the buffer's contents.
         if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
-            self.editor.focus_handle(cx).focus(window)
+            self.editor.focus_handle(cx).focus(window, cx)
         }
     }
 
@@ -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/diagnostic_renderer.rs 🔗

@@ -315,6 +315,6 @@ impl DiagnosticBlock {
         editor.change_selections(Default::default(), window, cx, |s| {
             s.select_ranges([range.start..range.start]);
         });
-        window.focus(&editor.focus_handle(cx));
+        window.focus(&editor.focus_handle(cx), cx);
     }
 }

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,
 };
@@ -243,7 +243,7 @@ impl ProjectDiagnosticsEditor {
                 match event {
                     EditorEvent::Focused => {
                         if this.multibuffer.read(cx).is_empty() {
-                            window.focus(&this.focus_handle);
+                            window.focus(&this.focus_handle, cx);
                         }
                     }
                     EditorEvent::Blurred => this.close_diagnosticless_buffers(cx, false),
@@ -434,7 +434,7 @@ impl ProjectDiagnosticsEditor {
 
     fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
-            self.editor.focus_handle(cx).focus(window)
+            self.editor.focus_handle(cx).focus(window, cx)
         }
     }
 
@@ -650,7 +650,7 @@ impl ProjectDiagnosticsEditor {
                         })
                     });
                     if this.focus_handle.is_focused(window) {
-                        this.editor.read(cx).focus_handle(cx).focus(window);
+                        this.editor.read(cx).focus_handle(cx).focus(window, cx);
                     }
                 }
 
@@ -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/docs_preprocessor/Cargo.toml 🔗

@@ -7,8 +7,6 @@ license = "GPL-3.0-or-later"
 
 [dependencies]
 anyhow.workspace = true
-command_palette.workspace = true
-gpui.workspace = true
 # We are specifically pinning this version of mdbook, as later versions introduce issues with double-nested subdirectories.
 # Ask @maxdeviant about this before bumping.
 mdbook = "= 0.4.40"
@@ -17,7 +15,6 @@ serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
 util.workspace = true
-zed.workspace = true
 zlog.workspace = true
 task.workspace = true
 theme.workspace = true
@@ -27,4 +24,4 @@ workspace = true
 
 [[bin]]
 name = "docs_preprocessor"
-path = "src/main.rs"
+path = "src/main.rs"

crates/docs_preprocessor/src/main.rs 🔗

@@ -22,16 +22,13 @@ static KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
     load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
 });
 
-static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
+static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(load_all_actions);
 
 const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
 
 fn main() -> Result<()> {
     zlog::init();
     zlog::init_output_stderr();
-    // call a zed:: function so everything in `zed` crate is linked and
-    // all actions in the actual app are registered
-    zed::stdout_is_a_pty();
     let args = std::env::args().skip(1).collect::<Vec<_>>();
 
     match args.get(0).map(String::as_str) {
@@ -72,8 +69,8 @@ enum PreprocessorError {
 impl PreprocessorError {
     fn new_for_not_found_action(action_name: String) -> Self {
         for action in &*ALL_ACTIONS {
-            for alias in action.deprecated_aliases {
-                if alias == &action_name {
+            for alias in &action.deprecated_aliases {
+                if alias == action_name.as_str() {
                     return PreprocessorError::DeprecatedActionUsed {
                         used: action_name,
                         should_be: action.name.to_string(),
@@ -214,7 +211,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Prepr
         chapter.content = regex
             .replace_all(&chapter.content, |caps: &regex::Captures| {
                 let action = caps[1].trim();
-                if find_action_by_name(action).is_none() {
+                if is_missing_action(action) {
                     errors.insert(PreprocessorError::new_for_not_found_action(
                         action.to_string(),
                     ));
@@ -244,10 +241,12 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Preproces
             .replace_all(&chapter.content, |caps: &regex::Captures| {
                 let name = caps[1].trim();
                 let Some(action) = find_action_by_name(name) else {
-                    errors.insert(PreprocessorError::new_for_not_found_action(
-                        name.to_string(),
-                    ));
-                    return String::new();
+                    if actions_available() {
+                        errors.insert(PreprocessorError::new_for_not_found_action(
+                            name.to_string(),
+                        ));
+                    }
+                    return format!("<code class=\"hljs\">{}</code>", name);
                 };
                 format!("<code class=\"hljs\">{}</code>", &action.human_name)
             })
@@ -257,11 +256,19 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Preproces
 
 fn find_action_by_name(name: &str) -> Option<&ActionDef> {
     ALL_ACTIONS
-        .binary_search_by(|action| action.name.cmp(name))
+        .binary_search_by(|action| action.name.as_str().cmp(name))
         .ok()
         .map(|index| &ALL_ACTIONS[index])
 }
 
+fn actions_available() -> bool {
+    !ALL_ACTIONS.is_empty()
+}
+
+fn is_missing_action(name: &str) -> bool {
+    actions_available() && find_action_by_name(name).is_none()
+}
+
 fn find_binding(os: &str, action: &str) -> Option<String> {
     let keymap = match os {
         "macos" => &KEYMAP_MACOS,
@@ -384,18 +391,13 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
                 let keymap = settings::KeymapFile::parse(&snippet_json_fixed)
                     .context("Failed to parse keymap JSON")?;
                 for section in keymap.sections() {
-                    for (keystrokes, action) in section.bindings() {
-                        keystrokes
-                            .split_whitespace()
-                            .map(|source| gpui::Keystroke::parse(source))
-                            .collect::<std::result::Result<Vec<_>, _>>()
-                            .context("Failed to parse keystroke")?;
+                    for (_keystrokes, action) in section.bindings() {
                         if let Some((action_name, _)) = settings::KeymapFile::parse_action(action)
                             .map_err(|err| anyhow::format_err!(err))
                             .context("Failed to parse action")?
                         {
                             anyhow::ensure!(
-                                find_action_by_name(action_name).is_some(),
+                                !is_missing_action(action_name),
                                 "Action not found: {}",
                                 action_name
                             );
@@ -491,27 +493,35 @@ where
     });
 }
 
-#[derive(Debug, serde::Serialize)]
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
 struct ActionDef {
-    name: &'static str,
+    name: String,
     human_name: String,
-    deprecated_aliases: &'static [&'static str],
-    docs: Option<&'static str>,
+    deprecated_aliases: Vec<String>,
+    #[serde(rename = "documentation")]
+    docs: Option<String>,
 }
 
-fn dump_all_gpui_actions() -> Vec<ActionDef> {
-    let mut actions = gpui::generate_list_of_all_registered_actions()
-        .map(|action| ActionDef {
-            name: action.name,
-            human_name: command_palette::humanize_action_name(action.name),
-            deprecated_aliases: action.deprecated_aliases,
-            docs: action.documentation,
-        })
-        .collect::<Vec<ActionDef>>();
-
-    actions.sort_by_key(|a| a.name);
-
-    actions
+fn load_all_actions() -> Vec<ActionDef> {
+    let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
+    match std::fs::read_to_string(asset_path) {
+        Ok(content) => {
+            let mut actions: Vec<ActionDef> =
+                serde_json::from_str(&content).expect("Failed to parse actions.json");
+            actions.sort_by(|a, b| a.name.cmp(&b.name));
+            actions
+        }
+        Err(err) => {
+            if std::env::var("CI").is_ok() {
+                panic!("actions.json not found at {}: {}", asset_path, err);
+            }
+            eprintln!(
+                "Warning: actions.json not found, action validation will be skipped: {}",
+                err
+            );
+            Vec::new()
+        }
+    }
 }
 
 fn handle_postprocessing() -> Result<()> {
@@ -647,7 +657,7 @@ fn generate_big_table_of_actions() -> String {
     let mut output = String::new();
 
     let mut actions_sorted = actions.iter().collect::<Vec<_>>();
-    actions_sorted.sort_by_key(|a| a.name);
+    actions_sorted.sort_by_key(|a| a.name.as_str());
 
     // Start the definition list with custom styling for better spacing
     output.push_str("<dl style=\"line-height: 1.8;\">\n");
@@ -664,7 +674,7 @@ fn generate_big_table_of_actions() -> String {
         output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
 
         // Add the description, escaping HTML if needed
-        if let Some(description) = action.docs {
+        if let Some(description) = action.docs.as_ref() {
             output.push_str(
                 &description
                     .replace("&", "&amp;")
@@ -674,7 +684,7 @@ fn generate_big_table_of_actions() -> String {
             output.push_str("<br>\n");
         }
         output.push_str("Keymap Name: <code>");
-        output.push_str(action.name);
+        output.push_str(&action.name);
         output.push_str("</code><br>\n");
         if !action.deprecated_aliases.is_empty() {
             output.push_str("Deprecated Alias(es): ");

crates/edit_prediction/src/mercury.rs 🔗

@@ -6,7 +6,7 @@ use crate::{
 use anyhow::{Context as _, Result};
 use futures::AsyncReadExt as _;
 use gpui::{
-    App, AppContext as _, Entity, SharedString, Task,
+    App, AppContext as _, Entity, Global, SharedString, Task,
     http_client::{self, AsyncBody, Method},
 };
 use language::{OffsetRangeExt as _, ToOffset, ToPoint as _};
@@ -300,14 +300,19 @@ pub const MERCURY_CREDENTIALS_URL: SharedString =
     SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions");
 pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token";
 pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("MERCURY_AI_TOKEN");
-pub static MERCURY_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
+
+struct GlobalMercuryApiKey(Entity<ApiKeyState>);
+
+impl Global for GlobalMercuryApiKey {}
 
 pub fn mercury_api_token(cx: &mut App) -> Entity<ApiKeyState> {
-    MERCURY_API_KEY
-        .get_or_init(|| {
-            cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()))
-        })
-        .clone()
+    if let Some(global) = cx.try_global::<GlobalMercuryApiKey>() {
+        return global.0.clone();
+    }
+    let entity =
+        cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()));
+    cx.set_global(GlobalMercuryApiKey(entity.clone()));
+    entity
 }
 
 pub fn load_mercury_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {

crates/edit_prediction/src/onboarding_modal.rs 🔗

@@ -131,8 +131,8 @@ impl Render for ZedPredictModal {
                 onboarding_event!("Cancelled", trigger = "Action");
                 cx.emit(DismissEvent);
             }))
-            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
-                this.focus_handle.focus(window);
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+                this.focus_handle.focus(window, cx);
             }))
             .child(
                 div()

crates/edit_prediction/src/sweep_ai.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::Result;
 use futures::AsyncReadExt as _;
 use gpui::{
-    App, AppContext as _, Entity, SharedString, Task,
+    App, AppContext as _, Entity, Global, SharedString, Task,
     http_client::{self, AsyncBody, Method},
 };
 use language::{Point, ToOffset as _};
@@ -272,14 +272,19 @@ pub const SWEEP_CREDENTIALS_URL: SharedString =
     SharedString::new_static("https://autocomplete.sweep.dev");
 pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token";
 pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("SWEEP_AI_TOKEN");
-pub static SWEEP_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
+
+struct GlobalSweepApiKey(Entity<ApiKeyState>);
+
+impl Global for GlobalSweepApiKey {}
 
 pub fn sweep_api_token(cx: &mut App) -> Entity<ApiKeyState> {
-    SWEEP_API_KEY
-        .get_or_init(|| {
-            cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()))
-        })
-        .clone()
+    if let Some(global) = cx.try_global::<GlobalSweepApiKey>() {
+        return global.0.clone();
+    }
+    let entity =
+        cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()));
+    cx.set_global(GlobalSweepApiKey(entity.clone()));
+    entity
 }
 
 pub fn load_sweep_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {

crates/edit_prediction/src/zed_edit_prediction_delegate.rs 🔗

@@ -2,7 +2,7 @@ use std::{cmp, sync::Arc};
 
 use client::{Client, UserStore};
 use cloud_llm_client::EditPredictionRejectReason;
-use edit_prediction_types::{DataCollectionState, Direction, EditPredictionDelegate};
+use edit_prediction_types::{DataCollectionState, EditPredictionDelegate};
 use gpui::{App, Entity, prelude::*};
 use language::{Buffer, ToPoint as _};
 use project::Project;
@@ -139,15 +139,6 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
         });
     }
 
-    fn cycle(
-        &mut self,
-        _buffer: Entity<language::Buffer>,
-        _cursor_position: language::Anchor,
-        _direction: Direction,
-        _cx: &mut Context<Self>,
-    ) {
-    }
-
     fn accept(&mut self, cx: &mut Context<Self>) {
         self.store.update(cx, |store, cx| {
             store.accept_current_prediction(&self.project, cx);

crates/edit_prediction_cli/src/headless.rs 🔗

@@ -8,8 +8,7 @@ use gpui_tokio::Tokio;
 use language::LanguageRegistry;
 use language_extension::LspAccess;
 use node_runtime::{NodeBinaryOptions, NodeRuntime};
-use project::Project;
-use project::project_settings::ProjectSettings;
+use project::{Project, project_settings::ProjectSettings};
 use release_channel::{AppCommitSha, AppVersion};
 use reqwest_client::ReqwestClient;
 use settings::{Settings, SettingsStore};

crates/edit_prediction_types/src/edit_prediction_types.rs 🔗

@@ -95,13 +95,6 @@ pub trait EditPredictionDelegate: 'static + Sized {
         debounce: bool,
         cx: &mut Context<Self>,
     );
-    fn cycle(
-        &mut self,
-        buffer: Entity<Buffer>,
-        cursor_position: language::Anchor,
-        direction: Direction,
-        cx: &mut Context<Self>,
-    );
     fn accept(&mut self, cx: &mut Context<Self>);
     fn discard(&mut self, cx: &mut Context<Self>);
     fn did_show(&mut self, _cx: &mut Context<Self>) {}
@@ -136,13 +129,6 @@ pub trait EditPredictionDelegateHandle {
         debounce: bool,
         cx: &mut App,
     );
-    fn cycle(
-        &self,
-        buffer: Entity<Buffer>,
-        cursor_position: language::Anchor,
-        direction: Direction,
-        cx: &mut App,
-    );
     fn did_show(&self, cx: &mut App);
     fn accept(&self, cx: &mut App);
     fn discard(&self, cx: &mut App);
@@ -215,18 +201,6 @@ where
         })
     }
 
-    fn cycle(
-        &self,
-        buffer: Entity<Buffer>,
-        cursor_position: language::Anchor,
-        direction: Direction,
-        cx: &mut App,
-    ) {
-        self.update(cx, |this, cx| {
-            this.cycle(buffer, cursor_position, direction, cx)
-        })
-    }
-
     fn accept(&self, cx: &mut App) {
         self.update(cx, |this, cx| this.accept(cx))
     }

crates/edit_prediction_ui/src/rate_prediction_modal.rs 🔗

@@ -305,7 +305,7 @@ impl RatePredictionsModal {
                 && prediction.id == prev_prediction.prediction.id
             {
                 if focus {
-                    window.focus(&prev_prediction.feedback_editor.focus_handle(cx));
+                    window.focus(&prev_prediction.feedback_editor.focus_handle(cx), cx);
                 }
                 return;
             }

crates/editor/benches/editor_render.rs 🔗

@@ -29,7 +29,7 @@ fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &TestAppContext
             );
             editor
         });
-        window.focus(&editor.focus_handle(cx));
+        window.focus(&editor.focus_handle(cx), cx);
         editor
     });
 
@@ -72,7 +72,7 @@ fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, Tes
                 editor.set_style(editor::EditorStyle::default(), window, cx);
                 editor
             });
-            window.focus(&editor.focus_handle(cx));
+            window.focus(&editor.focus_handle(cx), cx);
             editor
         });
     });
@@ -100,7 +100,7 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) {
             editor.set_style(editor::EditorStyle::default(), window, cx);
             editor
         });
-        window.focus(&editor.focus_handle(cx));
+        window.focus(&editor.focus_handle(cx), cx);
         editor
     });
 

crates/editor/src/bracket_colorization.rs 🔗

@@ -348,6 +348,61 @@ where
         );
     }
 
+    #[gpui::test]
+    async fn test_bracket_colorization_after_language_swap(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |language_settings| {
+            language_settings.defaults.colorize_brackets = Some(true);
+        });
+
+        let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor()));
+        language_registry.add(markdown_lang());
+        language_registry.add(rust_lang());
+
+        let mut cx = EditorTestContext::new(cx).await;
+        cx.update_buffer(|buffer, cx| {
+            buffer.set_language_registry(language_registry.clone());
+            buffer.set_language(Some(markdown_lang()), cx);
+        });
+
+        cx.set_state(indoc! {r#"
+            fn main() {
+                let v: Vec<Stringˇ> = vec![];
+            }
+        "#});
+        cx.executor().advance_clock(Duration::from_millis(100));
+        cx.executor().run_until_parked();
+
+        assert_eq!(
+            r#"fn main«1()1» «1{
+    let v: Vec<String> = vec!«2[]2»;
+}1»
+
+1 hsla(207.80, 16.20%, 69.19%, 1.00)
+2 hsla(29.00, 54.00%, 65.88%, 1.00)
+"#,
+            &bracket_colors_markup(&mut cx),
+            "Markdown does not colorize <> brackets"
+        );
+
+        cx.update_buffer(|buffer, cx| {
+            buffer.set_language(Some(rust_lang()), cx);
+        });
+        cx.executor().advance_clock(Duration::from_millis(100));
+        cx.executor().run_until_parked();
+
+        assert_eq!(
+            r#"fn main«1()1» «1{
+    let v: Vec«2<String>2» = vec!«2[]2»;
+}1»
+
+1 hsla(207.80, 16.20%, 69.19%, 1.00)
+2 hsla(29.00, 54.00%, 65.88%, 1.00)
+"#,
+            &bracket_colors_markup(&mut cx),
+            "After switching to Rust, <> brackets are now colorized"
+        );
+    }
+
     #[gpui::test]
     async fn test_bracket_colorization_when_editing(cx: &mut gpui::TestAppContext) {
         init_test(cx, |language_settings| {

crates/editor/src/code_context_menus.rs 🔗

@@ -51,6 +51,8 @@ pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.);
 pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.);
 pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.);
 pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.);
+pub const CODE_ACTION_MENU_MIN_WIDTH: Pixels = px(220.);
+pub const CODE_ACTION_MENU_MAX_WIDTH: Pixels = px(540.);
 
 // Constants for the markdown cache. The purpose of this cache is to reduce flickering due to
 // documentation not yet being parsed.
@@ -179,7 +181,7 @@ impl CodeContextMenu {
     ) -> Option<AnyElement> {
         match self {
             CodeContextMenu::Completions(menu) => menu.render_aside(max_size, window, cx),
-            CodeContextMenu::CodeActions(_) => None,
+            CodeContextMenu::CodeActions(menu) => menu.render_aside(max_size, window, cx),
         }
     }
 
@@ -891,7 +893,7 @@ impl CompletionsMenu {
                                     None
                                 } else {
                                     Some(
-                                        Label::new(text.clone())
+                                        Label::new(text.trim().to_string())
                                             .ml_4()
                                             .size(LabelSize::Small)
                                             .color(Color::Muted),
@@ -1419,26 +1421,6 @@ pub enum CodeActionsItem {
 }
 
 impl CodeActionsItem {
-    fn as_task(&self) -> Option<&ResolvedTask> {
-        let Self::Task(_, task) = self else {
-            return None;
-        };
-        Some(task)
-    }
-
-    fn as_code_action(&self) -> Option<&CodeAction> {
-        let Self::CodeAction { action, .. } = self else {
-            return None;
-        };
-        Some(action)
-    }
-    fn as_debug_scenario(&self) -> Option<&DebugScenario> {
-        let Self::DebugScenario(scenario) = self else {
-            return None;
-        };
-        Some(scenario)
-    }
-
     pub fn label(&self) -> String {
         match self {
             Self::CodeAction { action, .. } => action.lsp_action.title().to_owned(),
@@ -1446,6 +1428,14 @@ impl CodeActionsItem {
             Self::DebugScenario(scenario) => scenario.label.to_string(),
         }
     }
+
+    pub fn menu_label(&self) -> String {
+        match self {
+            Self::CodeAction { action, .. } => action.lsp_action.title().replace("\n", ""),
+            Self::Task(_, task) => task.resolved_label.replace("\n", ""),
+            Self::DebugScenario(scenario) => format!("debug: {}", scenario.label),
+        }
+    }
 }
 
 pub struct CodeActionsMenu {
@@ -1555,60 +1545,33 @@ impl CodeActionsMenu {
                         let item_ix = range.start + ix;
                         let selected = item_ix == selected_item;
                         let colors = cx.theme().colors();
-                        div().min_w(px(220.)).max_w(px(540.)).child(
-                            ListItem::new(item_ix)
-                                .inset(true)
-                                .toggle_state(selected)
-                                .when_some(action.as_code_action(), |this, action| {
-                                    this.child(
-                                        h_flex()
-                                            .overflow_hidden()
-                                            .when(is_quick_action_bar, |this| this.text_ui(cx))
-                                            .child(
-                                                // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
-                                                action.lsp_action.title().replace("\n", ""),
-                                            )
-                                            .when(selected, |this| {
-                                                this.text_color(colors.text_accent)
-                                            }),
-                                    )
-                                })
-                                .when_some(action.as_task(), |this, task| {
-                                    this.child(
-                                        h_flex()
-                                            .overflow_hidden()
-                                            .when(is_quick_action_bar, |this| this.text_ui(cx))
-                                            .child(task.resolved_label.replace("\n", ""))
-                                            .when(selected, |this| {
-                                                this.text_color(colors.text_accent)
-                                            }),
-                                    )
-                                })
-                                .when_some(action.as_debug_scenario(), |this, scenario| {
-                                    this.child(
-                                        h_flex()
-                                            .overflow_hidden()
-                                            .when(is_quick_action_bar, |this| this.text_ui(cx))
-                                            .child("debug: ")
-                                            .child(scenario.label.clone())
-                                            .when(selected, |this| {
-                                                this.text_color(colors.text_accent)
-                                            }),
-                                    )
-                                })
-                                .on_click(cx.listener(move |editor, _, window, cx| {
-                                    cx.stop_propagation();
-                                    if let Some(task) = editor.confirm_code_action(
-                                        &ConfirmCodeAction {
-                                            item_ix: Some(item_ix),
-                                        },
-                                        window,
-                                        cx,
-                                    ) {
-                                        task.detach_and_log_err(cx)
-                                    }
-                                })),
-                        )
+
+                        ListItem::new(item_ix)
+                            .inset(true)
+                            .toggle_state(selected)
+                            .overflow_x()
+                            .child(
+                                div()
+                                    .min_w(CODE_ACTION_MENU_MIN_WIDTH)
+                                    .max_w(CODE_ACTION_MENU_MAX_WIDTH)
+                                    .overflow_hidden()
+                                    .text_ellipsis()
+                                    .when(is_quick_action_bar, |this| this.text_ui(cx))
+                                    .when(selected, |this| this.text_color(colors.text_accent))
+                                    .child(action.menu_label()),
+                            )
+                            .on_click(cx.listener(move |editor, _, window, cx| {
+                                cx.stop_propagation();
+                                if let Some(task) = editor.confirm_code_action(
+                                    &ConfirmCodeAction {
+                                        item_ix: Some(item_ix),
+                                    },
+                                    window,
+                                    cx,
+                                ) {
+                                    task.detach_and_log_err(cx)
+                                }
+                            }))
                     })
                     .collect()
             }),
@@ -1635,4 +1598,46 @@ impl CodeActionsMenu {
 
         Popover::new().child(list).into_any_element()
     }
+
+    fn render_aside(
+        &mut self,
+        max_size: Size<Pixels>,
+        window: &mut Window,
+        _cx: &mut Context<Editor>,
+    ) -> Option<AnyElement> {
+        let Some(action) = self.actions.get(self.selected_item) else {
+            return None;
+        };
+
+        let label = action.menu_label();
+        let text_system = window.text_system();
+        let mut line_wrapper = text_system.line_wrapper(
+            window.text_style().font(),
+            window.text_style().font_size.to_pixels(window.rem_size()),
+        );
+        let is_truncated = line_wrapper.should_truncate_line(
+            &label,
+            CODE_ACTION_MENU_MAX_WIDTH,
+            "…",
+            gpui::TruncateFrom::End,
+        );
+
+        if is_truncated.is_none() {
+            return None;
+        }
+
+        Some(
+            Popover::new()
+                .child(
+                    div()
+                        .child(label)
+                        .id("code_actions_menu_extended")
+                        .px(MENU_ASIDE_X_PADDING / 2.)
+                        .max_w(max_size.width)
+                        .max_h(max_size.height)
+                        .occlude(),
+                )
+                .into_any_element(),
+        )
+    }
 }

crates/editor/src/display_map.rs 🔗

@@ -14,8 +14,57 @@
 //! - [`DisplayMap`] that adds background highlights to the regions of text.
 //!   Each one of those builds on top of preceding map.
 //!
+//! ## Structure of the display map layers
+//!
+//! Each layer in the map (and the multibuffer itself to some extent) has a few
+//! structures that are used to implement the public API available to the layer
+//! above:
+//! - a `Transform` type - this represents a region of text that the layer in
+//!   question is "managing", that it transforms into a more "processed" text
+//!   for the layer above. For example, the inlay map has an `enum Transform`
+//!   that has two variants:
+//!     - `Isomorphic`, representing a region of text that has no inlay hints (i.e.
+//!       is passed through the map transparently)
+//!     - `Inlay`, representing a location where an inlay hint is to be inserted.
+//! - a `TransformSummary` type, which is usually a struct with two fields:
+//!   [`input: TextSummary`][`TextSummary`] and [`output: TextSummary`][`TextSummary`]. Here,
+//!   `input` corresponds to "text in the layer below", and `output` corresponds to the text
+//!   exposed to the layer above. So in the inlay map case, a `Transform::Isomorphic`'s summary is
+//!   just `input = output = summary`, where `summary` is the [`TextSummary`] stored in that
+//!   variant. Conversely, a `Transform::Inlay` always has an empty `input` summary, because it's
+//!   not "replacing" any text that exists on disk. The `output` is the summary of the inlay text
+//!   to be injected. - Various newtype wrappers for co-ordinate spaces (e.g. [`WrapRow`]
+//!   represents a row index, after soft-wrapping (and all lower layers)).
+//! - A `Snapshot` type (e.g. [`InlaySnapshot`]) that captures the state of a layer at a specific
+//!   point in time.
+//! - various APIs which drill through the layers below to work with the underlying text. Notably:
+//!   - `fn text_summary_for_offset()` returns a [`TextSummary`] for the range in the co-ordinate
+//!     space that the map in question is responsible for.
+//!   - `fn <A>_point_to_<B>_point()` converts a point in co-ordinate space `A` into co-ordinate
+//!     space `B`.
+//!   - A [`RowInfo`] iterator (e.g. [`InlayBufferRows`]) and a [`Chunk`] iterator
+//!     (e.g. [`InlayChunks`])
+//!   - A `sync` function (e.g. [`InlayMap::sync`]) that takes a snapshot and list of [`Edit<T>`]s,
+//!     and returns a new snapshot and a list of transformed [`Edit<S>`]s. Note that the generic
+//!     parameter on `Edit` changes, since these methods take in edits in the co-ordinate space of
+//!     the lower layer, and return edits in their own co-ordinate space. The term "edit" is
+//!     slightly misleading, since an [`Edit<T>`] doesn't tell you what changed - rather it can be
+//!     thought of as a "region to invalidate". In theory, it would be correct to always use a
+//!     single edit that covers the entire range. However, this would lead to lots of unnecessary
+//!     recalculation.
+//!
+//! See the docs for the [`inlay_map`] module for a more in-depth explanation of how a single layer
+//! works.
+//!
 //! [Editor]: crate::Editor
 //! [EditorElement]: crate::element::EditorElement
+//! [`TextSummary`]: multi_buffer::MBTextSummary
+//! [`WrapRow`]: wrap_map::WrapRow
+//! [`InlayBufferRows`]: inlay_map::InlayBufferRows
+//! [`InlayChunks`]: inlay_map::InlayChunks
+//! [`Edit<T>`]: text::Edit
+//! [`Edit<S>`]: text::Edit
+//! [`Chunk`]: language::Chunk
 
 #[macro_use]
 mod dimensions;

crates/editor/src/display_map/block_map.rs 🔗

@@ -545,7 +545,7 @@ impl BlockMap {
         {
             let max_point = wrap_snapshot.max_point();
             let edit_start = wrap_snapshot.prev_row_boundary(max_point);
-            let edit_end = max_point.row() + WrapRow(1);
+            let edit_end = max_point.row() + WrapRow(1); // this is end of file
             edits = edits.compose([WrapEdit {
                 old: edit_start..edit_end,
                 new: edit_start..edit_end,
@@ -715,6 +715,7 @@ impl BlockMap {
                         let placement = block.placement.to_wrap_row(wrap_snapshot)?;
                         if let BlockPlacement::Above(row) = placement
                             && row < new_start
+                        // this will be true more often now
                         {
                             return None;
                         }

crates/editor/src/display_map/inlay_map.rs 🔗

@@ -1,3 +1,10 @@
+//! The inlay map. See the [`display_map`][super] docs for an overview of how the inlay map fits
+//! into the rest of the [`DisplayMap`][super::DisplayMap]. Much of the documentation for this
+//! module generalizes to other layers.
+//!
+//! The core of this module is the [`InlayMap`] struct, which maintains a vec of [`Inlay`]s, and
+//! [`InlaySnapshot`], which holds a sum tree of [`Transform`]s.
+
 use crate::{
     ChunkRenderer, HighlightStyles,
     inlays::{Inlay, InlayContent},
@@ -69,7 +76,9 @@ impl sum_tree::Item for Transform {
 
 #[derive(Clone, Debug, Default)]
 struct TransformSummary {
+    /// Summary of the text before inlays have been applied.
     input: MBTextSummary,
+    /// Summary of the text after inlays have been applied.
     output: MBTextSummary,
 }
 

crates/editor/src/display_map/wrap_map.rs 🔗

@@ -840,35 +840,62 @@ impl WrapSnapshot {
         self.tab_point_to_wrap_point(self.tab_snapshot.clip_point(self.to_tab_point(point), bias))
     }
 
-    #[ztracing::instrument(skip_all, fields(point=?point, ret))]
-    pub fn prev_row_boundary(&self, mut point: WrapPoint) -> WrapRow {
+    /// Try to find a TabRow start that is also a WrapRow start
+    /// Every TabRow start is a WrapRow start
+    #[ztracing::instrument(skip_all, fields(point=?point))]
+    pub fn prev_row_boundary(&self, point: WrapPoint) -> WrapRow {
         if self.transforms.is_empty() {
             return WrapRow(0);
         }
 
-        *point.column_mut() = 0;
+        let point = WrapPoint::new(point.row(), 0);
 
         let mut cursor = self
             .transforms
             .cursor::<Dimensions<WrapPoint, TabPoint>>(());
-        // start
+
         cursor.seek(&point, Bias::Right);
-        // end
         if cursor.item().is_none() {
             cursor.prev();
         }
 
-        // start
+        //                          real newline     fake          fake
+        // text:      helloworldasldlfjasd\njdlasfalsk\naskdjfasdkfj\n
+        // dimensions v       v           v            v            v
+        // transforms |-------|-----NW----|-----W------|-----W------|
+        // cursor    ^        ^^^^^^^^^^^^^                          ^
+        //                               (^)           ^^^^^^^^^^^^^^
+        // point:                                            ^
+        // point(col_zero):                           (^)
+
         while let Some(transform) = cursor.item() {
-            if transform.is_isomorphic() && cursor.start().1.column() == 0 {
-                return cmp::min(cursor.end().0.row(), point.row());
-            } else {
-                cursor.prev();
+            if transform.is_isomorphic() {
+                // this transform only has real linefeeds
+                let tab_summary = &transform.summary.input;
+                // is the wrap just before the end of the transform a tab row?
+                // thats only if this transform has at least one newline
+                //
+                // "this wrap row is a tab row" <=> self.to_tab_point(WrapPoint::new(wrap_row, 0)).column() == 0
+
+                // Note on comparison:
+                // We have code that relies on this to be row > 1
+                // It should work with row >= 1 but it does not :(
+                //
+                // That means that if every line is wrapped we walk back all the
+                // way to the start. Which invalidates the entire state triggering
+                // a full re-render.
+                if tab_summary.lines.row > 1 {
+                    let wrap_point_at_end = cursor.end().0.row();
+                    return cmp::min(wrap_point_at_end - RowDelta(1), point.row());
+                } else if cursor.start().1.column() == 0 {
+                    return cmp::min(cursor.end().0.row(), point.row());
+                }
             }
+
+            cursor.prev();
         }
-        // end
 
-        unreachable!()
+        WrapRow(0)
     }
 
     #[ztracing::instrument(skip_all)]
@@ -891,13 +918,11 @@ impl WrapSnapshot {
     }
 
     #[cfg(test)]
-    #[ztracing::instrument(skip_all)]
     pub fn text(&self) -> String {
         self.text_chunks(WrapRow(0)).collect()
     }
 
     #[cfg(test)]
-    #[ztracing::instrument(skip_all)]
     pub fn text_chunks(&self, wrap_row: WrapRow) -> impl Iterator<Item = &str> {
         self.chunks(
             wrap_row..self.max_point().row() + WrapRow(1),
@@ -1298,6 +1323,71 @@ mod tests {
     use text::Rope;
     use theme::LoadThemes;
 
+    #[gpui::test]
+    async fn test_prev_row_boundary(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        fn test_wrap_snapshot(
+            text: &str,
+            soft_wrap_every: usize, // font size multiple
+            cx: &mut gpui::TestAppContext,
+        ) -> WrapSnapshot {
+            let text_system = cx.read(|cx| cx.text_system().clone());
+            let tab_size = 4.try_into().unwrap();
+            let font = test_font();
+            let _font_id = text_system.resolve_font(&font);
+            let font_size = px(14.0);
+            // this is very much an estimate to try and get the wrapping to
+            // occur at `soft_wrap_every` we check that it pans out for every test case
+            let soft_wrapping = Some(font_size * soft_wrap_every * 0.6);
+
+            let buffer = cx.new(|cx| language::Buffer::local(text, cx));
+            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+            let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
+            let (_inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
+            let (_fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot);
+            let (mut tab_map, _) = TabMap::new(fold_snapshot, tab_size);
+            let tabs_snapshot = tab_map.set_max_expansion_column(32);
+            let (_wrap_map, wrap_snapshot) =
+                cx.update(|cx| WrapMap::new(tabs_snapshot, font, font_size, soft_wrapping, cx));
+
+            wrap_snapshot
+        }
+
+        // These two should pass but dont, see the comparison note in
+        // prev_row_boundary about why.
+        //
+        // //                                      0123  4567  wrap_rows
+        // let wrap_snapshot = test_wrap_snapshot("1234\n5678", 1, cx);
+        // assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8");
+        // let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+        // assert_eq!(row.0, 3);
+
+        // //                                      012  345  678  wrap_rows
+        // let wrap_snapshot = test_wrap_snapshot("123\n456\n789", 1, cx);
+        // assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9");
+        // let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+        // assert_eq!(row.0, 5);
+
+        //                                      012345678  wrap_rows
+        let wrap_snapshot = test_wrap_snapshot("123456789", 1, cx);
+        assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9");
+        let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+        assert_eq!(row.0, 0);
+
+        //                                      111  2222    44  wrap_rows
+        let wrap_snapshot = test_wrap_snapshot("123\n4567\n\n89", 4, cx);
+        assert_eq!(wrap_snapshot.text(), "123\n4567\n\n89");
+        let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+        assert_eq!(row.0, 2);
+
+        //                                      11  2223   wrap_rows
+        let wrap_snapshot = test_wrap_snapshot("12\n3456\n\n", 3, cx);
+        assert_eq!(wrap_snapshot.text(), "12\n345\n6\n\n");
+        let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point());
+        assert_eq!(row.0, 3);
+    }
+
     #[gpui::test(iterations = 100)]
     async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) {
         // todo this test is flaky

crates/editor/src/edit_prediction_tests.rs 🔗

@@ -485,15 +485,6 @@ impl EditPredictionDelegate for FakeEditPredictionDelegate {
     ) {
     }
 
-    fn cycle(
-        &mut self,
-        _buffer: gpui::Entity<language::Buffer>,
-        _cursor_position: language::Anchor,
-        _direction: edit_prediction_types::Direction,
-        _cx: &mut gpui::Context<Self>,
-    ) {
-    }
-
     fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
 
     fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}
@@ -561,15 +552,6 @@ impl EditPredictionDelegate for FakeNonZedEditPredictionDelegate {
     ) {
     }
 
-    fn cycle(
-        &mut self,
-        _buffer: gpui::Entity<language::Buffer>,
-        _cursor_position: language::Anchor,
-        _direction: edit_prediction_types::Direction,
-        _cx: &mut gpui::Context<Self>,
-    ) {
-    }
-
     fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
 
     fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}

crates/editor/src/editor.rs 🔗

@@ -73,11 +73,7 @@ pub use multi_buffer::{
 pub use split::SplittableEditor;
 pub use text::Bias;
 
-use ::git::{
-    Restore,
-    blame::{BlameEntry, ParsedCommitMessage},
-    status::FileStatus,
-};
+use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
 use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError};
 use anyhow::{Context as _, Result, anyhow, bail};
 use blink_manager::BlinkManager;
@@ -124,8 +120,9 @@ use language::{
     AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow,
     BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
     DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
-    IndentSize, Language, LanguageName, LanguageRegistry, OffsetRangeExt, OutlineItem, Point,
-    Runnable, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
+    IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, OffsetRangeExt,
+    OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId,
+    TreeSitterOptions, WordsQuery,
     language_settings::{
         self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
         all_language_settings, language_settings,
@@ -166,6 +163,7 @@ use project::{
     project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings},
 };
 use rand::seq::SliceRandom;
+use regex::Regex;
 use rpc::{ErrorCode, ErrorExt, proto::PeerId};
 use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager};
 use selections_collection::{MutableSelectionsCollection, SelectionsCollection};
@@ -202,7 +200,6 @@ use ui::{
     IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide,
 };
 use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc};
-use vim_mode_setting::VimModeSetting;
 use workspace::{
     CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal,
     RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast,
@@ -1111,6 +1108,9 @@ pub struct Editor {
     pending_rename: Option<RenameState>,
     searchable: bool,
     cursor_shape: CursorShape,
+    /// Whether the cursor is offset one character to the left when something is
+    /// selected (needed for vim visual mode)
+    cursor_offset_on_selection: bool,
     current_line_highlight: Option<CurrentLineHighlight>,
     pub collapse_matches: bool,
     autoindent_mode: Option<AutoindentMode>,
@@ -2062,46 +2062,34 @@ impl Editor {
                                         })
                                     });
                             });
-                            let edited_buffers_already_open = {
-                                let other_editors: Vec<Entity<Editor>> = workspace
-                                    .read(cx)
-                                    .panes()
-                                    .iter()
-                                    .flat_map(|pane| pane.read(cx).items_of_type::<Editor>())
-                                    .filter(|editor| editor.entity_id() != cx.entity_id())
-                                    .collect();
-
-                                transaction.0.keys().all(|buffer| {
-                                    other_editors.iter().any(|editor| {
-                                        let multi_buffer = editor.read(cx).buffer();
-                                        multi_buffer.read(cx).is_singleton()
-                                            && multi_buffer.read(cx).as_singleton().map_or(
-                                                false,
-                                                |singleton| {
-                                                    singleton.entity_id() == buffer.entity_id()
-                                                },
-                                            )
-                                    })
-                                })
-                            };
-                            if !edited_buffers_already_open {
-                                let workspace = workspace.downgrade();
-                                let transaction = transaction.clone();
-                                cx.defer_in(window, move |_, window, cx| {
-                                    cx.spawn_in(window, async move |editor, cx| {
-                                        Self::open_project_transaction(
-                                            &editor,
-                                            workspace,
-                                            transaction,
-                                            "Rename".to_string(),
-                                            cx,
-                                        )
-                                        .await
-                                        .ok()
-                                    })
-                                    .detach();
-                                });
-                            }
+
+                            Self::open_transaction_for_hidden_buffers(
+                                workspace,
+                                transaction.clone(),
+                                "Rename".to_string(),
+                                window,
+                                cx,
+                            );
+                        }
+                    }
+
+                    project::Event::WorkspaceEditApplied(transaction) => {
+                        let Some(workspace) = editor.workspace() else {
+                            return;
+                        };
+                        let Some(active_editor) = workspace.read(cx).active_item_as::<Self>(cx)
+                        else {
+                            return;
+                        };
+
+                        if active_editor.entity_id() == cx.entity_id() {
+                            Self::open_transaction_for_hidden_buffers(
+                                workspace,
+                                transaction.clone(),
+                                "LSP Edit".to_string(),
+                                window,
+                                cx,
+                            );
                         }
                     }
 
@@ -2287,6 +2275,7 @@ impl Editor {
             cursor_shape: EditorSettings::get_global(cx)
                 .cursor_shape
                 .unwrap_or_default(),
+            cursor_offset_on_selection: false,
             current_line_highlight: None,
             autoindent_mode: Some(AutoindentMode::EachLine),
             collapse_matches: false,
@@ -2474,7 +2463,10 @@ impl Editor {
                     }
                 }
                 EditorEvent::Edited { .. } => {
-                    if !editor.is_vim_mode_enabled(cx) {
+                    let vim_mode = vim_mode_setting::VimModeSetting::try_get(cx)
+                        .map(|vim_mode| vim_mode.0)
+                        .unwrap_or(false);
+                    if !vim_mode {
                         let display_map = editor.display_snapshot(cx);
                         let selections = editor.selections.all_adjusted_display(&display_map);
                         let pop_state = editor
@@ -3103,6 +3095,10 @@ impl Editor {
         self.cursor_shape
     }
 
+    pub fn set_cursor_offset_on_selection(&mut self, set_cursor_offset_on_selection: bool) {
+        self.cursor_offset_on_selection = set_cursor_offset_on_selection;
+    }
+
     pub fn set_current_line_highlight(
         &mut self,
         current_line_highlight: Option<CurrentLineHighlight>,
@@ -3419,7 +3415,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);
@@ -3459,7 +3456,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;
         }
@@ -3817,7 +3815,7 @@ impl Editor {
     ) {
         if !self.focus_handle.is_focused(window) {
             self.last_focused_descendant = None;
-            window.focus(&self.focus_handle);
+            window.focus(&self.focus_handle, cx);
         }
 
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -3922,7 +3920,7 @@ impl Editor {
     ) {
         if !self.focus_handle.is_focused(window) {
             self.last_focused_descendant = None;
-            window.focus(&self.focus_handle);
+            window.focus(&self.focus_handle, cx);
         }
 
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -4396,10 +4394,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 {
@@ -4752,18 +4790,27 @@ impl Editor {
                         let end = selection.end;
                         let selection_is_empty = start == end;
                         let language_scope = buffer.language_scope_at(start);
-                        let (
-                            comment_delimiter,
-                            doc_delimiter,
-                            insert_extra_newline,
-                            indent_on_newline,
-                            indent_on_extra_newline,
-                        ) = if let Some(language) = &language_scope {
-                            let mut insert_extra_newline =
-                                insert_extra_newline_brackets(&buffer, start..end, language)
-                                    || insert_extra_newline_tree_sitter(&buffer, start..end);
-
-                            // Comment extension on newline is allowed only for cursor selections
+                        let (delimiter, newline_config) = if let Some(language) = &language_scope {
+                            let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets(
+                                &buffer,
+                                start..end,
+                                language,
+                            )
+                                || NewlineConfig::insert_extra_newline_tree_sitter(
+                                    &buffer,
+                                    start..end,
+                                );
+
+                            let mut newline_config = NewlineConfig::Newline {
+                                additional_indent: IndentSize::spaces(0),
+                                extra_line_additional_indent: if needs_extra_newline {
+                                    Some(IndentSize::spaces(0))
+                                } else {
+                                    None
+                                },
+                                prevent_auto_indent: false,
+                            };
+
                             let comment_delimiter = maybe!({
                                 if !selection_is_empty {
                                     return None;
@@ -4773,63 +4820,13 @@ impl Editor {
                                     return None;
                                 }
 
-                                let delimiters = language.line_comment_prefixes();
-                                let max_len_of_delimiter =
-                                    delimiters.iter().map(|delimiter| delimiter.len()).max()?;
-                                let (snapshot, range) =
-                                    buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
-
-                                let num_of_whitespaces = snapshot
-                                    .chars_for_range(range.clone())
-                                    .take_while(|c| c.is_whitespace())
-                                    .count();
-                                let comment_candidate = snapshot
-                                    .chars_for_range(range.clone())
-                                    .skip(num_of_whitespaces)
-                                    .take(max_len_of_delimiter)
-                                    .collect::<String>();
-                                let (delimiter, trimmed_len) = delimiters
-                                    .iter()
-                                    .filter_map(|delimiter| {
-                                        let prefix = delimiter.trim_end();
-                                        if comment_candidate.starts_with(prefix) {
-                                            Some((delimiter, prefix.len()))
-                                        } else {
-                                            None
-                                        }
-                                    })
-                                    .max_by_key(|(_, len)| *len)?;
-
-                                if let Some(BlockCommentConfig {
-                                    start: block_start, ..
-                                }) = language.block_comment()
-                                {
-                                    let block_start_trimmed = block_start.trim_end();
-                                    if block_start_trimmed.starts_with(delimiter.trim_end()) {
-                                        let line_content = snapshot
-                                            .chars_for_range(range)
-                                            .skip(num_of_whitespaces)
-                                            .take(block_start_trimmed.len())
-                                            .collect::<String>();
-
-                                        if line_content.starts_with(block_start_trimmed) {
-                                            return None;
-                                        }
-                                    }
-                                }
-
-                                let cursor_is_placed_after_comment_marker =
-                                    num_of_whitespaces + trimmed_len <= start_point.column as usize;
-                                if cursor_is_placed_after_comment_marker {
-                                    Some(delimiter.clone())
-                                } else {
-                                    None
-                                }
+                                return comment_delimiter_for_newline(
+                                    &start_point,
+                                    &buffer,
+                                    language,
+                                );
                             });
 
-                            let mut indent_on_newline = IndentSize::spaces(0);
-                            let mut indent_on_extra_newline = IndentSize::spaces(0);
-
                             let doc_delimiter = maybe!({
                                 if !selection_is_empty {
                                     return None;
@@ -4839,149 +4836,100 @@ impl Editor {
                                     return None;
                                 }
 
-                                let BlockCommentConfig {
-                                    start: start_tag,
-                                    end: end_tag,
-                                    prefix: delimiter,
-                                    tab_size: len,
-                                } = language.documentation_comment()?;
-                                let is_within_block_comment = buffer
-                                    .language_scope_at(start_point)
-                                    .is_some_and(|scope| scope.override_name() == Some("comment"));
-                                if !is_within_block_comment {
+                                return documentation_delimiter_for_newline(
+                                    &start_point,
+                                    &buffer,
+                                    language,
+                                    &mut newline_config,
+                                );
+                            });
+
+                            let list_delimiter = maybe!({
+                                if !selection_is_empty {
                                     return None;
                                 }
 
-                                let (snapshot, range) =
-                                    buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
-
-                                let num_of_whitespaces = snapshot
-                                    .chars_for_range(range.clone())
-                                    .take_while(|c| c.is_whitespace())
-                                    .count();
-
-                                // It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time.
-                                let column = start_point.column;
-                                let cursor_is_after_start_tag = {
-                                    let start_tag_len = start_tag.len();
-                                    let start_tag_line = snapshot
-                                        .chars_for_range(range.clone())
-                                        .skip(num_of_whitespaces)
-                                        .take(start_tag_len)
-                                        .collect::<String>();
-                                    if start_tag_line.starts_with(start_tag.as_ref()) {
-                                        num_of_whitespaces + start_tag_len <= column as usize
-                                    } else {
-                                        false
-                                    }
-                                };
-
-                                let cursor_is_after_delimiter = {
-                                    let delimiter_trim = delimiter.trim_end();
-                                    let delimiter_line = snapshot
-                                        .chars_for_range(range.clone())
-                                        .skip(num_of_whitespaces)
-                                        .take(delimiter_trim.len())
-                                        .collect::<String>();
-                                    if delimiter_line.starts_with(delimiter_trim) {
-                                        num_of_whitespaces + delimiter_trim.len() <= column as usize
-                                    } else {
-                                        false
-                                    }
-                                };
-
-                                let cursor_is_before_end_tag_if_exists = {
-                                    let mut char_position = 0u32;
-                                    let mut end_tag_offset = None;
-
-                                    'outer: for chunk in snapshot.text_for_range(range) {
-                                        if let Some(byte_pos) = chunk.find(&**end_tag) {
-                                            let chars_before_match =
-                                                chunk[..byte_pos].chars().count() as u32;
-                                            end_tag_offset =
-                                                Some(char_position + chars_before_match);
-                                            break 'outer;
-                                        }
-                                        char_position += chunk.chars().count() as u32;
-                                    }
-
-                                    if let Some(end_tag_offset) = end_tag_offset {
-                                        let cursor_is_before_end_tag = column <= end_tag_offset;
-                                        if cursor_is_after_start_tag {
-                                            if cursor_is_before_end_tag {
-                                                insert_extra_newline = true;
-                                            }
-                                            let cursor_is_at_start_of_end_tag =
-                                                column == end_tag_offset;
-                                            if cursor_is_at_start_of_end_tag {
-                                                indent_on_extra_newline.len = *len;
-                                            }
-                                        }
-                                        cursor_is_before_end_tag
-                                    } else {
-                                        true
-                                    }
-                                };
-
-                                if (cursor_is_after_start_tag || cursor_is_after_delimiter)
-                                    && cursor_is_before_end_tag_if_exists
-                                {
-                                    if cursor_is_after_start_tag {
-                                        indent_on_newline.len = *len;
-                                    }
-                                    Some(delimiter.clone())
-                                } else {
-                                    None
+                                if !multi_buffer.language_settings(cx).extend_list_on_newline {
+                                    return None;
                                 }
+
+                                return list_delimiter_for_newline(
+                                    &start_point,
+                                    &buffer,
+                                    language,
+                                    &mut newline_config,
+                                );
                             });
 
                             (
-                                comment_delimiter,
-                                doc_delimiter,
-                                insert_extra_newline,
-                                indent_on_newline,
-                                indent_on_extra_newline,
+                                comment_delimiter.or(doc_delimiter).or(list_delimiter),
+                                newline_config,
                             )
                         } else {
                             (
                                 None,
-                                None,
-                                false,
-                                IndentSize::default(),
-                                IndentSize::default(),
+                                NewlineConfig::Newline {
+                                    additional_indent: IndentSize::spaces(0),
+                                    extra_line_additional_indent: None,
+                                    prevent_auto_indent: false,
+                                },
                             )
                         };
 
-                        let prevent_auto_indent = doc_delimiter.is_some();
-                        let delimiter = comment_delimiter.or(doc_delimiter);
-
-                        let capacity_for_delimiter =
-                            delimiter.as_deref().map(str::len).unwrap_or_default();
-                        let mut new_text = String::with_capacity(
-                            1 + capacity_for_delimiter
-                                + existing_indent.len as usize
-                                + indent_on_newline.len as usize
-                                + indent_on_extra_newline.len as usize,
-                        );
-                        new_text.push('\n');
-                        new_text.extend(existing_indent.chars());
-                        new_text.extend(indent_on_newline.chars());
-
-                        if let Some(delimiter) = &delimiter {
-                            new_text.push_str(delimiter);
-                        }
-
-                        if insert_extra_newline {
-                            new_text.push('\n');
-                            new_text.extend(existing_indent.chars());
-                            new_text.extend(indent_on_extra_newline.chars());
-                        }
+                        let (edit_start, new_text, prevent_auto_indent) = match &newline_config {
+                            NewlineConfig::ClearCurrentLine => {
+                                let row_start =
+                                    buffer.point_to_offset(Point::new(start_point.row, 0));
+                                (row_start, String::new(), false)
+                            }
+                            NewlineConfig::UnindentCurrentLine { continuation } => {
+                                let row_start =
+                                    buffer.point_to_offset(Point::new(start_point.row, 0));
+                                let tab_size = buffer.language_settings_at(start, cx).tab_size;
+                                let tab_size_indent = IndentSize::spaces(tab_size.get());
+                                let reduced_indent =
+                                    existing_indent.with_delta(Ordering::Less, tab_size_indent);
+                                let mut new_text = String::new();
+                                new_text.extend(reduced_indent.chars());
+                                new_text.push_str(continuation);
+                                (row_start, new_text, true)
+                            }
+                            NewlineConfig::Newline {
+                                additional_indent,
+                                extra_line_additional_indent,
+                                prevent_auto_indent,
+                            } => {
+                                let capacity_for_delimiter =
+                                    delimiter.as_deref().map(str::len).unwrap_or_default();
+                                let extra_line_len = extra_line_additional_indent
+                                    .map(|i| 1 + existing_indent.len as usize + i.len as usize)
+                                    .unwrap_or(0);
+                                let mut new_text = String::with_capacity(
+                                    1 + capacity_for_delimiter
+                                        + existing_indent.len as usize
+                                        + additional_indent.len as usize
+                                        + extra_line_len,
+                                );
+                                new_text.push('\n');
+                                new_text.extend(existing_indent.chars());
+                                new_text.extend(additional_indent.chars());
+                                if let Some(delimiter) = &delimiter {
+                                    new_text.push_str(delimiter);
+                                }
+                                if let Some(extra_indent) = extra_line_additional_indent {
+                                    new_text.push('\n');
+                                    new_text.extend(existing_indent.chars());
+                                    new_text.extend(extra_indent.chars());
+                                }
+                                (start, new_text, *prevent_auto_indent)
+                            }
+                        };
 
                         let anchor = buffer.anchor_after(end);
                         let new_selection = selection.map(|_| anchor);
                         (
-                            ((start..end, new_text), prevent_auto_indent),
-                            (insert_extra_newline, new_selection),
+                            ((edit_start..end, new_text), prevent_auto_indent),
+                            (newline_config.has_extra_line(), new_selection),
                         )
                     })
                     .unzip()
@@ -6622,6 +6570,52 @@ impl Editor {
         }
     }
 
+    fn open_transaction_for_hidden_buffers(
+        workspace: Entity<Workspace>,
+        transaction: ProjectTransaction,
+        title: String,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if transaction.0.is_empty() {
+            return;
+        }
+
+        let edited_buffers_already_open = {
+            let other_editors: Vec<Entity<Editor>> = workspace
+                .read(cx)
+                .panes()
+                .iter()
+                .flat_map(|pane| pane.read(cx).items_of_type::<Editor>())
+                .filter(|editor| editor.entity_id() != cx.entity_id())
+                .collect();
+
+            transaction.0.keys().all(|buffer| {
+                other_editors.iter().any(|editor| {
+                    let multi_buffer = editor.read(cx).buffer();
+                    multi_buffer.read(cx).is_singleton()
+                        && multi_buffer
+                            .read(cx)
+                            .as_singleton()
+                            .map_or(false, |singleton| {
+                                singleton.entity_id() == buffer.entity_id()
+                            })
+                })
+            })
+        };
+        if !edited_buffers_already_open {
+            let workspace = workspace.downgrade();
+            cx.defer_in(window, move |_, window, cx| {
+                cx.spawn_in(window, async move |editor, cx| {
+                    Self::open_project_transaction(&editor, workspace, transaction, title, cx)
+                        .await
+                        .ok()
+                })
+                .detach();
+            });
+        }
+    }
+
     pub async fn open_project_transaction(
         editor: &WeakEntity<Editor>,
         workspace: WeakEntity<Workspace>,
@@ -6781,7 +6775,7 @@ impl Editor {
                 })
             })
             .on_click(cx.listener(move |editor, _: &ClickEvent, window, cx| {
-                window.focus(&editor.focus_handle(cx));
+                window.focus(&editor.focus_handle(cx), cx);
                 editor.toggle_code_actions(
                     &crate::actions::ToggleCodeActions {
                         deployed_from: Some(crate::actions::CodeActionSource::Indicator(
@@ -7537,26 +7531,6 @@ impl Editor {
         .unwrap_or(false)
     }
 
-    fn cycle_edit_prediction(
-        &mut self,
-        direction: Direction,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Option<()> {
-        let provider = self.edit_prediction_provider()?;
-        let cursor = self.selections.newest_anchor().head();
-        let (buffer, cursor_buffer_position) =
-            self.buffer.read(cx).text_anchor_for_position(cursor, cx)?;
-        if self.edit_predictions_hidden_for_vim_mode || !self.should_show_edit_predictions() {
-            return None;
-        }
-
-        provider.cycle(buffer, cursor_buffer_position, direction, cx);
-        self.update_visible_edit_prediction(window, cx);
-
-        Some(())
-    }
-
     pub fn show_edit_prediction(
         &mut self,
         _: &ShowEditPrediction,
@@ -7594,42 +7568,6 @@ impl Editor {
         .detach();
     }
 
-    pub fn next_edit_prediction(
-        &mut self,
-        _: &NextEditPrediction,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        if self.has_active_edit_prediction() {
-            self.cycle_edit_prediction(Direction::Next, window, cx);
-        } else {
-            let is_copilot_disabled = self
-                .refresh_edit_prediction(false, true, window, cx)
-                .is_none();
-            if is_copilot_disabled {
-                cx.propagate();
-            }
-        }
-    }
-
-    pub fn previous_edit_prediction(
-        &mut self,
-        _: &PreviousEditPrediction,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        if self.has_active_edit_prediction() {
-            self.cycle_edit_prediction(Direction::Prev, window, cx);
-        } else {
-            let is_copilot_disabled = self
-                .refresh_edit_prediction(false, true, window, cx)
-                .is_none();
-            if is_copilot_disabled {
-                cx.propagate();
-            }
-        }
-    }
-
     pub fn accept_partial_edit_prediction(
         &mut self,
         granularity: EditPredictionGranularity,
@@ -8674,7 +8612,7 @@ impl Editor {
                         BreakpointEditAction::Toggle
                     };
 
-                    window.focus(&editor.focus_handle(cx));
+                    window.focus(&editor.focus_handle(cx), cx);
                     editor.edit_breakpoint_at_anchor(
                         position,
                         breakpoint.as_ref().clone(),
@@ -8866,7 +8804,7 @@ impl Editor {
                 ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
             };
 
-            window.focus(&editor.focus_handle(cx));
+            window.focus(&editor.focus_handle(cx), cx);
             editor.toggle_code_actions(
                 &ToggleCodeActions {
                     deployed_from: Some(CodeActionSource::RunMenu(row)),
@@ -10516,6 +10454,22 @@ impl Editor {
             }
             prev_edited_row = selection.end.row;
 
+            // If cursor is after a list prefix, make selection non-empty to trigger line indent
+            if selection.is_empty() {
+                let cursor = selection.head();
+                let settings = buffer.language_settings_at(cursor, cx);
+                if settings.indent_list_on_tab {
+                    if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) {
+                        if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) {
+                            row_delta = Self::indent_selection(
+                                buffer, &snapshot, selection, &mut edits, row_delta, cx,
+                            );
+                            continue;
+                        }
+                    }
+                }
+            }
+
             // If the selection is non-empty, then increase the indentation of the selected lines.
             if !selection.is_empty() {
                 row_delta =
@@ -11281,7 +11235,7 @@ impl Editor {
         }];
 
         let focus_handle = bp_prompt.focus_handle(cx);
-        window.focus(&focus_handle);
+        window.focus(&focus_handle, cx);
 
         let block_ids = self.insert_blocks(blocks, None, cx);
         bp_prompt.update(cx, |prompt, _| {
@@ -15484,10 +15438,9 @@ impl Editor {
         I: IntoIterator<Item = P>,
         P: AsRef<[u8]>,
     {
-        let case_sensitive = self.select_next_is_case_sensitive.map_or_else(
-            || EditorSettings::get_global(cx).search.case_sensitive,
-            |value| value,
-        );
+        let case_sensitive = self
+            .select_next_is_case_sensitive
+            .unwrap_or_else(|| EditorSettings::get_global(cx).search.case_sensitive);
 
         let mut builder = AhoCorasickBuilder::new();
         builder.ascii_case_insensitive(!case_sensitive);
@@ -17429,7 +17382,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)) => {
@@ -18102,7 +18062,7 @@ impl Editor {
                         cx,
                     );
                     let rename_focus_handle = rename_editor.focus_handle(cx);
-                    window.focus(&rename_focus_handle);
+                    window.focus(&rename_focus_handle, cx);
                     let block_id = this.insert_blocks(
                         [BlockProperties {
                             style: BlockStyle::Flex,
@@ -18216,7 +18176,7 @@ impl Editor {
     ) -> Option<RenameState> {
         let rename = self.pending_rename.take()?;
         if rename.editor.focus_handle(cx).is_focused(window) {
-            window.focus(&self.focus_handle);
+            window.focus(&self.focus_handle, cx);
         }
 
         self.remove_blocks(
@@ -20511,7 +20471,7 @@ impl Editor {
         EditorSettings::get_global(cx).gutter.line_numbers
     }
 
-    pub fn relative_line_numbers(&self, cx: &mut App) -> RelativeLineNumbers {
+    pub fn relative_line_numbers(&self, cx: &App) -> RelativeLineNumbers {
         match (
             self.use_relative_line_numbers,
             EditorSettings::get_global(cx).relative_line_numbers,
@@ -22593,7 +22553,10 @@ impl Editor {
             .and_then(|e| e.to_str())
             .map(|a| a.to_string()));
 
-        let vim_mode_enabled = self.is_vim_mode_enabled(cx);
+        let vim_mode = vim_mode_setting::VimModeSetting::try_get(cx)
+            .map(|vim_mode| vim_mode.0)
+            .unwrap_or(false);
+
         let edit_predictions_provider = all_language_settings(file, cx).edit_predictions.provider;
         let copilot_enabled = edit_predictions_provider
             == language::language_settings::EditPredictionProvider::Copilot;
@@ -22611,7 +22574,7 @@ impl Editor {
                 event_type,
                 type = if auto_saved {"autosave"} else {"manual"},
                 file_extension,
-                vim_mode_enabled,
+                vim_mode,
                 copilot_enabled,
                 copilot_enabled_for_language,
                 edit_predictions_provider,
@@ -22621,7 +22584,7 @@ impl Editor {
             telemetry::event!(
                 event_type,
                 file_extension,
-                vim_mode_enabled,
+                vim_mode,
                 copilot_enabled,
                 copilot_enabled_for_language,
                 edit_predictions_provider,
@@ -22777,7 +22740,7 @@ impl Editor {
             .take()
             .and_then(|descendant| descendant.upgrade())
         {
-            window.focus(&descendant);
+            window.focus(&descendant, cx);
         } else {
             if let Some(blame) = self.blame.as_ref() {
                 blame.update(cx, GitBlame::focus)
@@ -23099,7 +23062,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();
 
@@ -23236,15 +23200,6 @@ impl Editor {
             show_underlines: self.diagnostics_enabled(),
         }
     }
-
-    /// Returns the value of the `vim_mode` setting, defaulting `false` if the
-    /// setting is not set.
-    pub(crate) fn is_vim_mode_enabled(&self, cx: &App) -> bool {
-        VimModeSetting::try_get(cx)
-            .map(|vim_mode| vim_mode.0)
-            .unwrap_or(false)
-    }
-
     fn breadcrumbs_inner(&self, variant: &Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
         let cursor = self.selections.newest_anchor().head();
         let multibuffer = self.buffer().read(cx);
@@ -23463,76 +23418,460 @@ struct CompletionEdit {
     snippet: Option<Snippet>,
 }
 
-fn insert_extra_newline_brackets(
+fn comment_delimiter_for_newline(
+    start_point: &Point,
     buffer: &MultiBufferSnapshot,
-    range: Range<MultiBufferOffset>,
-    language: &language::LanguageScope,
-) -> bool {
-    let leading_whitespace_len = buffer
-        .reversed_chars_at(range.start)
-        .take_while(|c| c.is_whitespace() && *c != '\n')
-        .map(|c| c.len_utf8())
-        .sum::<usize>();
-    let trailing_whitespace_len = buffer
-        .chars_at(range.end)
-        .take_while(|c| c.is_whitespace() && *c != '\n')
-        .map(|c| c.len_utf8())
-        .sum::<usize>();
-    let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len;
-
-    language.brackets().any(|(pair, enabled)| {
-        let pair_start = pair.start.trim_end();
-        let pair_end = pair.end.trim_start();
-
-        enabled
-            && pair.newline
-            && buffer.contains_str_at(range.end, pair_end)
-            && buffer.contains_str_at(
-                range.start.saturating_sub_usize(pair_start.len()),
-                pair_start,
-            )
-    })
+    language: &LanguageScope,
+) -> Option<Arc<str>> {
+    let delimiters = language.line_comment_prefixes();
+    let max_len_of_delimiter = delimiters.iter().map(|delimiter| delimiter.len()).max()?;
+    let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
+
+    let num_of_whitespaces = snapshot
+        .chars_for_range(range.clone())
+        .take_while(|c| c.is_whitespace())
+        .count();
+    let comment_candidate = snapshot
+        .chars_for_range(range.clone())
+        .skip(num_of_whitespaces)
+        .take(max_len_of_delimiter)
+        .collect::<String>();
+    let (delimiter, trimmed_len) = delimiters
+        .iter()
+        .filter_map(|delimiter| {
+            let prefix = delimiter.trim_end();
+            if comment_candidate.starts_with(prefix) {
+                Some((delimiter, prefix.len()))
+            } else {
+                None
+            }
+        })
+        .max_by_key(|(_, len)| *len)?;
+
+    if let Some(BlockCommentConfig {
+        start: block_start, ..
+    }) = language.block_comment()
+    {
+        let block_start_trimmed = block_start.trim_end();
+        if block_start_trimmed.starts_with(delimiter.trim_end()) {
+            let line_content = snapshot
+                .chars_for_range(range)
+                .skip(num_of_whitespaces)
+                .take(block_start_trimmed.len())
+                .collect::<String>();
+
+            if line_content.starts_with(block_start_trimmed) {
+                return None;
+            }
+        }
+    }
+
+    let cursor_is_placed_after_comment_marker =
+        num_of_whitespaces + trimmed_len <= start_point.column as usize;
+    if cursor_is_placed_after_comment_marker {
+        Some(delimiter.clone())
+    } else {
+        None
+    }
 }
 
-fn insert_extra_newline_tree_sitter(
+fn documentation_delimiter_for_newline(
+    start_point: &Point,
     buffer: &MultiBufferSnapshot,
-    range: Range<MultiBufferOffset>,
-) -> bool {
-    let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() {
-        [(buffer, range, _)] => (*buffer, range.clone()),
-        _ => return false,
+    language: &LanguageScope,
+    newline_config: &mut NewlineConfig,
+) -> Option<Arc<str>> {
+    let BlockCommentConfig {
+        start: start_tag,
+        end: end_tag,
+        prefix: delimiter,
+        tab_size: len,
+    } = language.documentation_comment()?;
+    let is_within_block_comment = buffer
+        .language_scope_at(*start_point)
+        .is_some_and(|scope| scope.override_name() == Some("comment"));
+    if !is_within_block_comment {
+        return None;
+    }
+
+    let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
+
+    let num_of_whitespaces = snapshot
+        .chars_for_range(range.clone())
+        .take_while(|c| c.is_whitespace())
+        .count();
+
+    // It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time.
+    let column = start_point.column;
+    let cursor_is_after_start_tag = {
+        let start_tag_len = start_tag.len();
+        let start_tag_line = snapshot
+            .chars_for_range(range.clone())
+            .skip(num_of_whitespaces)
+            .take(start_tag_len)
+            .collect::<String>();
+        if start_tag_line.starts_with(start_tag.as_ref()) {
+            num_of_whitespaces + start_tag_len <= column as usize
+        } else {
+            false
+        }
     };
-    let pair = {
-        let mut result: Option<BracketMatch<usize>> = None;
 
-        for pair in buffer
-            .all_bracket_ranges(range.start.0..range.end.0)
-            .filter(move |pair| {
-                pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0
-            })
+    let cursor_is_after_delimiter = {
+        let delimiter_trim = delimiter.trim_end();
+        let delimiter_line = snapshot
+            .chars_for_range(range.clone())
+            .skip(num_of_whitespaces)
+            .take(delimiter_trim.len())
+            .collect::<String>();
+        if delimiter_line.starts_with(delimiter_trim) {
+            num_of_whitespaces + delimiter_trim.len() <= column as usize
+        } else {
+            false
+        }
+    };
+
+    let mut needs_extra_line = false;
+    let mut extra_line_additional_indent = IndentSize::spaces(0);
+
+    let cursor_is_before_end_tag_if_exists = {
+        let mut char_position = 0u32;
+        let mut end_tag_offset = None;
+
+        'outer: for chunk in snapshot.text_for_range(range) {
+            if let Some(byte_pos) = chunk.find(&**end_tag) {
+                let chars_before_match = chunk[..byte_pos].chars().count() as u32;
+                end_tag_offset = Some(char_position + chars_before_match);
+                break 'outer;
+            }
+            char_position += chunk.chars().count() as u32;
+        }
+
+        if let Some(end_tag_offset) = end_tag_offset {
+            let cursor_is_before_end_tag = column <= end_tag_offset;
+            if cursor_is_after_start_tag {
+                if cursor_is_before_end_tag {
+                    needs_extra_line = true;
+                }
+                let cursor_is_at_start_of_end_tag = column == end_tag_offset;
+                if cursor_is_at_start_of_end_tag {
+                    extra_line_additional_indent.len = *len;
+                }
+            }
+            cursor_is_before_end_tag
+        } else {
+            true
+        }
+    };
+
+    if (cursor_is_after_start_tag || cursor_is_after_delimiter)
+        && cursor_is_before_end_tag_if_exists
+    {
+        let additional_indent = if cursor_is_after_start_tag {
+            IndentSize::spaces(*len)
+        } else {
+            IndentSize::spaces(0)
+        };
+
+        *newline_config = NewlineConfig::Newline {
+            additional_indent,
+            extra_line_additional_indent: if needs_extra_line {
+                Some(extra_line_additional_indent)
+            } else {
+                None
+            },
+            prevent_auto_indent: true,
+        };
+        Some(delimiter.clone())
+    } else {
+        None
+    }
+}
+
+const ORDERED_LIST_MAX_MARKER_LEN: usize = 16;
+
+fn list_delimiter_for_newline(
+    start_point: &Point,
+    buffer: &MultiBufferSnapshot,
+    language: &LanguageScope,
+    newline_config: &mut NewlineConfig,
+) -> Option<Arc<str>> {
+    let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
+
+    let num_of_whitespaces = snapshot
+        .chars_for_range(range.clone())
+        .take_while(|c| c.is_whitespace())
+        .count();
+
+    let task_list_entries: Vec<_> = language
+        .task_list()
+        .into_iter()
+        .flat_map(|config| {
+            config
+                .prefixes
+                .iter()
+                .map(|prefix| (prefix.as_ref(), config.continuation.as_ref()))
+        })
+        .collect();
+    let unordered_list_entries: Vec<_> = language
+        .unordered_list()
+        .iter()
+        .map(|marker| (marker.as_ref(), marker.as_ref()))
+        .collect();
+
+    let all_entries: Vec<_> = task_list_entries
+        .into_iter()
+        .chain(unordered_list_entries)
+        .collect();
+
+    if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() {
+        let candidate: String = snapshot
+            .chars_for_range(range.clone())
+            .skip(num_of_whitespaces)
+            .take(max_prefix_len)
+            .collect();
+
+        if let Some((prefix, continuation)) = all_entries
+            .iter()
+            .filter(|(prefix, _)| candidate.starts_with(*prefix))
+            .max_by_key(|(prefix, _)| prefix.len())
         {
-            let len = pair.close_range.end - pair.open_range.start;
+            let end_of_prefix = num_of_whitespaces + prefix.len();
+            let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
+            let has_content_after_marker = snapshot
+                .chars_for_range(range)
+                .skip(end_of_prefix)
+                .any(|c| !c.is_whitespace());
 
-            if let Some(existing) = &result {
-                let existing_len = existing.close_range.end - existing.open_range.start;
-                if len > existing_len {
-                    continue;
+            if has_content_after_marker && cursor_is_after_prefix {
+                return Some((*continuation).into());
+            }
+
+            if start_point.column as usize == end_of_prefix {
+                if num_of_whitespaces == 0 {
+                    *newline_config = NewlineConfig::ClearCurrentLine;
+                } else {
+                    *newline_config = NewlineConfig::UnindentCurrentLine {
+                        continuation: (*continuation).into(),
+                    };
                 }
             }
 
-            result = Some(pair);
+            return None;
         }
+    }
 
-        result
-    };
-    let Some(pair) = pair else {
+    let candidate: String = snapshot
+        .chars_for_range(range.clone())
+        .skip(num_of_whitespaces)
+        .take(ORDERED_LIST_MAX_MARKER_LEN)
+        .collect();
+
+    for ordered_config in language.ordered_list() {
+        let regex = match Regex::new(&ordered_config.pattern) {
+            Ok(r) => r,
+            Err(_) => continue,
+        };
+
+        if let Some(captures) = regex.captures(&candidate) {
+            let full_match = captures.get(0)?;
+            let marker_len = full_match.len();
+            let end_of_prefix = num_of_whitespaces + marker_len;
+            let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
+
+            let has_content_after_marker = snapshot
+                .chars_for_range(range)
+                .skip(end_of_prefix)
+                .any(|c| !c.is_whitespace());
+
+            if has_content_after_marker && cursor_is_after_prefix {
+                let number: u32 = captures.get(1)?.as_str().parse().ok()?;
+                let continuation = ordered_config
+                    .format
+                    .replace("{1}", &(number + 1).to_string());
+                return Some(continuation.into());
+            }
+
+            if start_point.column as usize == end_of_prefix {
+                let continuation = ordered_config.format.replace("{1}", "1");
+                if num_of_whitespaces == 0 {
+                    *newline_config = NewlineConfig::ClearCurrentLine;
+                } else {
+                    *newline_config = NewlineConfig::UnindentCurrentLine {
+                        continuation: continuation.into(),
+                    };
+                }
+            }
+
+            return None;
+        }
+    }
+
+    None
+}
+
+fn is_list_prefix_row(
+    row: MultiBufferRow,
+    buffer: &MultiBufferSnapshot,
+    language: &LanguageScope,
+) -> bool {
+    let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else {
         return false;
     };
-    pair.newline_only
-        && buffer
-            .chars_for_range(pair.open_range.end..range.start.0)
-            .chain(buffer.chars_for_range(range.end.0..pair.close_range.start))
-            .all(|c| c.is_whitespace() && c != '\n')
+
+    let num_of_whitespaces = snapshot
+        .chars_for_range(range.clone())
+        .take_while(|c| c.is_whitespace())
+        .count();
+
+    let task_list_prefixes: Vec<_> = language
+        .task_list()
+        .into_iter()
+        .flat_map(|config| {
+            config
+                .prefixes
+                .iter()
+                .map(|p| p.as_ref())
+                .collect::<Vec<_>>()
+        })
+        .collect();
+    let unordered_list_markers: Vec<_> = language
+        .unordered_list()
+        .iter()
+        .map(|marker| marker.as_ref())
+        .collect();
+    let all_prefixes: Vec<_> = task_list_prefixes
+        .into_iter()
+        .chain(unordered_list_markers)
+        .collect();
+    if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() {
+        let candidate: String = snapshot
+            .chars_for_range(range.clone())
+            .skip(num_of_whitespaces)
+            .take(max_prefix_len)
+            .collect();
+        if all_prefixes
+            .iter()
+            .any(|prefix| candidate.starts_with(*prefix))
+        {
+            return true;
+        }
+    }
+
+    let ordered_list_candidate: String = snapshot
+        .chars_for_range(range)
+        .skip(num_of_whitespaces)
+        .take(ORDERED_LIST_MAX_MARKER_LEN)
+        .collect();
+    for ordered_config in language.ordered_list() {
+        let regex = match Regex::new(&ordered_config.pattern) {
+            Ok(r) => r,
+            Err(_) => continue,
+        };
+        if let Some(captures) = regex.captures(&ordered_list_candidate) {
+            return captures.get(0).is_some();
+        }
+    }
+
+    false
+}
+
+#[derive(Debug)]
+enum NewlineConfig {
+    /// Insert newline with optional additional indent and optional extra blank line
+    Newline {
+        additional_indent: IndentSize,
+        extra_line_additional_indent: Option<IndentSize>,
+        prevent_auto_indent: bool,
+    },
+    /// Clear the current line
+    ClearCurrentLine,
+    /// Unindent the current line and add continuation
+    UnindentCurrentLine { continuation: Arc<str> },
+}
+
+impl NewlineConfig {
+    fn has_extra_line(&self) -> bool {
+        matches!(
+            self,
+            Self::Newline {
+                extra_line_additional_indent: Some(_),
+                ..
+            }
+        )
+    }
+
+    fn insert_extra_newline_brackets(
+        buffer: &MultiBufferSnapshot,
+        range: Range<MultiBufferOffset>,
+        language: &language::LanguageScope,
+    ) -> bool {
+        let leading_whitespace_len = buffer
+            .reversed_chars_at(range.start)
+            .take_while(|c| c.is_whitespace() && *c != '\n')
+            .map(|c| c.len_utf8())
+            .sum::<usize>();
+        let trailing_whitespace_len = buffer
+            .chars_at(range.end)
+            .take_while(|c| c.is_whitespace() && *c != '\n')
+            .map(|c| c.len_utf8())
+            .sum::<usize>();
+        let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len;
+
+        language.brackets().any(|(pair, enabled)| {
+            let pair_start = pair.start.trim_end();
+            let pair_end = pair.end.trim_start();
+
+            enabled
+                && pair.newline
+                && buffer.contains_str_at(range.end, pair_end)
+                && buffer.contains_str_at(
+                    range.start.saturating_sub_usize(pair_start.len()),
+                    pair_start,
+                )
+        })
+    }
+
+    fn insert_extra_newline_tree_sitter(
+        buffer: &MultiBufferSnapshot,
+        range: Range<MultiBufferOffset>,
+    ) -> bool {
+        let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() {
+            [(buffer, range, _)] => (*buffer, range.clone()),
+            _ => return false,
+        };
+        let pair = {
+            let mut result: Option<BracketMatch<usize>> = None;
+
+            for pair in buffer
+                .all_bracket_ranges(range.start.0..range.end.0)
+                .filter(move |pair| {
+                    pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0
+                })
+            {
+                let len = pair.close_range.end - pair.open_range.start;
+
+                if let Some(existing) = &result {
+                    let existing_len = existing.close_range.end - existing.open_range.start;
+                    if len > existing_len {
+                        continue;
+                    }
+                }
+
+                result = Some(pair);
+            }
+
+            result
+        };
+        let Some(pair) = pair else {
+            return false;
+        };
+        pair.newline_only
+            && buffer
+                .chars_for_range(pair.open_range.end..range.start.0)
+                .chain(buffer.chars_for_range(range.end.0..pair.close_range.start))
+                .all(|c| c.is_whitespace() && c != '\n')
+    }
 }
 
 fn update_uncommitted_diff_for_buffer(

crates/editor/src/editor_settings.rs 🔗

@@ -215,7 +215,8 @@ impl Settings for EditorSettings {
             },
             scrollbar: Scrollbar {
                 show: scrollbar.show.map(Into::into).unwrap(),
-                git_diff: scrollbar.git_diff.unwrap(),
+                git_diff: scrollbar.git_diff.unwrap()
+                    && content.git.unwrap().enabled.unwrap().is_git_diff_enabled(),
                 selected_text: scrollbar.selected_text.unwrap(),
                 selected_symbol: scrollbar.selected_symbol.unwrap(),
                 search_results: scrollbar.search_results.unwrap(),

crates/editor/src/editor_tests.rs 🔗

@@ -36,19 +36,22 @@ use languages::markdown_lang;
 use languages::rust_lang;
 use lsp::CompletionParams;
 use multi_buffer::{
-    IndentGuide, MultiBufferFilterMode, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey,
+    ExcerptRange, IndentGuide, MultiBuffer, MultiBufferFilterMode, MultiBufferOffset,
+    MultiBufferOffsetUtf16, PathKey,
 };
 use parking_lot::Mutex;
 use pretty_assertions::{assert_eq, assert_ne};
 use project::{
-    FakeFs,
+    FakeFs, Project,
     debugger::breakpoint_store::{BreakpointState, SourceBreakpoint},
     project_settings::LspSettings,
+    trusted_worktrees::{PathTrust, TrustedWorktrees},
 };
 use serde_json::{self, json};
 use settings::{
     AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring,
-    IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent,
+    IndentGuideColoring, InlayHintSettingsContent, ProjectSettingsContent, SearchSettingsContent,
+    SettingsStore,
 };
 use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
 use std::{
@@ -67,7 +70,6 @@ use util::{
 use workspace::{
     CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry,
     OpenOptions, ViewId,
-    invalid_item_view::InvalidItemView,
     item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
     register_project_item,
 };
@@ -10869,6 +10871,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, |_| {});
@@ -18090,7 +18201,7 @@ async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) {
     );
 
     editor_handle.update_in(cx, |editor, window, cx| {
-        window.focus(&editor.focus_handle(cx));
+        window.focus(&editor.focus_handle(cx), cx);
         editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([Point::new(0, 21)..Point::new(0, 20)])
         });
@@ -20770,6 +20881,36 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) {
         .to_string(),
     );
 
+    cx.update_editor(|editor, window, cx| {
+        editor.move_up(&MoveUp, window, cx);
+        editor.toggle_selected_diff_hunks(&Default::default(), window, cx);
+    });
+    cx.assert_state_with_diff(
+        indoc! { "
+        ˇone
+      - two
+        three
+        five
+    "}
+        .to_string(),
+    );
+
+    cx.update_editor(|editor, window, cx| {
+        editor.move_down(&MoveDown, window, cx);
+        editor.move_down(&MoveDown, window, cx);
+        editor.toggle_selected_diff_hunks(&Default::default(), window, cx);
+    });
+    cx.assert_state_with_diff(
+        indoc! { "
+        one
+      - two
+        ˇthree
+      - four
+        five
+    "}
+        .to_string(),
+    );
+
     cx.set_state(indoc! { "
         one
         ˇTWO
@@ -20809,6 +20950,66 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_toggling_adjacent_diff_hunks_2(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let diff_base = r#"
+        lineA
+        lineB
+        lineC
+        lineD
+        "#
+    .unindent();
+
+    cx.set_state(
+        &r#"
+        ˇlineA1
+        lineB
+        lineD
+        "#
+        .unindent(),
+    );
+    cx.set_head_text(&diff_base);
+    executor.run_until_parked();
+
+    cx.update_editor(|editor, window, cx| {
+        editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_state_with_diff(
+        r#"
+        - lineA
+        + ˇlineA1
+          lineB
+          lineD
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, window, cx| {
+        editor.move_down(&MoveDown, window, cx);
+        editor.move_right(&MoveRight, window, cx);
+        editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx);
+    });
+    executor.run_until_parked();
+    cx.assert_state_with_diff(
+        r#"
+        - lineA
+        + lineA1
+          lˇineB
+        - lineC
+          lineD
+        "#
+        .unindent(),
+    );
+}
+
 #[gpui::test]
 async fn test_edits_around_expanded_deletion_hunks(
     executor: BackgroundExecutor,
@@ -22124,6 +22325,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, |_| {});
@@ -25435,6 +25670,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
         ˇ        log('for else')
     "});
     cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             ˇfor item in items:
@@ -25454,6 +25690,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
     // test relative indent is preserved when tab
     // for `if`, `elif`, `else`, `while`, `with` and `for`
     cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
                 ˇfor item in items:
@@ -25487,6 +25724,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
         ˇ            return 0
     "});
     cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             ˇtry:
@@ -25503,6 +25741,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
     // test relative indent is preserved when tab
     // for `try`, `except`, `else`, `finally`, `match` and `def`
     cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
                 ˇtry:
@@ -25536,6 +25775,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("else:", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             if i == 2:
@@ -25553,6 +25793,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("except:", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             try:
@@ -25572,6 +25813,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("else:", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             try:
@@ -25595,6 +25837,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("finally:", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             try:
@@ -25619,6 +25862,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("else:", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             try:
@@ -25644,6 +25888,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("finally:", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             try:
@@ -25669,6 +25914,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("except:", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             try:
@@ -25692,6 +25938,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("except:", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             try:
@@ -25713,6 +25960,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("else:", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def main():
             for i in range(10):
@@ -25729,6 +25977,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("a", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         def f() -> list[str]:
             aˇ
@@ -25742,6 +25991,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input(":", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         match 1:
             case:ˇ
@@ -25765,6 +26015,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         # COMMENT:
         ˇ
@@ -25777,7 +26028,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
-    cx.run_until_parked();
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         {
             ˇ
@@ -25811,6 +26062,48 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
     "});
 }
 
+#[gpui::test]
+async fn test_python_indent_in_markdown(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor()));
+    let python_lang = languages::language("python", tree_sitter_python::LANGUAGE.into());
+    language_registry.add(markdown_lang());
+    language_registry.add(python_lang);
+
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| {
+        buffer.set_language_registry(language_registry);
+        buffer.set_language(Some(markdown_lang()), cx);
+    });
+
+    // Test that `else:` correctly outdents to match `if:` inside the Python code block
+    cx.set_state(indoc! {"
+        # Heading
+
+        ```python
+        def main():
+            if condition:
+                pass
+                ˇ
+        ```
+    "});
+    cx.update_editor(|editor, window, cx| {
+        editor.handle_input("else:", window, cx);
+    });
+    cx.run_until_parked();
+    cx.assert_editor_state(indoc! {"
+        # Heading
+
+        ```python
+        def main():
+            if condition:
+                pass
+            else:ˇ
+        ```
+    "});
+}
+
 #[gpui::test]
 async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -25837,6 +26130,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
         ˇ}
     "});
     cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         function main() {
             ˇfor item in $items; do
@@ -25854,6 +26148,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
     "});
     // test relative indent is preserved when tab
     cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         function main() {
                 ˇfor item in $items; do
@@ -25888,6 +26183,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
         ˇ}
     "});
     cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         function handle() {
             ˇcase \"$1\" in
@@ -25930,6 +26226,7 @@ async fn test_indent_after_input_for_bash(cx: &mut TestAppContext) {
         ˇ}
     "});
     cx.update_editor(|e, window, cx| e.handle_input("#", window, cx));
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         function main() {
         #ˇ    for item in $items; do
@@ -25964,6 +26261,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("else", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         if [ \"$1\" = \"test\" ]; then
             echo \"foo bar\"
@@ -25979,6 +26277,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("elif", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         if [ \"$1\" = \"test\" ]; then
             echo \"foo bar\"
@@ -25996,6 +26295,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("fi", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         if [ \"$1\" = \"test\" ]; then
             echo \"foo bar\"
@@ -26013,6 +26313,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("done", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         while read line; do
             echo \"$line\"
@@ -26028,6 +26329,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("done", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         for file in *.txt; do
             cat \"$file\"
@@ -26048,6 +26350,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("esac", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         case \"$1\" in
             start)
@@ -26070,6 +26373,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("*)", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         case \"$1\" in
             start)
@@ -26089,6 +26393,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.handle_input("fi", window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         if [ \"$1\" = \"test\" ]; then
             echo \"outer if\"
@@ -26115,6 +26420,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         # COMMENT:
         ˇ
@@ -26128,7 +26434,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
-    cx.run_until_parked();
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
 
         if [ \"$1\" = \"test\" ]; then
@@ -26143,7 +26449,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
-    cx.run_until_parked();
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         if [ \"$1\" = \"test\" ]; then
         else
@@ -26158,7 +26464,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
-    cx.run_until_parked();
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         if [ \"$1\" = \"test\" ]; then
         elif
@@ -26172,7 +26478,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
-    cx.run_until_parked();
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         for file in *.txt; do
             ˇ
@@ -26186,7 +26492,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
-    cx.run_until_parked();
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         case \"$1\" in
             start)
@@ -26203,7 +26509,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
-    cx.run_until_parked();
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         case \"$1\" in
             start)
@@ -26219,7 +26525,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
-    cx.run_until_parked();
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         function test() {
             ˇ
@@ -26233,7 +26539,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
     cx.update_editor(|editor, window, cx| {
         editor.newline(&Newline, window, cx);
     });
-    cx.run_until_parked();
+    cx.wait_for_autoindent_applied().await;
     cx.assert_editor_state(indoc! {"
         echo \"test\";
         ˇ
@@ -27451,11 +27757,10 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) {
         })
         .await
         .unwrap();
-
-    assert_eq!(
-        handle.to_any_view().entity_type(),
-        TypeId::of::<InvalidItemView>()
-    );
+    // The test file content `vec![0xff, 0xfe, ...]` starts with a UTF-16 LE BOM.
+    // Previously, this fell back to `InvalidItemView` because it wasn't valid UTF-8.
+    // With auto-detection enabled, this is now recognized as UTF-16 and opens in the Editor.
+    assert_eq!(handle.to_any_view().entity_type(), TypeId::of::<Editor>());
 }
 
 #[gpui::test]
@@ -27717,7 +28022,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
         "
     });
 
-    // Case 2: Test adding new line after nested list preserves indent of previous line
+    // Case 2: Test adding new line after nested list continues the list with unchecked task
     cx.set_state(&indoc! {"
         - [ ] Item 1
             - [ ] Item 1.a
@@ -27734,32 +28039,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
         - [x] Item 2
             - [x] Item 2.a
             - [x] Item 2.b
-            ˇ"
+            - [ ] ˇ"
     });
 
-    // Case 3: Test adding a new nested list item preserves indent
-    cx.set_state(&indoc! {"
-        - [ ] Item 1
-            - [ ] Item 1.a
-        - [x] Item 2
-            - [x] Item 2.a
-            - [x] Item 2.b
-            ˇ"
-    });
-    cx.update_editor(|editor, window, cx| {
-        editor.handle_input("-", window, cx);
-    });
-    cx.run_until_parked();
-    cx.assert_editor_state(indoc! {"
-        - [ ] Item 1
-            - [ ] Item 1.a
-        - [x] Item 2
-            - [x] Item 2.a
-            - [x] Item 2.b
-            -ˇ"
-    });
+    // Case 3: Test adding content to continued list item
     cx.update_editor(|editor, window, cx| {
-        editor.handle_input(" [x] Item 2.c", window, cx);
+        editor.handle_input("Item 2.c", window, cx);
     });
     cx.run_until_parked();
     cx.assert_editor_state(indoc! {"
@@ -27768,10 +28053,10 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
         - [x] Item 2
             - [x] Item 2.a
             - [x] Item 2.b
-            - [x] Item 2.cˇ"
+            - [ ] Item 2.cˇ"
     });
 
-    // Case 4: Test adding new line after nested ordered list preserves indent of previous line
+    // Case 4: Test adding new line after nested ordered list continues with next number
     cx.set_state(indoc! {"
         1. Item 1
             1. Item 1.a
@@ -27788,44 +28073,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
         2. Item 2
             1. Item 2.a
             2. Item 2.b
-            ˇ"
+            3. ˇ"
     });
 
-    // Case 5: Adding new ordered list item preserves indent
-    cx.set_state(indoc! {"
-        1. Item 1
-            1. Item 1.a
-        2. Item 2
-            1. Item 2.a
-            2. Item 2.b
-            ˇ"
-    });
-    cx.update_editor(|editor, window, cx| {
-        editor.handle_input("3", window, cx);
-    });
-    cx.run_until_parked();
-    cx.assert_editor_state(indoc! {"
-        1. Item 1
-            1. Item 1.a
-        2. Item 2
-            1. Item 2.a
-            2. Item 2.b
-            3ˇ"
-    });
-    cx.update_editor(|editor, window, cx| {
-        editor.handle_input(".", window, cx);
-    });
-    cx.run_until_parked();
-    cx.assert_editor_state(indoc! {"
-        1. Item 1
-            1. Item 1.a
-        2. Item 2
-            1. Item 2.a
-            2. Item 2.b
-            3.ˇ"
-    });
+    // Case 5: Adding content to continued ordered list item
     cx.update_editor(|editor, window, cx| {
-        editor.handle_input(" Item 2.c", window, cx);
+        editor.handle_input("Item 2.c", window, cx);
     });
     cx.run_until_parked();
     cx.assert_editor_state(indoc! {"
@@ -28382,24 +28635,148 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) {
+fn test_relative_line_numbers(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
-    cx.update(|cx| {
-        SettingsStore::update_global(cx, |store, cx| {
-            store.update_user_settings(cx, |settings| {
-                settings.editor.sticky_scroll = Some(settings::StickyScrollContent {
-                    enabled: Some(true),
-                })
-            });
-        });
-    });
-    let mut cx = EditorTestContext::new(cx).await;
 
-    let line_height = cx.update_editor(|editor, window, cx| {
-        editor
-            .style(cx)
-            .text
-            .line_height_in_pixels(window.rem_size())
+    let buffer_1 = cx.new(|cx| Buffer::local("aaaaaaaaaa\nbbb\n", cx));
+    let buffer_2 = cx.new(|cx| Buffer::local("cccccccccc\nddd\n", cx));
+    let buffer_3 = cx.new(|cx| Buffer::local("eee\nffffffffff\n", cx));
+
+    let multibuffer = cx.new(|cx| {
+        let mut multibuffer = MultiBuffer::new(ReadWrite);
+        multibuffer.push_excerpts(
+            buffer_1.clone(),
+            [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))],
+            cx,
+        );
+        multibuffer.push_excerpts(
+            buffer_2.clone(),
+            [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))],
+            cx,
+        );
+        multibuffer.push_excerpts(
+            buffer_3.clone(),
+            [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))],
+            cx,
+        );
+        multibuffer
+    });
+
+    // wrapped contents of multibuffer:
+    //    aaa
+    //    aaa
+    //    aaa
+    //    a
+    //    bbb
+    //
+    //    ccc
+    //    ccc
+    //    ccc
+    //    c
+    //    ddd
+    //
+    //    eee
+    //    fff
+    //    fff
+    //    fff
+    //    f
+
+    let (editor, cx) = cx.add_window_view(|window, cx| build_editor(multibuffer, window, cx));
+    editor.update_in(cx, |editor, window, cx| {
+        editor.set_wrap_width(Some(30.0.into()), cx); // every 3 characters
+
+        // includes trailing newlines.
+        let expected_line_numbers = [2, 6, 7, 10, 14, 15, 18, 19, 23];
+        let expected_wrapped_line_numbers = [
+            2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23,
+        ];
+
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([
+                Point::new(7, 0)..Point::new(7, 1), // second row of `ccc`
+            ]);
+        });
+
+        let snapshot = editor.snapshot(window, cx);
+
+        // these are all 0-indexed
+        let base_display_row = DisplayRow(11);
+        let base_row = 3;
+        let wrapped_base_row = 7;
+
+        // test not counting wrapped lines
+        let expected_relative_numbers = expected_line_numbers
+            .into_iter()
+            .enumerate()
+            .map(|(i, row)| (DisplayRow(row), i.abs_diff(base_row) as u32))
+            .collect_vec();
+        let actual_relative_numbers = snapshot
+            .calculate_relative_line_numbers(
+                &(DisplayRow(0)..DisplayRow(24)),
+                base_display_row,
+                false,
+            )
+            .into_iter()
+            .sorted()
+            .collect_vec();
+        assert_eq!(expected_relative_numbers, actual_relative_numbers);
+        // check `calculate_relative_line_numbers()` against `relative_line_delta()` for each line
+        for (display_row, relative_number) in expected_relative_numbers {
+            assert_eq!(
+                relative_number,
+                snapshot
+                    .relative_line_delta(display_row, base_display_row)
+                    .unsigned_abs() as u32,
+            );
+        }
+
+        // test counting wrapped lines
+        let expected_wrapped_relative_numbers = expected_wrapped_line_numbers
+            .into_iter()
+            .enumerate()
+            .map(|(i, row)| (DisplayRow(row), i.abs_diff(wrapped_base_row) as u32))
+            .collect_vec();
+        let actual_relative_numbers = snapshot
+            .calculate_relative_line_numbers(
+                &(DisplayRow(0)..DisplayRow(24)),
+                base_display_row,
+                true,
+            )
+            .into_iter()
+            .sorted()
+            .collect_vec();
+        assert_eq!(expected_wrapped_relative_numbers, actual_relative_numbers);
+        // check `calculate_relative_line_numbers()` against `relative_wrapped_line_delta()` for each line
+        for (display_row, relative_number) in expected_wrapped_relative_numbers {
+            assert_eq!(
+                relative_number,
+                snapshot
+                    .relative_wrapped_line_delta(display_row, base_display_row)
+                    .unsigned_abs() as u32,
+            );
+        }
+    });
+}
+
+#[gpui::test]
+async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.editor.sticky_scroll = Some(settings::StickyScrollContent {
+                    enabled: Some(true),
+                })
+            });
+        });
+    });
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let line_height = cx.update_editor(|editor, window, cx| {
+        editor
+            .style(cx)
+            .text
+            .line_height_in_pixels(window.rem_size())
     });
 
     let buffer = indoc! {"
@@ -29192,3 +29569,720 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
 
     cx.assert_editor_state(after);
 }
+
+#[gpui::test]
+async fn test_newline_task_list_continuation(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = Some(2.try_into().unwrap());
+    });
+
+    let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+    // Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker
+    cx.set_state(indoc! {"
+        - [ ] taskˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - [ ] task
+        - [ ] ˇ
+    "});
+
+    // Case 2: Works with checked task items too
+    cx.set_state(indoc! {"
+        - [x] completed taskˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - [x] completed task
+        - [ ] ˇ
+    "});
+
+    // Case 3: Cursor position doesn't matter - content after marker is what counts
+    cx.set_state(indoc! {"
+        - [ ] taˇsk
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - [ ] ta
+        - [ ] ˇsk
+    "});
+
+    // Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker
+    cx.set_state(indoc! {"
+        - [ ]  ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(
+        indoc! {"
+        - [ ]$$
+        ˇ
+    "}
+        .replace("$", " ")
+        .as_str(),
+    );
+
+    // Case 5: Adding newline with content adds marker preserving indentation
+    cx.set_state(indoc! {"
+        - [ ] task
+          - [ ] indentedˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - [ ] task
+          - [ ] indented
+          - [ ] ˇ
+    "});
+
+    // Case 6: Adding newline with cursor right after prefix, unindents
+    cx.set_state(indoc! {"
+        - [ ] task
+          - [ ] sub task
+            - [ ] ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - [ ] task
+          - [ ] sub task
+          - [ ] ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+
+    // Case 7: Adding newline with cursor right after prefix, removes marker
+    cx.assert_editor_state(indoc! {"
+        - [ ] task
+          - [ ] sub task
+        - [ ] ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - [ ] task
+          - [ ] sub task
+        ˇ
+    "});
+
+    // Case 8: Cursor before or inside prefix does not add marker
+    cx.set_state(indoc! {"
+        ˇ- [ ] task
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+
+        ˇ- [ ] task
+    "});
+
+    cx.set_state(indoc! {"
+        - [ˇ ] task
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - [
+        ˇ
+        ] task
+    "});
+}
+
+#[gpui::test]
+async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = Some(2.try_into().unwrap());
+    });
+
+    let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+    // Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker
+    cx.set_state(indoc! {"
+        - itemˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - item
+        - ˇ
+    "});
+
+    // Case 2: Works with different markers
+    cx.set_state(indoc! {"
+        * starred itemˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        * starred item
+        * ˇ
+    "});
+
+    cx.set_state(indoc! {"
+        + plus itemˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        + plus item
+        + ˇ
+    "});
+
+    // Case 3: Cursor position doesn't matter - content after marker is what counts
+    cx.set_state(indoc! {"
+        - itˇem
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - it
+        - ˇem
+    "});
+
+    // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
+    cx.set_state(indoc! {"
+        -  ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(
+        indoc! {"
+        - $
+        ˇ
+    "}
+        .replace("$", " ")
+        .as_str(),
+    );
+
+    // Case 5: Adding newline with content adds marker preserving indentation
+    cx.set_state(indoc! {"
+        - item
+          - indentedˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - item
+          - indented
+          - ˇ
+    "});
+
+    // Case 6: Adding newline with cursor right after marker, unindents
+    cx.set_state(indoc! {"
+        - item
+          - sub item
+            - ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - item
+          - sub item
+          - ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+
+    // Case 7: Adding newline with cursor right after marker, removes marker
+    cx.assert_editor_state(indoc! {"
+        - item
+          - sub item
+        - ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        - item
+          - sub item
+        ˇ
+    "});
+
+    // Case 8: Cursor before or inside prefix does not add marker
+    cx.set_state(indoc! {"
+        ˇ- item
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+
+        ˇ- item
+    "});
+
+    cx.set_state(indoc! {"
+        -ˇ item
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        -
+        ˇitem
+    "});
+}
+
+#[gpui::test]
+async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = Some(2.try_into().unwrap());
+    });
+
+    let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+    // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
+    cx.set_state(indoc! {"
+        1. first itemˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        1. first item
+        2. ˇ
+    "});
+
+    // Case 2: Works with larger numbers
+    cx.set_state(indoc! {"
+        10. tenth itemˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        10. tenth item
+        11. ˇ
+    "});
+
+    // Case 3: Cursor position doesn't matter - content after marker is what counts
+    cx.set_state(indoc! {"
+        1. itˇem
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        1. it
+        2. ˇem
+    "});
+
+    // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
+    cx.set_state(indoc! {"
+        1.  ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(
+        indoc! {"
+        1. $
+        ˇ
+    "}
+        .replace("$", " ")
+        .as_str(),
+    );
+
+    // Case 5: Adding newline with content adds marker preserving indentation
+    cx.set_state(indoc! {"
+        1. item
+          2. indentedˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        1. item
+          2. indented
+          3. ˇ
+    "});
+
+    // Case 6: Adding newline with cursor right after marker, unindents
+    cx.set_state(indoc! {"
+        1. item
+          2. sub item
+            3. ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        1. item
+          2. sub item
+          1. ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+
+    // Case 7: Adding newline with cursor right after marker, removes marker
+    cx.assert_editor_state(indoc! {"
+        1. item
+          2. sub item
+        1. ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        1. item
+          2. sub item
+        ˇ
+    "});
+
+    // Case 8: Cursor before or inside prefix does not add marker
+    cx.set_state(indoc! {"
+        ˇ1. item
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+
+        ˇ1. item
+    "});
+
+    cx.set_state(indoc! {"
+        1ˇ. item
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        1
+        ˇ. item
+    "});
+}
+
+#[gpui::test]
+async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = Some(2.try_into().unwrap());
+    });
+
+    let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+    // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
+    cx.set_state(indoc! {"
+        1. first item
+          1. sub first item
+          2. sub second item
+          3. ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    cx.assert_editor_state(indoc! {"
+        1. first item
+          1. sub first item
+          2. sub second item
+        1. ˇ
+    "});
+}
+
+#[gpui::test]
+async fn test_tab_list_indent(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = Some(2.try_into().unwrap());
+    });
+
+    let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+    // Case 1: Unordered list - cursor after prefix, adds indent before prefix
+    cx.set_state(indoc! {"
+        - ˇitem
+    "});
+    cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    let expected = indoc! {"
+        $$- ˇitem
+    "};
+    cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+    // Case 2: Task list - cursor after prefix
+    cx.set_state(indoc! {"
+        - [ ] ˇtask
+    "});
+    cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    let expected = indoc! {"
+        $$- [ ] ˇtask
+    "};
+    cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+    // Case 3: Ordered list - cursor after prefix
+    cx.set_state(indoc! {"
+        1. ˇfirst
+    "});
+    cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    let expected = indoc! {"
+        $$1. ˇfirst
+    "};
+    cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+    // Case 4: With existing indentation - adds more indent
+    let initial = indoc! {"
+        $$- ˇitem
+    "};
+    cx.set_state(initial.replace("$", " ").as_str());
+    cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    let expected = indoc! {"
+        $$$$- ˇitem
+    "};
+    cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+    // Case 5: Empty list item
+    cx.set_state(indoc! {"
+        - ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    let expected = indoc! {"
+        $$- ˇ
+    "};
+    cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+    // Case 6: Cursor at end of line with content
+    cx.set_state(indoc! {"
+        - itemˇ
+    "});
+    cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    let expected = indoc! {"
+        $$- itemˇ
+    "};
+    cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+    // Case 7: Cursor at start of list item, indents it
+    cx.set_state(indoc! {"
+        - item
+        ˇ  - sub item
+    "});
+    cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    let expected = indoc! {"
+        - item
+          ˇ  - sub item
+    "};
+    cx.assert_editor_state(expected);
+
+    // Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false
+    cx.update_editor(|_, _, cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.project.all_languages.defaults.indent_list_on_tab = Some(false);
+            });
+        });
+    });
+    cx.set_state(indoc! {"
+        - item
+        ˇ  - sub item
+    "});
+    cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+    cx.wait_for_autoindent_applied().await;
+    let expected = indoc! {"
+        - item
+          ˇ- sub item
+    "};
+    cx.assert_editor_state(expected);
+}
+
+#[gpui::test]
+async fn test_local_worktree_trust(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    cx.update(|cx| project::trusted_worktrees::init(HashMap::default(), None, None, cx));
+
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.project.all_languages.defaults.inlay_hints =
+                    Some(InlayHintSettingsContent {
+                        enabled: Some(true),
+                        ..InlayHintSettingsContent::default()
+                    });
+            });
+        });
+    });
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            ".zed": {
+                "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
+            },
+            "main.rs": "fn main() {}"
+        }),
+    )
+    .await;
+
+    let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0));
+    let server_name = "override-rust-analyzer";
+    let project = Project::test_with_worktree_trust(fs, [path!("/project").as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    let capabilities = lsp::ServerCapabilities {
+        inlay_hint_provider: Some(lsp::OneOf::Left(true)),
+        ..lsp::ServerCapabilities::default()
+    };
+    let mut fake_language_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            name: server_name,
+            capabilities,
+            initializer: Some(Box::new({
+                let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+                move |fake_server| {
+                    let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+                    fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>(
+                        move |_params, _| {
+                            lsp_inlay_hint_request_count.fetch_add(1, atomic::Ordering::Release);
+                            async move {
+                                Ok(Some(vec![lsp::InlayHint {
+                                    position: lsp::Position::new(0, 0),
+                                    label: lsp::InlayHintLabel::String("hint".to_string()),
+                                    kind: None,
+                                    text_edits: None,
+                                    tooltip: None,
+                                    padding_left: None,
+                                    padding_right: None,
+                                    data: None,
+                                }]))
+                            }
+                        },
+                    );
+                }
+            })),
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    cx.run_until_parked();
+
+    let worktree_id = project.read_with(cx, |project, cx| {
+        project
+            .worktrees(cx)
+            .next()
+            .map(|wt| wt.read(cx).id())
+            .expect("should have a worktree")
+    });
+
+    let trusted_worktrees =
+        cx.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
+
+    let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+    assert!(!can_trust, "worktree should be restricted initially");
+
+    let buffer_before_approval = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, rel_path("main.rs")), cx)
+        })
+        .await
+        .unwrap();
+
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        Editor::new(
+            EditorMode::full(),
+            cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)),
+            Some(project.clone()),
+            window,
+            cx,
+        )
+    });
+    cx.run_until_parked();
+    let fake_language_server = fake_language_servers.next();
+
+    cx.read(|cx| {
+        let file = buffer_before_approval.read(cx).file();
+        assert_eq!(
+            language::language_settings::language_settings(Some("Rust".into()), file, cx)
+                .language_servers,
+            ["...".to_string()],
+            "local .zed/settings.json must not apply before trust approval"
+        )
+    });
+
+    editor.update_in(cx, |editor, window, cx| {
+        editor.handle_input("1", window, cx);
+    });
+    cx.run_until_parked();
+    cx.executor()
+        .advance_clock(std::time::Duration::from_secs(1));
+    assert_eq!(
+        lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire),
+        0,
+        "inlay hints must not be queried before trust approval"
+    );
+
+    trusted_worktrees.update(cx, |store, cx| {
+        store.trust(
+            std::collections::HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+            None,
+            cx,
+        );
+    });
+    cx.run_until_parked();
+
+    cx.read(|cx| {
+        let file = buffer_before_approval.read(cx).file();
+        assert_eq!(
+            language::language_settings::language_settings(Some("Rust".into()), file, cx)
+                .language_servers,
+            ["override-rust-analyzer".to_string()],
+            "local .zed/settings.json should apply after trust approval"
+        )
+    });
+    let _fake_language_server = fake_language_server.await.unwrap();
+    editor.update_in(cx, |editor, window, cx| {
+        editor.handle_input("1", window, cx);
+    });
+    cx.run_until_parked();
+    cx.executor()
+        .advance_clock(std::time::Duration::from_secs(1));
+    assert!(
+        lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire) > 0,
+        "inlay hints should be queried after trust approval"
+    );
+
+    let can_trust_after =
+        trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+    assert!(can_trust_after, "worktree should be trusted after trust()");
+}
+
+#[gpui::test]
+fn test_editor_rendering_when_positioned_above_viewport(cx: &mut TestAppContext) {
+    // This test reproduces a bug where drawing an editor at a position above the viewport
+    // (simulating what happens when an AutoHeight editor inside a List is scrolled past)
+    // causes an infinite loop in blocks_in_range.
+    //
+    // The issue: when the editor's bounds.origin.y is very negative (above the viewport),
+    // the content mask intersection produces visible_bounds with origin at the viewport top.
+    // This makes clipped_top_in_lines very large, causing start_row to exceed max_row.
+    // When blocks_in_range is called with start_row > max_row, the cursor seeks to the end
+    // but the while loop after seek never terminates because cursor.next() is a no-op at end.
+    init_test(cx, |_| {});
+
+    let window = cx.add_window(|_, _| gpui::Empty);
+    let mut cx = VisualTestContext::from_window(*window, cx);
+
+    let buffer = cx.update(|_, cx| MultiBuffer::build_simple("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n", cx));
+    let editor = cx.new_window_entity(|window, cx| build_editor(buffer, window, cx));
+
+    // Simulate a small viewport (500x500 pixels at origin 0,0)
+    cx.simulate_resize(gpui::size(px(500.), px(500.)));
+
+    // Draw the editor at a very negative Y position, simulating an editor that's been
+    // scrolled way above the visible viewport (like in a List that has scrolled past it).
+    // The editor is 3000px tall but positioned at y=-10000, so it's entirely above the viewport.
+    // This should NOT hang - it should just render nothing.
+    cx.draw(
+        gpui::point(px(0.), px(-10000.)),
+        gpui::size(px(500.), px(3000.)),
+        |_, _| editor.clone(),
+    );
+
+    // If we get here without hanging, the test passes
+}

crates/editor/src/element.rs 🔗

@@ -37,11 +37,7 @@ use crate::{
 use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
 use collections::{BTreeMap, HashMap};
 use file_icons::FileIcons;
-use git::{
-    Oid,
-    blame::{BlameEntry, ParsedCommitMessage},
-    status::FileStatus,
-};
+use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
 use gpui::{
     Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
     Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
@@ -50,7 +46,7 @@ use gpui::{
     KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent,
     MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement,
     Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
-    Size, StatefulInteractiveElement, Style, Styled, StyledText, TextRun, TextStyleRefinement,
+    Size, StatefulInteractiveElement, Style, Styled, TextAlign, TextRun, TextStyleRefinement,
     WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline,
     point, px, quad, relative, size, solid_background, transparent_black,
 };
@@ -70,7 +66,7 @@ use project::{
 };
 use settings::{
     GitGutterSetting, GitHunkStyleSetting, IndentGuideBackgroundColoring, IndentGuideColoring,
-    Settings,
+    RelativeLineNumbers, Settings,
 };
 use smallvec::{SmallVec, smallvec};
 use std::{
@@ -134,7 +130,7 @@ impl SelectionLayout {
     fn new<T: ToPoint + ToDisplayPoint + Clone>(
         selection: Selection<T>,
         line_mode: bool,
-        vim_mode_enabled: bool,
+        cursor_offset: bool,
         cursor_shape: CursorShape,
         map: &DisplaySnapshot,
         is_newest: bool,
@@ -155,7 +151,7 @@ impl SelectionLayout {
         }
 
         // any vim visual mode (including line mode)
-        if vim_mode_enabled && !range.is_empty() && !selection.reversed {
+        if cursor_offset && !range.is_empty() && !selection.reversed {
             if head.column() > 0 {
                 head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left);
             } else if head.row().0 > 0 && head != map.max_point() {
@@ -199,8 +195,6 @@ pub struct EditorElement {
     style: EditorStyle,
 }
 
-type DisplayRowDelta = u32;
-
 impl EditorElement {
     pub(crate) const SCROLLBAR_WIDTH: Pixels = px(15.);
 
@@ -595,8 +589,6 @@ impl EditorElement {
         register_action(editor, window, Editor::show_signature_help);
         register_action(editor, window, Editor::signature_help_prev);
         register_action(editor, window, Editor::signature_help_next);
-        register_action(editor, window, Editor::next_edit_prediction);
-        register_action(editor, window, Editor::previous_edit_prediction);
         register_action(editor, window, Editor::show_edit_prediction);
         register_action(editor, window, Editor::context_menu_first);
         register_action(editor, window, Editor::context_menu_prev);
@@ -1464,7 +1456,7 @@ impl EditorElement {
                     let layout = SelectionLayout::new(
                         selection,
                         editor.selections.line_mode(),
-                        editor.is_vim_mode_enabled(cx),
+                        editor.cursor_offset_on_selection,
                         editor.cursor_shape,
                         &snapshot.display_snapshot,
                         is_newest,
@@ -1511,7 +1503,7 @@ impl EditorElement {
                     let drag_cursor_layout = SelectionLayout::new(
                         drop_cursor.clone(),
                         false,
-                        editor.is_vim_mode_enabled(cx),
+                        editor.cursor_offset_on_selection,
                         CursorShape::Bar,
                         &snapshot.display_snapshot,
                         false,
@@ -1575,7 +1567,7 @@ impl EditorElement {
                         .push(SelectionLayout::new(
                             selection.selection,
                             selection.line_mode,
-                            editor.is_vim_mode_enabled(cx),
+                            editor.cursor_offset_on_selection,
                             selection.cursor_shape,
                             &snapshot.display_snapshot,
                             false,
@@ -1586,7 +1578,8 @@ impl EditorElement {
 
                 selections.extend(remote_selections.into_values());
             } else if !editor.is_focused(window) && editor.show_cursor_when_unfocused {
-                let player = editor.current_user_player_color(cx);
+                let cursor_offset_on_selection = editor.cursor_offset_on_selection;
+
                 let layouts = snapshot
                     .buffer_snapshot()
                     .selections_in_range(&(start_anchor..end_anchor), true)
@@ -1594,7 +1587,7 @@ impl EditorElement {
                         SelectionLayout::new(
                             selection,
                             line_mode,
-                            editor.is_vim_mode_enabled(cx),
+                            cursor_offset_on_selection,
                             cursor_shape,
                             &snapshot.display_snapshot,
                             false,
@@ -1603,7 +1596,7 @@ impl EditorElement {
                         )
                     })
                     .collect::<Vec<_>>();
-
+                let player = editor.current_user_player_color(cx);
                 selections.push((player, layouts));
             }
         });
@@ -1703,9 +1696,13 @@ impl EditorElement {
                         [cursor_position.row().minus(visible_display_row_range.start) as usize];
                     let cursor_column = cursor_position.column() as usize;
 
-                    let cursor_character_x = cursor_row_layout.x_for_index(cursor_column);
-                    let mut block_width =
-                        cursor_row_layout.x_for_index(cursor_column + 1) - cursor_character_x;
+                    let cursor_character_x = cursor_row_layout.x_for_index(cursor_column)
+                        + cursor_row_layout
+                            .alignment_offset(self.style.text.text_align, text_hitbox.size.width);
+                    let cursor_next_x = cursor_row_layout.x_for_index(cursor_column + 1)
+                        + cursor_row_layout
+                            .alignment_offset(self.style.text.text_align, text_hitbox.size.width);
+                    let mut block_width = cursor_next_x - cursor_character_x;
                     if block_width == Pixels::ZERO {
                         block_width = em_advance;
                     }
@@ -3231,64 +3228,6 @@ impl EditorElement {
             .collect()
     }
 
-    fn calculate_relative_line_numbers(
-        &self,
-        snapshot: &EditorSnapshot,
-        rows: &Range<DisplayRow>,
-        relative_to: Option<DisplayRow>,
-        count_wrapped_lines: bool,
-    ) -> HashMap<DisplayRow, DisplayRowDelta> {
-        let mut relative_rows: HashMap<DisplayRow, DisplayRowDelta> = Default::default();
-        let Some(relative_to) = relative_to else {
-            return relative_rows;
-        };
-
-        let start = rows.start.min(relative_to);
-        let end = rows.end.max(relative_to);
-
-        let buffer_rows = snapshot
-            .row_infos(start)
-            .take(1 + end.minus(start) as usize)
-            .collect::<Vec<_>>();
-
-        let head_idx = relative_to.minus(start);
-        let mut delta = 1;
-        let mut i = head_idx + 1;
-        let should_count_line = |row_info: &RowInfo| {
-            if count_wrapped_lines {
-                row_info.buffer_row.is_some() || row_info.wrapped_buffer_row.is_some()
-            } else {
-                row_info.buffer_row.is_some()
-            }
-        };
-        while i < buffer_rows.len() as u32 {
-            if should_count_line(&buffer_rows[i as usize]) {
-                if rows.contains(&DisplayRow(i + start.0)) {
-                    relative_rows.insert(DisplayRow(i + start.0), delta);
-                }
-                delta += 1;
-            }
-            i += 1;
-        }
-        delta = 1;
-        i = head_idx.min(buffer_rows.len().saturating_sub(1) as u32);
-        while i > 0 && buffer_rows[i as usize].buffer_row.is_none() && !count_wrapped_lines {
-            i -= 1;
-        }
-
-        while i > 0 {
-            i -= 1;
-            if should_count_line(&buffer_rows[i as usize]) {
-                if rows.contains(&DisplayRow(i + start.0)) {
-                    relative_rows.insert(DisplayRow(i + start.0), delta);
-                }
-                delta += 1;
-            }
-        }
-
-        relative_rows
-    }
-
     fn layout_line_numbers(
         &self,
         gutter_hitbox: Option<&Hitbox>,
@@ -3298,7 +3237,7 @@ impl EditorElement {
         rows: Range<DisplayRow>,
         buffer_rows: &[RowInfo],
         active_rows: &BTreeMap<DisplayRow, LineHighlightSpec>,
-        newest_selection_head: Option<DisplayPoint>,
+        relative_line_base: Option<DisplayRow>,
         snapshot: &EditorSnapshot,
         window: &mut Window,
         cx: &mut App,
@@ -3310,32 +3249,16 @@ impl EditorElement {
             return Arc::default();
         }
 
-        let (newest_selection_head, relative) = self.editor.update(cx, |editor, cx| {
-            let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
-                let newest = editor
-                    .selections
-                    .newest::<Point>(&editor.display_snapshot(cx));
-                SelectionLayout::new(
-                    newest,
-                    editor.selections.line_mode(),
-                    editor.is_vim_mode_enabled(cx),
-                    editor.cursor_shape,
-                    &snapshot.display_snapshot,
-                    true,
-                    true,
-                    None,
-                )
-                .head
-            });
-            let relative = editor.relative_line_numbers(cx);
-            (newest_selection_head, relative)
-        });
+        let relative = self.editor.read(cx).relative_line_numbers(cx);
 
         let relative_line_numbers_enabled = relative.enabled();
-        let relative_to = relative_line_numbers_enabled.then(|| newest_selection_head.row());
+        let relative_rows = if relative_line_numbers_enabled && let Some(base) = relative_line_base
+        {
+            snapshot.calculate_relative_line_numbers(&rows, base, relative.wrapped())
+        } else {
+            Default::default()
+        };
 
-        let relative_rows =
-            self.calculate_relative_line_numbers(snapshot, &rows, relative_to, relative.wrapped());
         let mut line_number = String::new();
         let segments = buffer_rows.iter().enumerate().flat_map(|(ix, row_info)| {
             let display_row = DisplayRow(rows.start.0 + ix as u32);
@@ -4779,6 +4702,8 @@ impl EditorElement {
         gutter_hitbox: &Hitbox,
         text_hitbox: &Hitbox,
         style: &EditorStyle,
+        relative_line_numbers: RelativeLineNumbers,
+        relative_to: Option<DisplayRow>,
         window: &mut Window,
         cx: &mut App,
     ) -> Option<StickyHeaders> {
@@ -4808,9 +4733,21 @@ impl EditorElement {
             );
 
             let line_number = show_line_numbers.then(|| {
-                let number = (start_point.row + 1).to_string();
+                let relative_number = relative_to.and_then(|base| match relative_line_numbers {
+                    RelativeLineNumbers::Disabled => None,
+                    RelativeLineNumbers::Enabled => {
+                        Some(snapshot.relative_line_delta_to_point(base, start_point))
+                    }
+                    RelativeLineNumbers::Wrapped => {
+                        Some(snapshot.relative_wrapped_line_delta_to_point(base, start_point))
+                    }
+                });
+                let number = relative_number
+                    .filter(|&delta| delta != 0)
+                    .map(|delta| delta.unsigned_abs() as u32)
+                    .unwrap_or(start_point.row + 1);
                 let color = cx.theme().colors().editor_line_number;
-                self.shape_line_number(SharedString::from(number), color, window)
+                self.shape_line_number(SharedString::from(number.to_string()), color, window)
             });
 
             lines.push(StickyHeaderLine::new(
@@ -5544,6 +5481,12 @@ impl EditorElement {
                 .max(MIN_POPOVER_LINE_HEIGHT * line_height), // Apply minimum height of 4 lines
         );
 
+        // Don't show hover popovers when context menu is open to avoid overlap
+        let has_context_menu = self.editor.read(cx).mouse_context_menu.is_some();
+        if has_context_menu {
+            return;
+        }
+
         let hover_popovers = self.editor.update(cx, |editor, cx| {
             editor.hover_state.render(
                 snapshot,
@@ -6343,10 +6286,25 @@ impl EditorElement {
                     let color = cx.theme().colors().editor_hover_line_number;
 
                     let line = self.shape_line_number(shaped_line.text.clone(), color, window);
-                    line.paint(hitbox.origin, line_height, window, cx).log_err()
+                    line.paint(
+                        hitbox.origin,
+                        line_height,
+                        TextAlign::Left,
+                        None,
+                        window,
+                        cx,
+                    )
+                    .log_err()
                 } else {
                     shaped_line
-                        .paint(hitbox.origin, line_height, window, cx)
+                        .paint(
+                            hitbox.origin,
+                            line_height,
+                            TextAlign::Left,
+                            None,
+                            window,
+                            cx,
+                        )
                         .log_err()
                 }) else {
                     continue;
@@ -7435,23 +7393,27 @@ impl EditorElement {
                     .map(|row| {
                         let line_layout =
                             &layout.position_map.line_layouts[row.minus(start_row) as usize];
+                        let alignment_offset =
+                            line_layout.alignment_offset(layout.text_align, layout.content_width);
                         HighlightedRangeLine {
                             start_x: if row == range.start.row() {
                                 layout.content_origin.x
                                     + Pixels::from(
                                         ScrollPixelOffset::from(
-                                            line_layout.x_for_index(range.start.column() as usize),
+                                            line_layout.x_for_index(range.start.column() as usize)
+                                                + alignment_offset,
                                         ) - layout.position_map.scroll_pixel_position.x,
                                     )
                             } else {
-                                layout.content_origin.x
+                                layout.content_origin.x + alignment_offset
                                     - Pixels::from(layout.position_map.scroll_pixel_position.x)
                             },
                             end_x: if row == range.end.row() {
                                 layout.content_origin.x
                                     + Pixels::from(
                                         ScrollPixelOffset::from(
-                                            line_layout.x_for_index(range.end.column() as usize),
+                                            line_layout.x_for_index(range.end.column() as usize)
+                                                + alignment_offset,
                                         ) - layout.position_map.scroll_pixel_position.x,
                                     )
                             } else {
@@ -7459,6 +7421,7 @@ impl EditorElement {
                                     ScrollPixelOffset::from(
                                         layout.content_origin.x
                                             + line_layout.width
+                                            + alignment_offset
                                             + line_end_overshoot,
                                     ) - layout.position_map.scroll_pixel_position.x,
                                 )
@@ -8699,8 +8662,15 @@ impl LineWithInvisibles {
         for fragment in &self.fragments {
             match fragment {
                 LineFragment::Text(line) => {
-                    line.paint(fragment_origin, line_height, window, cx)
-                        .log_err();
+                    line.paint(
+                        fragment_origin,
+                        line_height,
+                        layout.text_align,
+                        Some(layout.content_width),
+                        window,
+                        cx,
+                    )
+                    .log_err();
                     fragment_origin.x += line.width;
                 }
                 LineFragment::Element { size, .. } => {
@@ -8742,8 +8712,15 @@ impl LineWithInvisibles {
         for fragment in &self.fragments {
             match fragment {
                 LineFragment::Text(line) => {
-                    line.paint_background(fragment_origin, line_height, window, cx)
-                        .log_err();
+                    line.paint_background(
+                        fragment_origin,
+                        line_height,
+                        layout.text_align,
+                        Some(layout.content_width),
+                        window,
+                        cx,
+                    )
+                    .log_err();
                     fragment_origin.x += line.width;
                 }
                 LineFragment::Element { size, .. } => {
@@ -8792,7 +8769,7 @@ impl LineWithInvisibles {
                 [token_offset, token_end_offset],
                 Box::new(move |window: &mut Window, cx: &mut App| {
                     invisible_symbol
-                        .paint(origin, line_height, window, cx)
+                        .paint(origin, line_height, TextAlign::Left, None, window, cx)
                         .log_err();
                 }),
             )
@@ -8953,6 +8930,15 @@ impl LineWithInvisibles {
 
         None
     }
+
+    pub fn alignment_offset(&self, text_align: TextAlign, content_width: Pixels) -> Pixels {
+        let line_width = self.width;
+        match text_align {
+            TextAlign::Left => px(0.0),
+            TextAlign::Center => (content_width - line_width) / 2.0,
+            TextAlign::Right => content_width - line_width,
+        }
+    }
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -9285,6 +9271,15 @@ impl Element for EditorElement {
                     let height_in_lines = f64::from(bounds.size.height / line_height);
                     let max_row = snapshot.max_point().row().as_f64();
 
+                    // Calculate how much of the editor is clipped by parent containers (e.g., List).
+                    // This allows us to only render lines that are actually visible, which is
+                    // critical for performance when large AutoHeight editors are inside Lists.
+                    let visible_bounds = window.content_mask().bounds;
+                    let clipped_top = (visible_bounds.origin.y - bounds.origin.y).max(px(0.));
+                    let clipped_top_in_lines = f64::from(clipped_top / line_height);
+                    let visible_height_in_lines =
+                        f64::from(visible_bounds.size.height / line_height);
+
                     // The max scroll position for the top of the window
                     let max_scroll_top = if matches!(
                         snapshot.mode,
@@ -9341,10 +9336,16 @@ impl Element for EditorElement {
                     let mut scroll_position = snapshot.scroll_position();
                     // The scroll position is a fractional point, the whole number of which represents
                     // the top of the window in terms of display rows.
-                    let start_row = DisplayRow(scroll_position.y as u32);
+                    // We add clipped_top_in_lines to skip rows that are clipped by parent containers,
+                    // but we don't modify scroll_position itself since the parent handles positioning.
                     let max_row = snapshot.max_point().row();
+                    let start_row = cmp::min(
+                        DisplayRow((scroll_position.y + clipped_top_in_lines).floor() as u32),
+                        max_row,
+                    );
                     let end_row = cmp::min(
-                        (scroll_position.y + height_in_lines).ceil() as u32,
+                        (scroll_position.y + clipped_top_in_lines + visible_height_in_lines).ceil()
+                            as u32,
                         max_row.next_row().0,
                     );
                     let end_row = DisplayRow(end_row);
@@ -9542,6 +9543,28 @@ impl Element for EditorElement {
                             window,
                             cx,
                         );
+
+                    // relative rows are based on newest selection, even outside the visible area
+                    let relative_row_base =  self.editor.update(cx, |editor, cx| {
+                        if editor.selections.count()==0 {
+                            return None;
+                        }
+                            let newest = editor
+                                .selections
+                                .newest::<Point>(&editor.display_snapshot(cx));
+                            Some(SelectionLayout::new(
+                                newest,
+                                editor.selections.line_mode(),
+                                editor.cursor_offset_on_selection,
+                                editor.cursor_shape,
+                                &snapshot.display_snapshot,
+                                true,
+                                true,
+                                None,
+                            )
+                            .head.row())
+                        });
+
                     let mut breakpoint_rows = self.editor.update(cx, |editor, cx| {
                         editor.active_breakpoints(start_row..end_row, window, cx)
                     });
@@ -9559,7 +9582,7 @@ impl Element for EditorElement {
                         start_row..end_row,
                         &row_infos,
                         &active_rows,
-                        newest_selection_head,
+                        relative_row_base,
                         &snapshot,
                         window,
                         cx,
@@ -9879,6 +9902,7 @@ impl Element for EditorElement {
                         && is_singleton
                         && EditorSettings::get_global(cx).sticky_scroll.enabled
                     {
+                        let relative = self.editor.read(cx).relative_line_numbers(cx);
                         self.layout_sticky_headers(
                             &snapshot,
                             editor_width,
@@ -9890,6 +9914,8 @@ impl Element for EditorElement {
                             &gutter_hitbox,
                             &text_hitbox,
                             &style,
+                            relative,
+                            relative_row_base,
                             window,
                             cx,
                         )
@@ -10315,6 +10341,8 @@ impl Element for EditorElement {
                         em_width,
                         em_advance,
                         snapshot,
+                        text_align: self.style.text.text_align,
+                        content_width: text_hitbox.size.width,
                         gutter_hitbox: gutter_hitbox.clone(),
                         text_hitbox: text_hitbox.clone(),
                         inline_blame_bounds: inline_blame_layout
@@ -10368,6 +10396,8 @@ impl Element for EditorElement {
                         sticky_buffer_header,
                         sticky_headers,
                         expand_toggles,
+                        text_align: self.style.text.text_align,
+                        content_width: text_hitbox.size.width,
                     }
                 })
             })
@@ -10548,6 +10578,8 @@ pub struct EditorLayout {
     sticky_buffer_header: Option<AnyElement>,
     sticky_headers: Option<StickyHeaders>,
     document_colors: Option<(DocumentColorsRenderMode, Vec<(Range<DisplayPoint>, Hsla)>)>,
+    text_align: TextAlign,
+    content_width: Pixels,
 }
 
 struct StickyHeaders {
@@ -10715,7 +10747,9 @@ impl StickyHeaderLine {
                 gutter_origin.x + gutter_width - gutter_right_padding - line_number.width,
                 gutter_origin.y,
             );
-            line_number.paint(origin, line_height, window, cx).log_err();
+            line_number
+                .paint(origin, line_height, TextAlign::Left, None, window, cx)
+                .log_err();
         }
     }
 }
@@ -11154,6 +11188,8 @@ pub(crate) struct PositionMap {
     pub visible_row_range: Range<DisplayRow>,
     pub line_layouts: Vec<LineWithInvisibles>,
     pub snapshot: EditorSnapshot,
+    pub text_align: TextAlign,
+    pub content_width: Pixels,
     pub text_hitbox: Hitbox,
     pub gutter_hitbox: Hitbox,
     pub inline_blame_bounds: Option<(Bounds<Pixels>, BufferId, BlameEntry)>,
@@ -11219,10 +11255,12 @@ impl PositionMap {
             .line_layouts
             .get(row as usize - scroll_position.y as usize)
         {
-            if let Some(ix) = line.index_for_x(x) {
+            let alignment_offset = line.alignment_offset(self.text_align, self.content_width);
+            let x_relative_to_text = x - alignment_offset;
+            if let Some(ix) = line.index_for_x(x_relative_to_text) {
                 (ix as u32, px(0.))
             } else {
-                (line.len as u32, px(0.).max(x - line.width))
+                (line.len as u32, px(0.).max(x_relative_to_text - line.width))
             }
         } else {
             (0, x)
@@ -11411,7 +11449,14 @@ impl CursorLayout {
 
         if let Some(block_text) = &self.block_text {
             block_text
-                .paint(self.origin + origin, self.line_height, window, cx)
+                .paint(
+                    self.origin + origin,
+                    self.line_height,
+                    TextAlign::Left,
+                    None,
+                    window,
+                    cx,
+                )
                 .log_err();
         }
     }
@@ -11670,7 +11715,6 @@ mod tests {
     use log::info;
     use std::num::NonZeroU32;
     use util::test::sample_text;
-    use vim_mode_setting::VimModeSetting;
 
     #[gpui::test]
     async fn test_soft_wrap_editor_width_auto_height_editor(cx: &mut TestAppContext) {
@@ -11738,7 +11782,7 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_shape_line_numbers(cx: &mut TestAppContext) {
+    fn test_layout_line_numbers(cx: &mut TestAppContext) {
         init_test(cx, |_| {});
         let window = cx.add_window(|window, cx| {
             let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
@@ -11778,7 +11822,7 @@ mod tests {
                         })
                         .collect::<Vec<_>>(),
                     &BTreeMap::default(),
-                    Some(DisplayPoint::new(DisplayRow(0), 0)),
+                    Some(DisplayRow(0)),
                     &snapshot,
                     window,
                     cx,
@@ -11790,10 +11834,9 @@ mod tests {
         let relative_rows = window
             .update(cx, |editor, window, cx| {
                 let snapshot = editor.snapshot(window, cx);
-                element.calculate_relative_line_numbers(
-                    &snapshot,
+                snapshot.calculate_relative_line_numbers(
                     &(DisplayRow(0)..DisplayRow(6)),
-                    Some(DisplayRow(3)),
+                    DisplayRow(3),
                     false,
                 )
             })
@@ -11809,10 +11852,9 @@ mod tests {
         let relative_rows = window
             .update(cx, |editor, window, cx| {
                 let snapshot = editor.snapshot(window, cx);
-                element.calculate_relative_line_numbers(
-                    &snapshot,
+                snapshot.calculate_relative_line_numbers(
                     &(DisplayRow(3)..DisplayRow(6)),
-                    Some(DisplayRow(1)),
+                    DisplayRow(1),
                     false,
                 )
             })
@@ -11826,10 +11868,9 @@ mod tests {
         let relative_rows = window
             .update(cx, |editor, window, cx| {
                 let snapshot = editor.snapshot(window, cx);
-                element.calculate_relative_line_numbers(
-                    &snapshot,
+                snapshot.calculate_relative_line_numbers(
                     &(DisplayRow(0)..DisplayRow(3)),
-                    Some(DisplayRow(6)),
+                    DisplayRow(6),
                     false,
                 )
             })
@@ -11866,7 +11907,7 @@ mod tests {
                         })
                         .collect::<Vec<_>>(),
                     &BTreeMap::default(),
-                    Some(DisplayPoint::new(DisplayRow(0), 0)),
+                    Some(DisplayRow(0)),
                     &snapshot,
                     window,
                     cx,
@@ -11881,7 +11922,7 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_shape_line_numbers_wrapping(cx: &mut TestAppContext) {
+    fn test_layout_line_numbers_wrapping(cx: &mut TestAppContext) {
         init_test(cx, |_| {});
         let window = cx.add_window(|window, cx| {
             let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
@@ -11926,7 +11967,7 @@ mod tests {
                         })
                         .collect::<Vec<_>>(),
                     &BTreeMap::default(),
-                    Some(DisplayPoint::new(DisplayRow(0), 0)),
+                    Some(DisplayRow(0)),
                     &snapshot,
                     window,
                     cx,
@@ -11938,10 +11979,9 @@ mod tests {
         let relative_rows = window
             .update(cx, |editor, window, cx| {
                 let snapshot = editor.snapshot(window, cx);
-                element.calculate_relative_line_numbers(
-                    &snapshot,
+                snapshot.calculate_relative_line_numbers(
                     &(DisplayRow(0)..DisplayRow(6)),
-                    Some(DisplayRow(3)),
+                    DisplayRow(3),
                     true,
                 )
             })
@@ -11978,7 +12018,7 @@ mod tests {
                         })
                         .collect::<Vec<_>>(),
                     &BTreeMap::from_iter([(DisplayRow(0), LineHighlightSpec::default())]),
-                    Some(DisplayPoint::new(DisplayRow(0), 0)),
+                    Some(DisplayRow(0)),
                     &snapshot,
                     window,
                     cx,
@@ -11993,10 +12033,9 @@ mod tests {
         let relative_rows = window
             .update(cx, |editor, window, cx| {
                 let snapshot = editor.snapshot(window, cx);
-                element.calculate_relative_line_numbers(
-                    &snapshot,
+                snapshot.calculate_relative_line_numbers(
                     &(DisplayRow(0)..DisplayRow(6)),
-                    Some(DisplayRow(3)),
+                    DisplayRow(3),
                     true,
                 )
             })
@@ -12015,12 +12054,6 @@ mod tests {
     async fn test_vim_visual_selections(cx: &mut TestAppContext) {
         init_test(cx, |_| {});
 
-        // Enable `vim_mode` setting so the logic that checks whether this is
-        // enabled can work as expected.
-        cx.update(|cx| {
-            VimModeSetting::override_global(VimModeSetting(true), cx);
-        });
-
         let window = cx.add_window(|window, cx| {
             let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx);
             Editor::new(EditorMode::full(), buffer, None, window, cx)
@@ -12031,6 +12064,7 @@ mod tests {
 
         window
             .update(cx, |editor, window, cx| {
+                editor.cursor_offset_on_selection = true;
                 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                     s.select_ranges([
                         Point::new(0, 0)..Point::new(1, 0),

crates/editor/src/git/blame.rs 🔗

@@ -1,11 +1,11 @@
 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},
-    parse_git_remote_url,
+    GitHostingProviderRegistry, Oid,
+    blame::{Blame, BlameEntry},
+    commit::ParsedCommitMessage,
 };
 use gpui::{
     AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
@@ -494,84 +494,103 @@ 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).default_remote_url());
+                            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 = messages
+                                            .into_iter()
+                                            .map(|(oid, message)| {
+                                                let parsed_commit_message =
+                                                    ParsedCommitMessage::parse(
+                                                        oid.to_string(),
+                                                        message,
+                                                        remote_url.as_deref(),
+                                                        Some(provider_registry.clone()),
+                                                    );
+                                                (oid, parsed_commit_message)
+                                            })
+                                            .collect();
+                                        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 +605,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 +620,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:?}");
                         }
                     })
                 }
@@ -662,55 +681,6 @@ fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree
     entries
 }
 
-async fn parse_commit_messages(
-    messages: impl IntoIterator<Item = (Oid, String)>,
-    remote_url: Option<String>,
-    provider_registry: Arc<GitHostingProviderRegistry>,
-) -> HashMap<Oid, ParsedCommitMessage> {
-    let mut commit_details = HashMap::default();
-
-    let parsed_remote_url = remote_url
-        .as_deref()
-        .and_then(|remote_url| parse_git_remote_url(provider_registry, remote_url));
-
-    for (oid, message) in messages {
-        let permalink = if let Some((provider, git_remote)) = parsed_remote_url.as_ref() {
-            Some(provider.build_commit_permalink(
-                git_remote,
-                git::BuildCommitPermalinkParams {
-                    sha: oid.to_string().as_str(),
-                },
-            ))
-        } else {
-            None
-        };
-
-        let remote = parsed_remote_url
-            .as_ref()
-            .map(|(provider, remote)| GitRemote {
-                host: provider.clone(),
-                owner: remote.owner.clone().into(),
-                repo: remote.repo.clone().into(),
-            });
-
-        let pull_request = parsed_remote_url
-            .as_ref()
-            .and_then(|(provider, remote)| provider.extract_pull_request(remote, &message));
-
-        commit_details.insert(
-            oid,
-            ParsedCommitMessage {
-                message: message.into(),
-                permalink,
-                remote,
-                pull_request,
-            },
-        );
-    }
-
-    commit_details
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;

crates/editor/src/hover_links.rs 🔗

@@ -218,7 +218,7 @@ impl Editor {
             self.hide_hovered_link(cx);
             if !hovered_link_state.links.is_empty() {
                 if !self.focus_handle.is_focused(window) {
-                    window.focus(&self.focus_handle);
+                    window.focus(&self.focus_handle, cx);
                 }
 
                 // exclude links pointing back to the current anchor

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/items.rs 🔗

@@ -17,8 +17,8 @@ use gpui::{
     ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point,
 };
 use language::{
-    Bias, Buffer, BufferRow, CharKind, CharScopeContext, DiskState, LocalFile, Point,
-    SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
+    Bias, Buffer, BufferRow, CharKind, CharScopeContext, LocalFile, Point, SelectionGoal,
+    proto::serialize_anchor as serialize_text_anchor,
 };
 use lsp::DiagnosticSeverity;
 use multi_buffer::MultiBufferOffset;
@@ -722,7 +722,7 @@ impl Item for Editor {
             .read(cx)
             .as_singleton()
             .and_then(|buffer| buffer.read(cx).file())
-            .is_some_and(|file| file.disk_state() == DiskState::Deleted);
+            .is_some_and(|file| file.disk_state().is_deleted());
 
         h_flex()
             .gap_2()

crates/editor/src/jsx_tag_auto_close.rs 🔗

@@ -19,7 +19,7 @@ pub struct JsxTagCompletionState {
 /// that corresponds to the tag name
 /// Note that this is not configurable, i.e. we assume the first
 /// named child of a tag node is the tag name
-const TS_NODE_TAG_NAME_CHILD_INDEX: usize = 0;
+const TS_NODE_TAG_NAME_CHILD_INDEX: u32 = 0;
 
 /// Maximum number of parent elements to walk back when checking if an open tag
 /// is already closed.

crates/editor/src/mouse_context_menu.rs 🔗

@@ -90,8 +90,8 @@ impl MouseContextMenu {
         // `true` when the `ContextMenu` is focused.
         let focus_handle = context_menu_focus.clone();
         cx.on_next_frame(window, move |_, window, cx| {
-            cx.on_next_frame(window, move |_, window, _cx| {
-                window.focus(&focus_handle);
+            cx.on_next_frame(window, move |_, window, cx| {
+                window.focus(&focus_handle, cx);
             });
         });
 
@@ -100,7 +100,7 @@ impl MouseContextMenu {
             move |editor, _, _event: &DismissEvent, window, cx| {
                 editor.mouse_context_menu.take();
                 if context_menu_focus.contains_focused(window, cx) {
-                    window.focus(&editor.focus_handle(cx));
+                    window.focus(&editor.focus_handle(cx), cx);
                 }
             }
         });
@@ -127,7 +127,7 @@ impl MouseContextMenu {
                 }
                 editor.mouse_context_menu.take();
                 if context_menu_focus.contains_focused(window, cx) {
-                    window.focus(&editor.focus_handle(cx));
+                    window.focus(&editor.focus_handle(cx), cx);
                 }
             },
         );
@@ -161,7 +161,7 @@ pub fn deploy_context_menu(
     cx: &mut Context<Editor>,
 ) {
     if !editor.is_focused(window) {
-        window.focus(&editor.focus_handle(cx));
+        window.focus(&editor.focus_handle(cx), cx);
     }
 
     // Don't show context menu for inline editors

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/scroll/autoscroll.rs 🔗

@@ -5,7 +5,7 @@ use crate::{
 };
 use gpui::{Bounds, Context, Pixels, Window};
 use language::Point;
-use multi_buffer::Anchor;
+use multi_buffer::{Anchor, ToPoint};
 use std::cmp;
 
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
@@ -186,6 +186,19 @@ impl Editor {
             }
         }
 
+        let style = self.style(cx).clone();
+        let sticky_headers = self.sticky_headers(&style, cx).unwrap_or_default();
+        let visible_sticky_headers = sticky_headers
+            .iter()
+            .filter(|h| {
+                let buffer_snapshot = display_map.buffer_snapshot();
+                let buffer_range =
+                    h.range.start.to_point(buffer_snapshot)..h.range.end.to_point(buffer_snapshot);
+
+                buffer_range.contains(&Point::new(target_top as u32, 0))
+            })
+            .count();
+
         let margin = if matches!(self.mode, EditorMode::AutoHeight { .. }) {
             0.
         } else {
@@ -218,7 +231,7 @@ impl Editor {
         let was_autoscrolled = match strategy {
             AutoscrollStrategy::Fit | AutoscrollStrategy::Newest => {
                 let margin = margin.min(self.scroll_manager.vertical_scroll_margin);
-                let target_top = (target_top - margin).max(0.0);
+                let target_top = (target_top - margin - visible_sticky_headers as f64).max(0.0);
                 let target_bottom = target_bottom + margin;
                 let start_row = scroll_position.y;
                 let end_row = start_row + visible_lines;

crates/editor/src/test.rs 🔗

@@ -176,11 +176,9 @@ pub fn block_content_for_tests(
 }
 
 pub fn editor_content_with_blocks(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> String {
-    cx.draw(
-        gpui::Point::default(),
-        size(px(3000.0), px(3000.0)),
-        |_, _| editor.clone(),
-    );
+    let draw_size = size(px(3000.0), px(3000.0));
+    cx.simulate_resize(draw_size);
+    cx.draw(gpui::Point::default(), draw_size, |_, _| editor.clone());
     let (snapshot, mut lines, blocks) = editor.update_in(cx, |editor, window, cx| {
         let snapshot = editor.snapshot(window, cx);
         let text = editor.display_text(cx);

crates/editor/src/test/editor_lsp_test_context.rs 🔗

@@ -126,7 +126,7 @@ impl EditorLspTestContext {
                 .read(cx)
                 .nav_history_for_item(&cx.entity());
             editor.set_nav_history(Some(nav_history));
-            window.focus(&editor.focus_handle(cx))
+            window.focus(&editor.focus_handle(cx), cx)
         });
 
         let lsp = fake_servers.next().await.unwrap();
@@ -205,6 +205,49 @@ impl EditorLspTestContext {
                 (_ "{" "}" @end) @indent
                 (_ "(" ")" @end) @indent
                 "#})),
+            text_objects: Some(Cow::from(indoc! {r#"
+                (function_declaration
+                    body: (_
+                        "{"
+                        (_)* @function.inside
+                        "}")) @function.around
+
+                (method_definition
+                    body: (_
+                        "{"
+                        (_)* @function.inside
+                        "}")) @function.around
+
+                ; Arrow function in variable declaration - capture the full declaration
+                ([
+                    (lexical_declaration
+                        (variable_declarator
+                            value: (arrow_function
+                                body: (statement_block
+                                    "{"
+                                    (_)* @function.inside
+                                    "}"))))
+                    (variable_declaration
+                        (variable_declarator
+                            value: (arrow_function
+                                body: (statement_block
+                                    "{"
+                                    (_)* @function.inside
+                                    "}"))))
+                ]) @function.around
+
+                ([
+                    (lexical_declaration
+                        (variable_declarator
+                            value: (arrow_function)))
+                    (variable_declaration
+                        (variable_declarator
+                            value: (arrow_function)))
+                ]) @function.around
+
+                ; Catch-all for arrow functions in other contexts (callbacks, etc.)
+                ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator))
+                "#})),
             ..Default::default()
         })
         .expect("Could not parse queries");
@@ -276,6 +319,49 @@ impl EditorLspTestContext {
                   (jsx_opening_element) @start
                   (jsx_closing_element)? @end) @indent
                 "#})),
+            text_objects: Some(Cow::from(indoc! {r#"
+                (function_declaration
+                    body: (_
+                        "{"
+                        (_)* @function.inside
+                        "}")) @function.around
+
+                (method_definition
+                    body: (_
+                        "{"
+                        (_)* @function.inside
+                        "}")) @function.around
+
+                ; Arrow function in variable declaration - capture the full declaration
+                ([
+                    (lexical_declaration
+                        (variable_declarator
+                            value: (arrow_function
+                                body: (statement_block
+                                    "{"
+                                    (_)* @function.inside
+                                    "}"))))
+                    (variable_declaration
+                        (variable_declarator
+                            value: (arrow_function
+                                body: (statement_block
+                                    "{"
+                                    (_)* @function.inside
+                                    "}"))))
+                ]) @function.around
+
+                ([
+                    (lexical_declaration
+                        (variable_declarator
+                            value: (arrow_function)))
+                    (variable_declaration
+                        (variable_declarator
+                            value: (arrow_function)))
+                ]) @function.around
+
+                ; Catch-all for arrow functions in other contexts (callbacks, etc.)
+                ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator))
+                "#})),
             ..Default::default()
         })
         .expect("Could not parse queries");

crates/editor/src/test/editor_test_context.rs 🔗

@@ -78,7 +78,7 @@ impl EditorTestContext {
                 cx,
             );
 
-            window.focus(&editor.focus_handle(cx));
+            window.focus(&editor.focus_handle(cx), cx);
             editor
         });
         let editor_view = editor.root(cx).unwrap();
@@ -139,7 +139,7 @@ impl EditorTestContext {
 
         let editor = cx.add_window(|window, cx| {
             let editor = build_editor(buffer, window, cx);
-            window.focus(&editor.focus_handle(cx));
+            window.focus(&editor.focus_handle(cx), cx);
 
             editor
         });
@@ -305,6 +305,12 @@ impl EditorTestContext {
         snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
     }
 
+    pub async fn wait_for_autoindent_applied(&mut self) {
+        if let Some(fut) = self.update_buffer(|buffer, _| buffer.wait_for_autoindent_applied()) {
+            fut.await.ok();
+        }
+    }
+
     pub fn set_head_text(&mut self, diff_base: &str) {
         self.cx.run_until_parked();
         let fs =

crates/extension/src/extension_host_proxy.rs 🔗

@@ -19,6 +19,9 @@ impl Global for GlobalExtensionHostProxy {}
 ///
 /// This object implements each of the individual proxy types so that their
 /// methods can be called directly on it.
+/// Registration function for language model providers.
+pub type LanguageModelProviderRegistration = Box<dyn FnOnce(&mut App) + Send>;
+
 #[derive(Default)]
 pub struct ExtensionHostProxy {
     theme_proxy: RwLock<Option<Arc<dyn ExtensionThemeProxy>>>,
@@ -29,6 +32,7 @@ pub struct ExtensionHostProxy {
     slash_command_proxy: RwLock<Option<Arc<dyn ExtensionSlashCommandProxy>>>,
     context_server_proxy: RwLock<Option<Arc<dyn ExtensionContextServerProxy>>>,
     debug_adapter_provider_proxy: RwLock<Option<Arc<dyn ExtensionDebugAdapterProviderProxy>>>,
+    language_model_provider_proxy: RwLock<Option<Arc<dyn ExtensionLanguageModelProviderProxy>>>,
 }
 
 impl ExtensionHostProxy {
@@ -54,6 +58,7 @@ impl ExtensionHostProxy {
             slash_command_proxy: RwLock::default(),
             context_server_proxy: RwLock::default(),
             debug_adapter_provider_proxy: RwLock::default(),
+            language_model_provider_proxy: RwLock::default(),
         }
     }
 
@@ -90,6 +95,15 @@ impl ExtensionHostProxy {
             .write()
             .replace(Arc::new(proxy));
     }
+
+    pub fn register_language_model_provider_proxy(
+        &self,
+        proxy: impl ExtensionLanguageModelProviderProxy,
+    ) {
+        self.language_model_provider_proxy
+            .write()
+            .replace(Arc::new(proxy));
+    }
 }
 
 pub trait ExtensionThemeProxy: Send + Sync + 'static {
@@ -446,3 +460,37 @@ impl ExtensionDebugAdapterProviderProxy for ExtensionHostProxy {
         proxy.unregister_debug_locator(locator_name)
     }
 }
+
+pub trait ExtensionLanguageModelProviderProxy: Send + Sync + 'static {
+    fn register_language_model_provider(
+        &self,
+        provider_id: Arc<str>,
+        register_fn: LanguageModelProviderRegistration,
+        cx: &mut App,
+    );
+
+    fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App);
+}
+
+impl ExtensionLanguageModelProviderProxy for ExtensionHostProxy {
+    fn register_language_model_provider(
+        &self,
+        provider_id: Arc<str>,
+        register_fn: LanguageModelProviderRegistration,
+        cx: &mut App,
+    ) {
+        let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
+            return;
+        };
+
+        proxy.register_language_model_provider(provider_id, register_fn, cx)
+    }
+
+    fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App) {
+        let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
+            return;
+        };
+
+        proxy.unregister_language_model_provider(provider_id, cx)
+    }
+}

crates/extension/src/extension_manifest.rs 🔗

@@ -93,6 +93,8 @@ pub struct ExtensionManifest {
     pub debug_adapters: BTreeMap<Arc<str>, DebugAdapterManifestEntry>,
     #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
     pub debug_locators: BTreeMap<Arc<str>, DebugLocatorManifestEntry>,
+    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    pub language_model_providers: BTreeMap<Arc<str>, LanguageModelProviderManifestEntry>,
 }
 
 impl ExtensionManifest {
@@ -288,6 +290,16 @@ pub struct DebugAdapterManifestEntry {
 #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
 pub struct DebugLocatorManifestEntry {}
 
+/// Manifest entry for a language model provider.
+#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
+pub struct LanguageModelProviderManifestEntry {
+    /// Display name for the provider.
+    pub name: String,
+    /// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg").
+    #[serde(default)]
+    pub icon: Option<String>,
+}
+
 impl ExtensionManifest {
     pub async fn load(fs: Arc<dyn Fs>, extension_dir: &Path) -> Result<Self> {
         let extension_name = extension_dir
@@ -358,6 +370,7 @@ fn manifest_from_old_manifest(
         capabilities: Vec::new(),
         debug_adapters: Default::default(),
         debug_locators: Default::default(),
+        language_model_providers: Default::default(),
     }
 }
 
@@ -391,6 +404,7 @@ mod tests {
             capabilities: vec![],
             debug_adapters: Default::default(),
             debug_locators: Default::default(),
+            language_model_providers: BTreeMap::default(),
         }
     }
 

crates/extension_api/src/extension_api.rs 🔗

@@ -331,7 +331,6 @@ static mut EXTENSION: Option<Box<dyn Extension>> = None;
 pub static ZED_API_VERSION: [u8; 6] = *include_bytes!(concat!(env!("OUT_DIR"), "/version_bytes"));
 
 mod wit {
-
     wit_bindgen::generate!({
         skip: ["init-extension"],
         path: "./wit/since_v0.8.0",
@@ -524,6 +523,12 @@ impl wit::Guest for Component {
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
 pub struct LanguageServerId(String);
 
+impl LanguageServerId {
+    pub fn new(value: String) -> Self {
+        Self(value)
+    }
+}
+
 impl AsRef<str> for LanguageServerId {
     fn as_ref(&self) -> &str {
         &self.0
@@ -540,6 +545,12 @@ impl fmt::Display for LanguageServerId {
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
 pub struct ContextServerId(String);
 
+impl ContextServerId {
+    pub fn new(value: String) -> Self {
+        Self(value)
+    }
+}
+
 impl AsRef<str> for ContextServerId {
     fn as_ref(&self) -> &str {
         &self.0

crates/extension_host/src/extension_store_test.rs 🔗

@@ -165,6 +165,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         capabilities: Vec::new(),
                         debug_adapters: Default::default(),
                         debug_locators: Default::default(),
+                        language_model_providers: BTreeMap::default(),
                     }),
                     dev: false,
                 },
@@ -196,6 +197,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         capabilities: Vec::new(),
                         debug_adapters: Default::default(),
                         debug_locators: Default::default(),
+                        language_model_providers: BTreeMap::default(),
                     }),
                     dev: false,
                 },
@@ -376,6 +378,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                 capabilities: Vec::new(),
                 debug_adapters: Default::default(),
                 debug_locators: Default::default(),
+                language_model_providers: BTreeMap::default(),
             }),
             dev: false,
         },

crates/extension_host/src/wasm_host.rs 🔗

@@ -45,7 +45,7 @@ use wasmtime::{
     CacheStore, Engine, Store,
     component::{Component, ResourceTable},
 };
-use wasmtime_wasi::{self as wasi, WasiView};
+use wasmtime_wasi::p2::{self as wasi, IoView as _};
 use wit::Extension;
 
 pub struct WasmHost {
@@ -685,8 +685,8 @@ impl WasmHost {
             .await
             .context("failed to create extension work dir")?;
 
-        let file_perms = wasi::FilePerms::all();
-        let dir_perms = wasi::DirPerms::all();
+        let file_perms = wasmtime_wasi::FilePerms::all();
+        let dir_perms = wasmtime_wasi::DirPerms::all();
         let path = SanitizedPath::new(&extension_work_dir).to_string();
         #[cfg(target_os = "windows")]
         let path = path.replace('\\', "/");
@@ -856,11 +856,13 @@ impl WasmState {
     }
 }
 
-impl wasi::WasiView for WasmState {
+impl wasi::IoView for WasmState {
     fn table(&mut self) -> &mut ResourceTable {
         &mut self.table
     }
+}
 
+impl wasi::WasiView for WasmState {
     fn ctx(&mut self) -> &mut wasi::WasiCtx {
         &mut self.ctx
     }

crates/extension_host/src/wasm_host/wit.rs 🔗

@@ -45,7 +45,7 @@ pub fn new_linker(
     f: impl Fn(&mut Linker<WasmState>, fn(&mut WasmState) -> &mut WasmState) -> Result<()>,
 ) -> Linker<WasmState> {
     let mut linker = Linker::new(&wasm_engine(executor));
-    wasmtime_wasi::add_to_linker_async(&mut linker).unwrap();
+    wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap();
     f(&mut linker, wasi_view).unwrap();
     linker
 }

crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs 🔗

@@ -1,7 +1,7 @@
 use crate::wasm_host::wit::since_v0_6_0::{
     dap::{
-        AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest,
-        StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate,
+        BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, StartDebuggingRequestArguments,
+        TcpArguments, TcpArgumentsTemplate,
     },
     slash_command::SlashCommandOutputSection,
 };
@@ -736,6 +736,7 @@ impl nodejs::Host for WasmState {
             .node_runtime
             .npm_package_latest_version(&package_name)
             .await
+            .map(|v| v.to_string())
             .to_wasmtime_result()
     }
 
@@ -747,6 +748,7 @@ impl nodejs::Host for WasmState {
             .node_runtime
             .npm_package_installed_version(&self.work_dir(), &package_name)
             .await
+            .map(|option| option.map(|version| version.to_string()))
             .to_wasmtime_result()
     }
 

crates/file_finder/src/file_finder.rs 🔗

@@ -1713,7 +1713,7 @@ impl PickerDelegate for FileFinderDelegate {
                                                 ui::IconPosition::End,
                                                 Some(ToggleIncludeIgnored.boxed_clone()),
                                                 move |window, cx| {
-                                                    window.focus(&focus_handle);
+                                                    window.focus(&focus_handle, cx);
                                                     window.dispatch_action(
                                                         ToggleIncludeIgnored.boxed_clone(),
                                                         cx,

crates/fs/src/fs.rs 🔗

@@ -434,7 +434,18 @@ impl RealFs {
         for component in path.components() {
             match component {
                 std::path::Component::Prefix(_) => {
-                    let canonicalized = std::fs::canonicalize(component)?;
+                    let component = component.as_os_str();
+                    let canonicalized = if component
+                        .to_str()
+                        .map(|e| e.ends_with("\\"))
+                        .unwrap_or(false)
+                    {
+                        std::fs::canonicalize(component)
+                    } else {
+                        let mut component = component.to_os_string();
+                        component.push("\\");
+                        std::fs::canonicalize(component)
+                    }?;
 
                     let mut strip = PathBuf::new();
                     for component in canonicalized.components() {
@@ -3394,6 +3405,26 @@ mod tests {
         assert_eq!(content, "Hello");
     }
 
+    #[gpui::test]
+    #[cfg(target_os = "windows")]
+    async fn test_realfs_canonicalize(executor: BackgroundExecutor) {
+        use util::paths::SanitizedPath;
+
+        let fs = RealFs {
+            bundled_git_binary_path: None,
+            executor,
+            next_job_id: Arc::new(AtomicUsize::new(0)),
+            job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
+        };
+        let temp_dir = TempDir::new().unwrap();
+        let file = temp_dir.path().join("test (1).txt");
+        let file = SanitizedPath::new(&file);
+        std::fs::write(&file, "test").unwrap();
+
+        let canonicalized = fs.canonicalize(file.as_path()).await;
+        assert!(canonicalized.is_ok());
+    }
+
     #[gpui::test]
     async fn test_rename(executor: BackgroundExecutor) {
         let fs = FakeFs::new(executor.clone());

crates/git/src/blame.rs 🔗

@@ -1,10 +1,9 @@
+use crate::Oid;
 use crate::commit::get_messages;
 use crate::repository::RepoPath;
-use crate::{GitRemote, Oid};
 use anyhow::{Context as _, Result};
 use collections::{HashMap, HashSet};
 use futures::AsyncWriteExt;
-use gpui::SharedString;
 use serde::{Deserialize, Serialize};
 use std::process::Stdio;
 use std::{ops::Range, path::Path};
@@ -21,14 +20,6 @@ pub struct Blame {
     pub messages: HashMap<Oid, String>,
 }
 
-#[derive(Clone, Debug, Default)]
-pub struct ParsedCommitMessage {
-    pub message: SharedString,
-    pub permalink: Option<url::Url>,
-    pub pull_request: Option<crate::hosting_provider::PullRequest>,
-    pub remote: Option<GitRemote>,
-}
-
 impl Blame {
     pub async fn for_path(
         git_binary: &Path,

crates/git/src/commit.rs 🔗

@@ -1,7 +1,52 @@
-use crate::{Oid, status::StatusCode};
+use crate::{
+    BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url,
+    status::StatusCode,
+};
 use anyhow::{Context as _, Result};
 use collections::HashMap;
-use std::path::Path;
+use gpui::SharedString;
+use std::{path::Path, sync::Arc};
+
+#[derive(Clone, Debug, Default)]
+pub struct ParsedCommitMessage {
+    pub message: SharedString,
+    pub permalink: Option<url::Url>,
+    pub pull_request: Option<crate::hosting_provider::PullRequest>,
+    pub remote: Option<GitRemote>,
+}
+
+impl ParsedCommitMessage {
+    pub fn parse(
+        sha: String,
+        message: String,
+        remote_url: Option<&str>,
+        provider_registry: Option<Arc<GitHostingProviderRegistry>>,
+    ) -> Self {
+        if let Some((hosting_provider, remote)) = provider_registry
+            .and_then(|reg| remote_url.and_then(|url| parse_git_remote_url(reg, url)))
+        {
+            let pull_request = hosting_provider.extract_pull_request(&remote, &message);
+            Self {
+                message: message.into(),
+                permalink: Some(
+                    hosting_provider
+                        .build_commit_permalink(&remote, BuildCommitPermalinkParams { sha: &sha }),
+                ),
+                pull_request,
+                remote: Some(GitRemote {
+                    host: hosting_provider,
+                    owner: remote.owner.into(),
+                    repo: remote.repo.into(),
+                }),
+            }
+        } else {
+            Self {
+                message: message.into(),
+                ..Default::default()
+            }
+        }
+    }
+}
 
 pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result<HashMap<Oid, String>> {
     if shas.is_empty() {

crates/git/src/git.rs 🔗

@@ -23,6 +23,7 @@ pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
 pub const LFS_DIR: &str = "lfs";
 pub const COMMIT_MESSAGE: &str = "COMMIT_EDITMSG";
 pub const INDEX_LOCK: &str = "index.lock";
+pub const REPO_EXCLUDE: &str = "info/exclude";
 
 actions!(
     git,

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/blame_ui.rs 🔗

@@ -3,10 +3,7 @@ use crate::{
     commit_view::CommitView,
 };
 use editor::{BlameRenderer, Editor, hover_markdown_style};
-use git::{
-    blame::{BlameEntry, ParsedCommitMessage},
-    repository::CommitSummary,
-};
+use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary};
 use gpui::{
     ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle,
     TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,

crates/git_ui/src/branch_picker.rs 🔗

@@ -72,32 +72,26 @@ pub fn open(
     let repository = workspace.project().read(cx).active_repository(cx);
     let style = BranchListStyle::Modal;
     workspace.toggle_modal(window, cx, |window, cx| {
-        BranchList::new(
-            Some(workspace_handle),
-            repository,
-            style,
-            rems(34.),
-            window,
-            cx,
-        )
+        BranchList::new(workspace_handle, repository, style, rems(34.), window, cx)
     })
 }
 
 pub fn popover(
+    workspace: WeakEntity<Workspace>,
     repository: Option<Entity<Repository>>,
     window: &mut Window,
     cx: &mut App,
 ) -> Entity<BranchList> {
     cx.new(|cx| {
         let list = BranchList::new(
-            None,
+            workspace,
             repository,
             BranchListStyle::Popover,
             rems(20.),
             window,
             cx,
         );
-        list.focus_handle(cx).focus(window);
+        list.focus_handle(cx).focus(window, cx);
         list
     })
 }
@@ -117,7 +111,7 @@ pub struct BranchList {
 
 impl BranchList {
     fn new(
-        workspace: Option<WeakEntity<Workspace>>,
+        workspace: WeakEntity<Workspace>,
         repository: Option<Entity<Repository>>,
         style: BranchListStyle,
         width: Rems,
@@ -316,23 +310,23 @@ impl Entry {
 
 #[derive(Clone, Copy, PartialEq)]
 enum BranchFilter {
-    /// Only show local branches
-    Local,
-    /// Only show remote branches
+    /// Show both local and remote branches.
+    All,
+    /// Only show remote branches.
     Remote,
 }
 
 impl BranchFilter {
     fn invert(&self) -> Self {
         match self {
-            BranchFilter::Local => BranchFilter::Remote,
-            BranchFilter::Remote => BranchFilter::Local,
+            BranchFilter::All => BranchFilter::Remote,
+            BranchFilter::Remote => BranchFilter::All,
         }
     }
 }
 
 pub struct BranchListDelegate {
-    workspace: Option<WeakEntity<Workspace>>,
+    workspace: WeakEntity<Workspace>,
     matches: Vec<Entry>,
     all_branches: Option<Vec<Branch>>,
     default_branch: Option<SharedString>,
@@ -360,7 +354,7 @@ enum PickerState {
 
 impl BranchListDelegate {
     fn new(
-        workspace: Option<WeakEntity<Workspace>>,
+        workspace: WeakEntity<Workspace>,
         repo: Option<Entity<Repository>>,
         style: BranchListStyle,
         cx: &mut Context<BranchList>,
@@ -375,7 +369,7 @@ impl BranchListDelegate {
             selected_index: 0,
             last_query: Default::default(),
             modifiers: Default::default(),
-            branch_filter: BranchFilter::Local,
+            branch_filter: BranchFilter::All,
             state: PickerState::List,
             focus_handle: cx.focus_handle(),
         }
@@ -464,7 +458,7 @@ impl BranchListDelegate {
                     log::error!("Failed to delete branch: {}", e);
                 }
 
-                if let Some(workspace) = workspace.and_then(|w| w.upgrade()) {
+                if let Some(workspace) = workspace.upgrade() {
                     cx.update(|_window, cx| {
                         if is_remote {
                             show_error_toast(
@@ -518,7 +512,7 @@ impl PickerDelegate for BranchListDelegate {
         match self.state {
             PickerState::List | PickerState::NewRemote | PickerState::NewBranch => {
                 match self.branch_filter {
-                    BranchFilter::Local => "Select branch…",
+                    BranchFilter::All => "Select branch or remote…",
                     BranchFilter::Remote => "Select remote…",
                 }
             }
@@ -560,8 +554,8 @@ impl PickerDelegate for BranchListDelegate {
                         self.editor_position() == PickerEditorPosition::End,
                         |this| {
                             let tooltip_label = match self.branch_filter {
-                                BranchFilter::Local => "Turn Off Remote Filter",
-                                BranchFilter::Remote => "Filter Remote Branches",
+                                BranchFilter::All => "Filter Remote Branches",
+                                BranchFilter::Remote => "Show All Branches",
                             };
 
                             this.gap_1().justify_between().child({
@@ -625,40 +619,38 @@ impl PickerDelegate for BranchListDelegate {
             return Task::ready(());
         };
 
-        let display_remotes = self.branch_filter;
+        let branch_filter = self.branch_filter;
         cx.spawn_in(window, async move |picker, cx| {
+            let branch_matches_filter = |branch: &Branch| match branch_filter {
+                BranchFilter::All => true,
+                BranchFilter::Remote => branch.is_remote(),
+            };
+
             let mut matches: Vec<Entry> = if query.is_empty() {
-                all_branches
+                let mut matches: Vec<Entry> = all_branches
                     .into_iter()
-                    .filter(|branch| {
-                        if display_remotes == BranchFilter::Remote {
-                            branch.is_remote()
-                        } else {
-                            !branch.is_remote()
-                        }
-                    })
+                    .filter(|branch| branch_matches_filter(branch))
                     .map(|branch| Entry::Branch {
                         branch,
                         positions: Vec::new(),
                     })
-                    .collect()
+                    .collect();
+
+                // Keep the existing recency sort within each group, but show local branches first.
+                matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
+
+                matches
             } else {
                 let branches = all_branches
                     .iter()
-                    .filter(|branch| {
-                        if display_remotes == BranchFilter::Remote {
-                            branch.is_remote()
-                        } else {
-                            !branch.is_remote()
-                        }
-                    })
+                    .filter(|branch| branch_matches_filter(branch))
                     .collect::<Vec<_>>();
                 let candidates = branches
                     .iter()
                     .enumerate()
                     .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
                     .collect::<Vec<StringMatchCandidate>>();
-                fuzzy::match_strings(
+                let mut matches: Vec<Entry> = fuzzy::match_strings(
                     &candidates,
                     &query,
                     true,
@@ -673,7 +665,12 @@ impl PickerDelegate for BranchListDelegate {
                     branch: branches[candidate.candidate_id].clone(),
                     positions: candidate.positions,
                 })
-                .collect()
+                .collect();
+
+                // Keep fuzzy-relevance ordering within local/remote groups, but show locals first.
+                matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote()));
+
+                matches
             };
             picker
                 .update(cx, |picker, _| {
@@ -841,10 +838,13 @@ impl PickerDelegate for BranchListDelegate {
             Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => {
                 Icon::new(IconName::Plus).color(Color::Muted)
             }
-            Entry::Branch { .. } => match self.branch_filter {
-                BranchFilter::Local => Icon::new(IconName::GitBranchAlt).color(Color::Muted),
-                BranchFilter::Remote => Icon::new(IconName::Screen).color(Color::Muted),
-            },
+            Entry::Branch { branch, .. } => {
+                if branch.is_remote() {
+                    Icon::new(IconName::Screen).color(Color::Muted)
+                } else {
+                    Icon::new(IconName::GitBranchAlt).color(Color::Muted)
+                }
+            }
         };
 
         let entry_title = match entry {
@@ -874,19 +874,21 @@ impl PickerDelegate for BranchListDelegate {
             Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. }
         );
 
-        let delete_branch_button = IconButton::new("delete", IconName::Trash)
-            .tooltip(move |_, cx| {
-                Tooltip::for_action_in(
-                    "Delete Branch",
-                    &branch_picker::DeleteBranch,
-                    &focus_handle,
-                    cx,
-                )
-            })
-            .on_click(cx.listener(|this, _, window, cx| {
-                let selected_idx = this.delegate.selected_index();
-                this.delegate.delete_at(selected_idx, window, cx);
-            }));
+        let deleted_branch_icon = |entry_ix: usize, is_head_branch: bool| {
+            IconButton::new(("delete", entry_ix), IconName::Trash)
+                .tooltip(move |_, cx| {
+                    Tooltip::for_action_in(
+                        "Delete Branch",
+                        &branch_picker::DeleteBranch,
+                        &focus_handle,
+                        cx,
+                    )
+                })
+                .disabled(is_head_branch)
+                .on_click(cx.listener(move |this, _, window, cx| {
+                    this.delegate.delete_at(entry_ix, window, cx);
+                }))
+        };
 
         let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
             let tooltip_label: SharedString = format!("Create New From: {default_branch}").into();
@@ -963,12 +965,12 @@ impl PickerDelegate for BranchListDelegate {
                                                             "No commits found".into(),
                                                             |subject| {
                                                                 if show_author_name
-                                                                    && author_name.is_some()
+                                                                    && let Some(author) =
+                                                                        author_name
                                                                 {
                                                                     format!(
                                                                         "{}  •  {}",
-                                                                        author_name.unwrap(),
-                                                                        subject
+                                                                        author, subject
                                                                     )
                                                                 } else {
                                                                     subject.to_string()
@@ -1002,10 +1004,12 @@ impl PickerDelegate for BranchListDelegate {
                     self.editor_position() == PickerEditorPosition::End && !is_new_items,
                     |this| {
                         this.map(|this| {
+                            let is_head_branch =
+                                entry.as_branch().is_some_and(|branch| branch.is_head);
                             if self.selected_index() == ix {
-                                this.end_slot(delete_branch_button)
+                                this.end_slot(deleted_branch_icon(ix, is_head_branch))
                             } else {
-                                this.end_hover_slot(delete_branch_button)
+                                this.end_hover_slot(deleted_branch_icon(ix, is_head_branch))
                             }
                         })
                     },
@@ -1036,8 +1040,8 @@ impl PickerDelegate for BranchListDelegate {
     ) -> Option<AnyElement> {
         matches!(self.state, PickerState::List).then(|| {
             let label = match self.branch_filter {
-                BranchFilter::Local => "Local",
-                BranchFilter::Remote => "Remote",
+                BranchFilter::All => "Branches",
+                BranchFilter::Remote => "Remotes",
             };
 
             ListHeader::new(label).inset(true).into_any_element()
@@ -1230,7 +1234,7 @@ mod tests {
 
     use super::*;
     use git::repository::{CommitSummary, Remote};
-    use gpui::{TestAppContext, VisualTestContext};
+    use gpui::{AppContext, TestAppContext, VisualTestContext};
     use project::{FakeFs, Project};
     use rand::{Rng, rngs::StdRng};
     use serde_json::json;
@@ -1279,35 +1283,47 @@ mod tests {
         ]
     }
 
-    fn init_branch_list_test(
+    async fn init_branch_list_test(
         repository: Option<Entity<Repository>>,
         branches: Vec<Branch>,
         cx: &mut TestAppContext,
     ) -> (Entity<BranchList>, VisualTestContext) {
-        let window = cx.add_window(|window, cx| {
-            let mut delegate =
-                BranchListDelegate::new(None, repository, BranchListStyle::Modal, cx);
-            delegate.all_branches = Some(branches);
-            let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
-            let picker_focus_handle = picker.focus_handle(cx);
-            picker.update(cx, |picker, _| {
-                picker.delegate.focus_handle = picker_focus_handle.clone();
-            });
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
 
-            let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
-                cx.emit(DismissEvent);
-            });
+        let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
 
-            BranchList {
-                picker,
-                picker_focus_handle,
-                width: rems(34.),
-                _subscription,
-            }
-        });
+        let branch_list = workspace
+            .update(cx, |workspace, window, cx| {
+                cx.new(|cx| {
+                    let mut delegate = BranchListDelegate::new(
+                        workspace.weak_handle(),
+                        repository,
+                        BranchListStyle::Modal,
+                        cx,
+                    );
+                    delegate.all_branches = Some(branches);
+                    let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+                    let picker_focus_handle = picker.focus_handle(cx);
+                    picker.update(cx, |picker, _| {
+                        picker.delegate.focus_handle = picker_focus_handle.clone();
+                    });
 
-        let branch_list = window.root(cx).unwrap();
-        let cx = VisualTestContext::from_window(*window, cx);
+                    let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
+                        cx.emit(DismissEvent);
+                    });
+
+                    BranchList {
+                        picker,
+                        picker_focus_handle,
+                        width: rems(34.),
+                        _subscription,
+                    }
+                })
+            })
+            .unwrap();
+
+        let cx = VisualTestContext::from_window(*workspace, cx);
 
         (branch_list, cx)
     }
@@ -1343,7 +1359,7 @@ mod tests {
         init_test(cx);
 
         let branches = create_test_branches();
-        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
+        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
         let cx = &mut ctx;
 
         branch_list
@@ -1419,7 +1435,7 @@ mod tests {
         .await;
         cx.run_until_parked();
 
-        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx);
+        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
         let cx = &mut ctx;
 
         update_branch_list_matches_with_empty_query(&branch_list, cx).await;
@@ -1484,7 +1500,7 @@ mod tests {
         .await;
         cx.run_until_parked();
 
-        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx);
+        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
         let cx = &mut ctx;
         // Enable remote filter
         branch_list.update(cx, |branch_list, cx| {
@@ -1532,7 +1548,7 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_update_remote_matches_with_query(cx: &mut TestAppContext) {
+    async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) {
         init_test(cx);
 
         let branches = vec![
@@ -1542,39 +1558,54 @@ mod tests {
             create_test_branch("develop", false, None, Some(700)),
         ];
 
-        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
+        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
         let cx = &mut ctx;
 
         update_branch_list_matches_with_empty_query(&branch_list, cx).await;
 
-        // Check matches, it should match all existing branches and no option to create new branch
-        branch_list
-            .update_in(cx, |branch_list, window, cx| {
-                branch_list.picker.update(cx, |picker, cx| {
-                    assert_eq!(picker.delegate.matches.len(), 2);
-                    let branches = picker
-                        .delegate
-                        .matches
-                        .iter()
-                        .map(|be| be.name())
-                        .collect::<HashSet<_>>();
-                    assert_eq!(
-                        branches,
-                        ["feature-ui", "develop"]
-                            .into_iter()
-                            .collect::<HashSet<_>>()
-                    );
+        branch_list.update(cx, |branch_list, cx| {
+            branch_list.picker.update(cx, |picker, _cx| {
+                assert_eq!(picker.delegate.matches.len(), 4);
 
-                    // Verify the last entry is NOT the "create new branch" option
-                    let last_match = picker.delegate.matches.last().unwrap();
-                    assert!(!last_match.is_new_branch());
-                    assert!(!last_match.is_new_url());
-                    picker.delegate.branch_filter = BranchFilter::Remote;
-                    picker.delegate.update_matches(String::new(), window, cx)
-                })
+                let branches = picker
+                    .delegate
+                    .matches
+                    .iter()
+                    .map(|be| be.name())
+                    .collect::<HashSet<_>>();
+                assert_eq!(
+                    branches,
+                    ["origin/main", "fork/feature-auth", "feature-ui", "develop"]
+                        .into_iter()
+                        .collect::<HashSet<_>>()
+                );
+
+                // Locals should be listed before remotes.
+                let ordered = picker
+                    .delegate
+                    .matches
+                    .iter()
+                    .map(|be| be.name())
+                    .collect::<Vec<_>>();
+                assert_eq!(
+                    ordered,
+                    vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"]
+                );
+
+                // Verify the last entry is NOT the "create new branch" option
+                let last_match = picker.delegate.matches.last().unwrap();
+                assert!(!last_match.is_new_branch());
+                assert!(!last_match.is_new_url());
             })
-            .await;
-        cx.run_until_parked();
+        });
+
+        branch_list.update(cx, |branch_list, cx| {
+            branch_list.picker.update(cx, |picker, _cx| {
+                picker.delegate.branch_filter = BranchFilter::Remote;
+            })
+        });
+
+        update_branch_list_matches_with_empty_query(&branch_list, cx).await;
 
         branch_list
             .update_in(cx, |branch_list, window, cx| {
@@ -1637,7 +1668,8 @@ mod tests {
             create_test_branch(FEATURE_BRANCH, false, None, Some(900)),
         ];
 
-        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, test_cx);
+        let (branch_list, mut ctx) =
+            init_branch_list_test(repository.into(), branches, test_cx).await;
         let cx = &mut ctx;
 
         branch_list
@@ -1696,7 +1728,7 @@ mod tests {
         let repository = init_fake_repository(cx).await;
         let branches = vec![create_test_branch("main", true, None, Some(1000))];
 
-        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx);
+        let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
         let cx = &mut ctx;
 
         branch_list
@@ -1774,7 +1806,7 @@ mod tests {
         init_test(cx);
 
         let branches = vec![create_test_branch("main_branch", true, None, Some(1000))];
-        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
+        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
         let cx = &mut ctx;
 
         branch_list
@@ -1837,7 +1869,7 @@ mod tests {
         init_test(cx);
         let branches = vec![create_test_branch("main", true, None, Some(1000))];
 
-        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
+        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
         let cx = &mut ctx;
 
         let subscription = cx.update(|_, cx| {
@@ -1848,7 +1880,12 @@ mod tests {
 
         branch_list
             .update_in(cx, |branch_list, window, cx| {
-                window.focus(&branch_list.picker_focus_handle);
+                window.focus(&branch_list.picker_focus_handle, cx);
+                assert!(
+                    branch_list.picker_focus_handle.is_focused(window),
+                    "Branch picker should be focused when selecting an entry"
+                );
+
                 branch_list.picker.update(cx, |picker, cx| {
                     picker
                         .delegate
@@ -1860,6 +1897,9 @@ mod tests {
         cx.run_until_parked();
 
         branch_list.update_in(cx, |branch_list, window, cx| {
+            // Re-focus the picker since workspace initialization during run_until_parked
+            window.focus(&branch_list.picker_focus_handle, cx);
+
             branch_list.picker.update(cx, |picker, cx| {
                 let last_match = picker.delegate.matches.last().unwrap();
                 assert!(last_match.is_new_url());
@@ -1893,7 +1933,7 @@ mod tests {
             .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100)))
             .collect();
 
-        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx);
+        let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await;
         let cx = &mut ctx;
 
         update_branch_list_matches_with_empty_query(&branch_list, cx).await;

crates/git_ui/src/clone.rs 🔗

@@ -0,0 +1,155 @@
+use gpui::{App, Context, WeakEntity, Window};
+use notifications::status_toast::{StatusToast, ToastIcon};
+use std::sync::Arc;
+use ui::{Color, IconName, SharedString};
+use util::ResultExt;
+use workspace::{self, Workspace};
+
+pub fn clone_and_open(
+    repo_url: SharedString,
+    workspace: WeakEntity<Workspace>,
+    window: &mut Window,
+    cx: &mut App,
+    on_success: Arc<
+        dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send + Sync + 'static,
+    >,
+) {
+    let destination_prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
+        files: false,
+        directories: true,
+        multiple: false,
+        prompt: Some("Select as Repository Destination".into()),
+    });
+
+    window
+        .spawn(cx, async move |cx| {
+            let mut paths = destination_prompt.await.ok()?.ok()??;
+            let mut destination_dir = paths.pop()?;
+
+            let repo_name = repo_url
+                .split('/')
+                .next_back()
+                .map(|name| name.strip_suffix(".git").unwrap_or(name))
+                .unwrap_or("repository")
+                .to_owned();
+
+            let clone_task = workspace
+                .update(cx, |workspace, cx| {
+                    let fs = workspace.app_state().fs.clone();
+                    let destination_dir = destination_dir.clone();
+                    let repo_url = repo_url.clone();
+                    cx.spawn(async move |_workspace, _cx| {
+                        fs.git_clone(&repo_url, destination_dir.as_path()).await
+                    })
+                })
+                .ok()?;
+
+            if let Err(error) = clone_task.await {
+                workspace
+                    .update(cx, |workspace, cx| {
+                        let toast = StatusToast::new(error.to_string(), cx, |this, _| {
+                            this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
+                                .dismiss_button(true)
+                        });
+                        workspace.toggle_status_toast(toast, cx);
+                    })
+                    .log_err();
+                return None;
+            }
+
+            let has_worktrees = workspace
+                .read_with(cx, |workspace, cx| {
+                    workspace.project().read(cx).worktrees(cx).next().is_some()
+                })
+                .ok()?;
+
+            let prompt_answer = if has_worktrees {
+                cx.update(|window, cx| {
+                    window.prompt(
+                        gpui::PromptLevel::Info,
+                        &format!("Git Clone: {}", repo_name),
+                        None,
+                        &["Add repo to project", "Open repo in new project"],
+                        cx,
+                    )
+                })
+                .ok()?
+                .await
+                .ok()?
+            } else {
+                // Don't ask if project is empty
+                0
+            };
+
+            destination_dir.push(&repo_name);
+
+            match prompt_answer {
+                0 => {
+                    workspace
+                        .update_in(cx, |workspace, window, cx| {
+                            let create_task = workspace.project().update(cx, |project, cx| {
+                                project.create_worktree(destination_dir.as_path(), true, cx)
+                            });
+
+                            let workspace_weak = cx.weak_entity();
+                            let on_success = on_success.clone();
+                            cx.spawn_in(window, async move |_window, cx| {
+                                if create_task.await.log_err().is_some() {
+                                    workspace_weak
+                                        .update_in(cx, |workspace, window, cx| {
+                                            (on_success)(workspace, window, cx);
+                                        })
+                                        .ok();
+                                }
+                            })
+                            .detach();
+                        })
+                        .ok()?;
+                }
+                1 => {
+                    workspace
+                        .update(cx, move |workspace, cx| {
+                            let app_state = workspace.app_state().clone();
+                            let destination_path = destination_dir.clone();
+                            let on_success = on_success.clone();
+
+                            workspace::open_new(
+                                Default::default(),
+                                app_state,
+                                cx,
+                                move |workspace, window, cx| {
+                                    cx.activate(true);
+
+                                    let create_task =
+                                        workspace.project().update(cx, |project, cx| {
+                                            project.create_worktree(
+                                                destination_path.as_path(),
+                                                true,
+                                                cx,
+                                            )
+                                        });
+
+                                    let workspace_weak = cx.weak_entity();
+                                    cx.spawn_in(window, async move |_window, cx| {
+                                        if create_task.await.log_err().is_some() {
+                                            workspace_weak
+                                                .update_in(cx, |workspace, window, cx| {
+                                                    (on_success)(workspace, window, cx);
+                                                })
+                                                .ok();
+                                        }
+                                    })
+                                    .detach();
+                                },
+                            )
+                            .detach();
+                        })
+                        .ok();
+                }
+                _ => {}
+            }
+
+            Some(())
+        })
+        .detach();
+}

crates/git_ui/src/commit_modal.rs 🔗

@@ -337,6 +337,7 @@ impl CommitModal {
             active_repo,
             is_amend_pending,
             is_signoff_enabled,
+            workspace,
         ) = self.git_panel.update(cx, |git_panel, cx| {
             let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
             let title = git_panel.commit_button_title();
@@ -354,6 +355,7 @@ impl CommitModal {
                 active_repo,
                 is_amend_pending,
                 is_signoff_enabled,
+                git_panel.workspace.clone(),
             )
         });
 
@@ -375,7 +377,14 @@ impl CommitModal {
             .style(ButtonStyle::Transparent);
 
         let branch_picker = PopoverMenu::new("popover-button")
-            .menu(move |window, cx| Some(branch_picker::popover(active_repo.clone(), window, cx)))
+            .menu(move |window, cx| {
+                Some(branch_picker::popover(
+                    workspace.clone(),
+                    active_repo.clone(),
+                    window,
+                    cx,
+                ))
+            })
             .with_handle(self.branch_list_handle.clone())
             .trigger_with_tooltip(
                 branch_picker_button,
@@ -512,7 +521,7 @@ impl CommitModal {
 
     fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         if self.branch_list_handle.is_focused(window, cx) {
-            self.focus_handle(cx).focus(window)
+            self.focus_handle(cx).focus(window, cx)
         } else {
             self.branch_list_handle.toggle(window, cx);
         }
@@ -578,8 +587,8 @@ impl Render for CommitModal {
                     .bg(cx.theme().colors().editor_background)
                     .border_1()
                     .border_color(cx.theme().colors().border_variant)
-                    .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
-                        window.focus(&editor_focus_handle);
+                    .on_click(cx.listener(move |_, _: &ClickEvent, window, cx| {
+                        window.focus(&editor_focus_handle, cx);
                     }))
                     .child(
                         div()

crates/git_ui/src/commit_tooltip.rs 🔗

@@ -3,7 +3,7 @@ use editor::hover_markdown_style;
 use futures::Future;
 use git::blame::BlameEntry;
 use git::repository::CommitSummary;
-use git::{GitRemote, blame::ParsedCommitMessage};
+use git::{GitRemote, commit::ParsedCommitMessage};
 use gpui::{
     App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
     StatefulInteractiveElement, WeakEntity, prelude::*,

crates/git_ui/src/commit_view.rs 🔗

@@ -3,7 +3,10 @@ use buffer_diff::{BufferDiff, BufferDiffSnapshot};
 use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
 use editor::{Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines};
 use git::repository::{CommitDetails, CommitDiff, RepoPath};
-use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url};
+use git::{
+    BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, ParsedGitRemote,
+    parse_git_remote_url,
+};
 use gpui::{
     AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, Context, Element, Entity,
     EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
@@ -66,7 +69,7 @@ struct GitBlob {
     path: RepoPath,
     worktree_id: WorktreeId,
     is_deleted: bool,
-    display_name: Arc<str>,
+    display_name: String,
 }
 
 const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0;
@@ -240,9 +243,8 @@ impl CommitView {
                     .path
                     .file_name()
                     .map(|name| name.to_string())
-                    .unwrap_or_else(|| file.path.display(PathStyle::Posix).to_string());
-                let display_name: Arc<str> =
-                    Arc::from(format!("{short_sha} - {file_name}").into_boxed_str());
+                    .unwrap_or_else(|| file.path.display(PathStyle::local()).to_string());
+                let display_name = format!("{short_sha} - {file_name}");
 
                 let file = Arc::new(GitBlob {
                     path: file.path.clone(),
@@ -393,13 +395,15 @@ impl CommitView {
 
         let remote_info = self.remote.as_ref().map(|remote| {
             let provider = remote.host.name();
-            let url = format!(
-                "{}/{}/{}/commit/{}",
-                remote.host.base_url(),
-                remote.owner,
-                remote.repo,
-                commit.sha
-            );
+            let parsed_remote = ParsedGitRemote {
+                owner: remote.owner.as_ref().into(),
+                repo: remote.repo.as_ref().into(),
+            };
+            let params = BuildCommitPermalinkParams { sha: &commit.sha };
+            let url = remote
+                .host
+                .build_commit_permalink(&parsed_remote, params)
+                .to_string();
             (provider, url)
         });
 
@@ -656,15 +660,13 @@ impl language::File for GitBlob {
     }
 
     fn disk_state(&self) -> DiskState {
-        if self.is_deleted {
-            DiskState::Deleted
-        } else {
-            DiskState::New
+        DiskState::Historic {
+            was_deleted: self.is_deleted,
         }
     }
 
     fn path_style(&self, _: &App) -> PathStyle {
-        PathStyle::Posix
+        PathStyle::local()
     }
 
     fn path(&self) -> &Arc<RelPath> {
@@ -692,45 +694,6 @@ impl language::File for GitBlob {
     }
 }
 
-// No longer needed since metadata buffer is not created
-// impl language::File for CommitMetadataFile {
-//     fn as_local(&self) -> Option<&dyn language::LocalFile> {
-//         None
-//     }
-//
-//     fn disk_state(&self) -> DiskState {
-//         DiskState::New
-//     }
-//
-//     fn path_style(&self, _: &App) -> PathStyle {
-//         PathStyle::Posix
-//     }
-//
-//     fn path(&self) -> &Arc<RelPath> {
-//         &self.title
-//     }
-//
-//     fn full_path(&self, _: &App) -> PathBuf {
-//         self.title.as_std_path().to_path_buf()
-//     }
-//
-//     fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
-//         self.title.file_name().unwrap_or("commit")
-//     }
-//
-//     fn worktree_id(&self, _: &App) -> WorktreeId {
-//         self.worktree_id
-//     }
-//
-//     fn to_proto(&self, _cx: &App) -> language::proto::File {
-//         unimplemented!()
-//     }
-//
-//     fn is_private(&self) -> bool {
-//         false
-//     }
-// }
-
 async fn build_buffer(
     mut text: String,
     blob: Arc<dyn File>,

crates/git_ui/src/file_history_view.rs 🔗

@@ -633,9 +633,9 @@ impl Item for FileHistoryView {
         &mut self,
         _workspace: &mut Workspace,
         window: &mut Window,
-        _cx: &mut Context<Self>,
+        cx: &mut Context<Self>,
     ) {
-        window.focus(&self.focus_handle);
+        window.focus(&self.focus_handle, cx);
     }
 
     fn show_toolbar(&self) -> bool {

crates/git_ui/src/git_panel.rs 🔗

@@ -15,12 +15,13 @@ use askpass::AskPassDelegate;
 use cloud_llm_client::CompletionIntent;
 use collections::{BTreeMap, HashMap, HashSet};
 use db::kvp::KEY_VALUE_STORE;
+use editor::RewrapOptions;
 use editor::{
     Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
     actions::ExpandAllDiffHunks,
 };
 use futures::StreamExt as _;
-use git::blame::ParsedCommitMessage;
+use git::commit::ParsedCommitMessage;
 use git::repository::{
     Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
     PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
@@ -30,22 +31,22 @@ use git::stash::GitStash;
 use git::status::StageStatus;
 use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
 use git::{
-    ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop,
-    TrashUntrackedFiles, UnstageAll,
+    ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
+    StashApply, StashPop, TrashUntrackedFiles, UnstageAll,
 };
 use gpui::{
     Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity,
-    EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
-    ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy,
-    Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point,
-    size, uniform_list,
+    EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point,
+    PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions,
+    anchored, deferred, point, size, uniform_list,
 };
 use itertools::Itertools;
 use language::{Buffer, File};
 use language_model::{
-    ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
+    ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
+    Role, ZED_CLOUD_PROVIDER_ID,
 };
-use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use menu;
 use multi_buffer::ExcerptInfo;
 use notifications::status_toast::{StatusToast, ToastIcon};
 use panel::{
@@ -57,6 +58,7 @@ use project::{
     git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
     project_settings::{GitPathStyle, ProjectSettings},
 };
+use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore, StatusStyle};
 use std::future::Future;
@@ -71,7 +73,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,
@@ -91,12 +93,24 @@ actions!(
         FocusEditor,
         /// Focuses on the changes list.
         FocusChanges,
+        /// Select next git panel menu item, and show it in the diff view
+        NextEntry,
+        /// Select previous git panel menu item, and show it in the diff view
+        PreviousEntry,
+        /// Select first git panel menu item, and show it in the diff view
+        FirstEntry,
+        /// Select last git panel menu item, and show it in the diff view
+        LastEntry,
         /// Toggles automatic co-author suggestions.
         ToggleFillCoAuthors,
         /// Toggles sorting entries by path vs status.
         ToggleSortByPath,
         /// Toggles showing entries in tree vs flat view.
         ToggleTreeView,
+        /// Expands the selected entry to show its children.
+        ExpandSelectedEntry,
+        /// Collapses the selected entry to hide its children.
+        CollapseSelectedEntry,
     ]
 );
 
@@ -198,8 +212,7 @@ const GIT_PANEL_KEY: &str = "GitPanel";
 
 const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
 // TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel
-const TREE_INDENT: f32 = 12.0;
-const TREE_INDENT_GUIDE_OFFSET: f32 = 16.0;
+const TREE_INDENT: f32 = 16.0;
 
 pub fn register(workspace: &mut Workspace) {
     workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
@@ -273,6 +286,13 @@ impl GitListEntry {
             _ => None,
         }
     }
+
+    fn directory_entry(&self) -> Option<&GitTreeDirEntry> {
+        match self {
+            GitListEntry::Directory(entry) => Some(entry),
+            _ => None,
+        }
+    }
 }
 
 enum GitPanelViewMode {
@@ -319,9 +339,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 +383,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 +392,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 +402,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 +417,6 @@ impl TreeViewState {
                     key,
                     name,
                     depth,
-                    staged_state,
                     expanded,
                 }),
                 true,
@@ -465,23 +460,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 +479,7 @@ struct GitTreeDirEntry {
     key: TreeKey,
     name: SharedString,
     depth: usize,
-    staged_state: ToggleState,
+    // staged_state: ToggleState,
     expanded: bool,
 }
 
@@ -630,7 +608,7 @@ pub struct GitPanel {
     tracked_staged_count: usize,
     update_visible_entries_task: Task<()>,
     width: Option<Pixels>,
-    workspace: WeakEntity<Workspace>,
+    pub(crate) workspace: WeakEntity<Workspace>,
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     modal_open: bool,
     show_placeholders: bool,
@@ -638,7 +616,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 +785,6 @@ impl GitPanel {
                 entry_count: 0,
                 bulk_staging: None,
                 stash_entries: Default::default(),
-                optimistic_staging: HashMap::default(),
                 _settings_subscription,
             };
 
@@ -824,20 +800,63 @@ impl GitPanel {
     pub fn select_entry_by_path(
         &mut self,
         path: ProjectPath,
-        _: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         let Some(git_repo) = self.active_repository.as_ref() else {
             return;
         };
-        let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
-            return;
+
+        let (repo_path, section) = {
+            let repo = git_repo.read(cx);
+            let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
+                return;
+            };
+
+            let section = repo
+                .status_for_path(&repo_path)
+                .map(|status| status.status)
+                .map(|status| {
+                    if repo.had_conflict_on_last_merge_head_change(&repo_path) {
+                        Section::Conflict
+                    } else if status.is_created() {
+                        Section::New
+                    } else {
+                        Section::Tracked
+                    }
+                });
+
+            (repo_path, section)
         };
+
+        let mut needs_rebuild = false;
+        if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
+            let mut current_dir = repo_path.parent();
+            while let Some(dir) = current_dir {
+                let key = TreeKey {
+                    section,
+                    path: RepoPath::from_rel_path(dir),
+                };
+
+                if tree_state.expanded_dirs.get(&key) == Some(&false) {
+                    tree_state.expanded_dirs.insert(key, true);
+                    needs_rebuild = true;
+                }
+
+                current_dir = dir.parent();
+            }
+        }
+
+        if needs_rebuild {
+            self.update_visible_entries(window, cx);
+        }
+
         let Some(ix) = self.entry_by_path(&repo_path) else {
             return;
         };
+
         self.selected_entry = Some(ix);
-        cx.notify();
+        self.scroll_to_selected_entry(cx);
     }
 
     fn serialization_key(workspace: &Workspace) -> Option<String> {
@@ -925,22 +944,90 @@ impl GitPanel {
     }
 
     fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
-        if let Some(selected_entry) = self.selected_entry {
+        let Some(selected_entry) = self.selected_entry else {
+            cx.notify();
+            return;
+        };
+
+        let visible_index = match &self.view_mode {
+            GitPanelViewMode::Flat => Some(selected_entry),
+            GitPanelViewMode::Tree(state) => state
+                .logical_indices
+                .iter()
+                .position(|&ix| ix == selected_entry),
+        };
+
+        if let Some(visible_index) = visible_index {
             self.scroll_handle
-                .scroll_to_item(selected_entry, ScrollStrategy::Center);
+                .scroll_to_item(visible_index, ScrollStrategy::Center);
         }
 
         cx.notify();
     }
 
-    fn first_status_entry_index(&self) -> Option<usize> {
-        self.entries
-            .iter()
-            .position(|entry| entry.status_entry().is_some())
+    fn expand_selected_entry(
+        &mut self,
+        _: &ExpandSelectedEntry,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(entry) = self.get_selected_entry().cloned() else {
+            return;
+        };
+
+        if let GitListEntry::Directory(dir_entry) = entry {
+            if dir_entry.expanded {
+                self.select_next(&menu::SelectNext, window, cx);
+            } else {
+                self.toggle_directory(&dir_entry.key, window, cx);
+            }
+        } else {
+            self.select_next(&menu::SelectNext, window, cx);
+        }
     }
 
-    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
-        if let Some(first_entry) = self.first_status_entry_index() {
+    fn collapse_selected_entry(
+        &mut self,
+        _: &CollapseSelectedEntry,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(entry) = self.get_selected_entry().cloned() else {
+            return;
+        };
+
+        if let GitListEntry::Directory(dir_entry) = entry {
+            if dir_entry.expanded {
+                self.toggle_directory(&dir_entry.key, window, cx);
+            } else {
+                self.select_previous(&menu::SelectPrevious, window, cx);
+            }
+        } else {
+            self.select_previous(&menu::SelectPrevious, window, cx);
+        }
+    }
+
+    fn select_first(
+        &mut self,
+        _: &menu::SelectFirst,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let first_entry = match &self.view_mode {
+            GitPanelViewMode::Flat => self
+                .entries
+                .iter()
+                .position(|entry| entry.status_entry().is_some()),
+            GitPanelViewMode::Tree(state) => {
+                let index = self.entries.iter().position(|entry| {
+                    entry.status_entry().is_some() || entry.directory_entry().is_some()
+                });
+
+                index.map(|index| state.logical_indices[index])
+            }
+        };
+
+        if let Some(first_entry) = first_entry {
             self.selected_entry = Some(first_entry);
             self.scroll_to_selected_entry(cx);
         }
@@ -948,7 +1035,7 @@ impl GitPanel {
 
     fn select_previous(
         &mut self,
-        _: &SelectPrevious,
+        _: &menu::SelectPrevious,
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -957,80 +1044,142 @@ impl GitPanel {
             return;
         }
 
-        if let Some(selected_entry) = self.selected_entry {
-            let new_selected_entry = if selected_entry > 0 {
-                selected_entry - 1
-            } else {
-                selected_entry
-            };
+        let Some(selected_entry) = self.selected_entry else {
+            return;
+        };
 
-            if matches!(
-                self.entries.get(new_selected_entry),
-                Some(GitListEntry::Header(..))
-            ) {
-                if new_selected_entry > 0 {
-                    self.selected_entry = Some(new_selected_entry - 1)
-                }
-            } else {
-                self.selected_entry = Some(new_selected_entry);
+        let new_index = match &self.view_mode {
+            GitPanelViewMode::Flat => selected_entry.saturating_sub(1),
+            GitPanelViewMode::Tree(state) => {
+                let Some(current_logical_index) = state
+                    .logical_indices
+                    .iter()
+                    .position(|&i| i == selected_entry)
+                else {
+                    return;
+                };
+
+                state.logical_indices[current_logical_index.saturating_sub(1)]
             }
+        };
 
-            self.scroll_to_selected_entry(cx);
+        if selected_entry == 0 && new_index == 0 {
+            return;
         }
 
-        cx.notify();
+        if matches!(
+            self.entries.get(new_index.saturating_sub(1)),
+            Some(GitListEntry::Header(..))
+        ) && new_index == 0
+        {
+            return;
+        }
+
+        if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
+            self.selected_entry = Some(new_index.saturating_sub(1));
+        } else {
+            self.selected_entry = Some(new_index);
+        }
+
+        self.scroll_to_selected_entry(cx);
     }
 
-    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
         let item_count = self.entries.len();
         if item_count == 0 {
             return;
         }
 
-        if let Some(selected_entry) = self.selected_entry {
-            let new_selected_entry = if selected_entry < item_count - 1 {
-                selected_entry + 1
-            } else {
-                selected_entry
-            };
-            if matches!(
-                self.entries.get(new_selected_entry),
-                Some(GitListEntry::Header(..))
-            ) {
-                self.selected_entry = Some(new_selected_entry + 1);
-            } else {
-                self.selected_entry = Some(new_selected_entry);
+        let Some(selected_entry) = self.selected_entry else {
+            return;
+        };
+
+        if selected_entry == item_count - 1 {
+            return;
+        }
+
+        let new_index = match &self.view_mode {
+            GitPanelViewMode::Flat => selected_entry.saturating_add(1),
+            GitPanelViewMode::Tree(state) => {
+                let Some(current_logical_index) = state
+                    .logical_indices
+                    .iter()
+                    .position(|&i| i == selected_entry)
+                else {
+                    return;
+                };
+
+                state.logical_indices[current_logical_index.saturating_add(1)]
             }
+        };
 
-            self.scroll_to_selected_entry(cx);
+        if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
+            self.selected_entry = Some(new_index.saturating_add(1));
+        } else {
+            self.selected_entry = Some(new_index);
         }
 
-        cx.notify();
+        self.scroll_to_selected_entry(cx);
     }
 
-    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
         if self.entries.last().is_some() {
             self.selected_entry = Some(self.entries.len() - 1);
             self.scroll_to_selected_entry(cx);
         }
     }
 
+    /// Show diff view at selected entry, only if the diff view is open
+    fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        maybe!({
+            let workspace = self.workspace.upgrade()?;
+
+            if let Some(project_diff) = workspace.read(cx).item_of_type::<ProjectDiff>(cx) {
+                let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
+
+                project_diff.update(cx, |project_diff, cx| {
+                    project_diff.move_to_entry(entry.clone(), window, cx);
+                });
+            }
+
+            Some(())
+        });
+    }
+
+    fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_first(&menu::SelectFirst, window, cx);
+        self.move_diff_to_entry(window, cx);
+    }
+
+    fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_last(&menu::SelectLast, window, cx);
+        self.move_diff_to_entry(window, cx);
+    }
+
+    fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_next(&menu::SelectNext, window, cx);
+        self.move_diff_to_entry(window, cx);
+    }
+
+    fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context<Self>) {
+        self.select_previous(&menu::SelectPrevious, window, cx);
+        self.move_diff_to_entry(window, cx);
+    }
+
     fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
         self.commit_editor.update(cx, |editor, cx| {
-            window.focus(&editor.focus_handle(cx));
+            window.focus(&editor.focus_handle(cx), cx);
         });
         cx.notify();
     }
 
-    fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
+    fn select_first_entry_if_none(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let have_entries = self
             .active_repository
             .as_ref()
             .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
         if have_entries && self.selected_entry.is_none() {
-            self.selected_entry = self.first_status_entry_index();
-            self.scroll_to_selected_entry(cx);
-            cx.notify();
+            self.select_first(&menu::SelectFirst, window, cx);
         }
     }
 
@@ -1040,10 +1189,8 @@ impl GitPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.select_first_entry_if_none(cx);
-
-        self.focus_handle.focus(window);
-        cx.notify();
+        self.focus_handle.focus(window, cx);
+        self.select_first_entry_if_none(window, cx);
     }
 
     fn get_selected_entry(&self) -> Option<&GitListEntry> {
@@ -1064,7 +1211,7 @@ impl GitPanel {
                         .project_path_to_repo_path(&project_path, cx)
                         .as_ref()
             {
-                project_diff.focus_handle(cx).focus(window);
+                project_diff.focus_handle(cx).focus(window, cx);
                 project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
                 return None;
             };
@@ -1074,7 +1221,7 @@ impl GitPanel {
                     ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
                 })
                 .ok();
-            self.focus_handle.focus(window);
+            self.focus_handle.focus(window, cx);
 
             Some(())
         });
@@ -1177,14 +1324,14 @@ impl GitPanel {
                 let prompt = window.prompt(
                     PromptLevel::Warning,
                     &format!(
-                        "Are you sure you want to restore {}?",
+                        "Are you sure you want to discard changes to {}?",
                         entry
                             .repo_path
                             .file_name()
                             .unwrap_or(entry.repo_path.display(path_style).as_ref()),
                     ),
                     None,
-                    &["Restore", "Cancel"],
+                    &["Discard Changes", "Cancel"],
                     cx,
                 );
                 cx.background_spawn(prompt)
@@ -1555,7 +1702,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 +1711,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 +1792,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 +1834,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 +1843,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 +1857,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 +1876,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 +1920,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
     }
@@ -2066,7 +2181,13 @@ impl GitPanel {
         let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
         let wrapped_message = editor.update(cx, |editor, cx| {
             editor.select_all(&Default::default(), window, cx);
-            editor.rewrap(&Default::default(), window, cx);
+            editor.rewrap_impl(
+                RewrapOptions {
+                    override_language_settings: false,
+                    preserve_existing_whitespace: true,
+                },
+                cx,
+            );
             editor.text(cx)
         });
         if wrapped_message.trim().is_empty() {
@@ -2117,7 +2238,10 @@ impl GitPanel {
         let commit_message = self.custom_or_suggested_commit_message(window, cx);
 
         let Some(mut message) = commit_message else {
-            self.commit_editor.read(cx).focus_handle(cx).focus(window);
+            self.commit_editor
+                .read(cx)
+                .focus_handle(cx)
+                .focus(window, cx);
             return;
         };
 
@@ -2401,6 +2525,82 @@ 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)
+        }
+    }
+
+    async fn load_commit_message_prompt(
+        is_using_legacy_zed_pro: bool,
+        cx: &mut AsyncApp,
+    ) -> String {
+        // Remove this once we stop supporting legacy Zed Pro
+        // In legacy Zed Pro, Git commit summary generation did not count as a
+        // prompt. If the user changes the prompt, our classification will fail,
+        // meaning that users will be charged for generating commit messages.
+        if is_using_legacy_zed_pro {
+            return BuiltInPrompt::CommitMessage.default_content().to_string();
+        }
+
+        let load = async {
+            let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?;
+            store
+                .update(cx, |s, cx| {
+                    s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
+                })
+                .ok()?
+                .await
+                .ok()
+        };
+        load.await
+            .unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
+    }
+
     /// 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) {

crates/git_ui/src/git_ui.rs 🔗

@@ -10,6 +10,7 @@ use ui::{
 };
 
 mod blame_ui;
+pub mod clone;
 
 use git::{
     repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
@@ -817,7 +818,7 @@ impl GitCloneModal {
         });
         let focus_handle = repo_input.focus_handle(cx);
 
-        window.focus(&focus_handle);
+        window.focus(&focus_handle, cx);
 
         Self {
             panel,

crates/git_ui/src/onboarding.rs 🔗

@@ -85,8 +85,8 @@ impl Render for GitOnboardingModal {
                 git_onboarding_event!("Cancelled", trigger = "Action");
                 cx.emit(DismissEvent);
             }))
-            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
-                this.focus_handle.focus(window);
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
+                this.focus_handle.focus(window, cx);
             }))
             .child(
                 div().p_1p5().absolute().inset_0().h(px(160.)).child(

crates/git_ui/src/project_diff.rs 🔗

@@ -494,7 +494,7 @@ impl ProjectDiff {
         if editor.focus_handle(cx).contains_focused(window, cx)
             && self.multibuffer.read(cx).is_empty()
         {
-            self.focus_handle.focus(window)
+            self.focus_handle.focus(window, cx)
         }
     }
 
@@ -599,10 +599,10 @@ impl ProjectDiff {
                 .focus_handle(cx)
                 .contains_focused(window, cx)
         {
-            self.focus_handle.focus(window);
+            self.focus_handle.focus(window, cx);
         } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
             self.editor.update(cx, |editor, cx| {
-                editor.focus_handle(cx).focus(window);
+                editor.focus_handle(cx).focus(window, cx);
             });
         }
         if self.pending_scroll.as_ref() == Some(&path_key) {
@@ -1028,7 +1028,7 @@ impl Render for ProjectDiff {
                                         cx,
                                     ))
                                     .on_click(move |_, window, cx| {
-                                        window.focus(&keybinding_focus_handle);
+                                        window.focus(&keybinding_focus_handle, cx);
                                         window.dispatch_action(
                                             Box::new(CloseActiveItem::default()),
                                             cx,
@@ -1198,7 +1198,7 @@ impl ProjectDiffToolbar {
 
     fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(project_diff) = self.project_diff(cx) {
-            project_diff.focus_handle(cx).focus(window);
+            project_diff.focus_handle(cx).focus(window, cx);
         }
         let action = action.boxed_clone();
         cx.defer(move |cx| {

crates/git_ui/src/worktree_picker.rs 🔗

@@ -1,4 +1,5 @@
 use anyhow::Context as _;
+use collections::HashSet;
 use fuzzy::StringMatchCandidate;
 
 use git::repository::Worktree as GitWorktree;
@@ -9,7 +10,11 @@ use gpui::{
     actions, rems,
 };
 use picker::{Picker, PickerDelegate, PickerEditorPosition};
-use project::{DirectoryLister, git_store::Repository};
+use project::{
+    DirectoryLister,
+    git_store::Repository,
+    trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
+};
 use recent_projects::{RemoteConnectionModal, connect};
 use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
 use std::{path::PathBuf, sync::Arc};
@@ -219,7 +224,6 @@ impl WorktreeListDelegate {
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) {
-        let workspace = self.workspace.clone();
         let Some(repo) = self.repo.clone() else {
             return;
         };
@@ -247,6 +251,7 @@ impl WorktreeListDelegate {
 
         let branch = worktree_branch.to_string();
         let window_handle = window.window_handle();
+        let workspace = self.workspace.clone();
         cx.spawn_in(window, async move |_, cx| {
             let Some(paths) = worktree_path.await? else {
                 return anyhow::Ok(());
@@ -257,8 +262,32 @@ impl WorktreeListDelegate {
                 repo.create_worktree(branch.clone(), path.clone(), commit)
             })?
             .await??;
-
-            let final_path = path.join(branch);
+            let new_worktree_path = path.join(branch);
+
+            workspace.update(cx, |workspace, cx| {
+                if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+                    let repo_path = &repo.read(cx).snapshot().work_directory_abs_path;
+                    let project = workspace.project();
+                    if let Some((parent_worktree, _)) =
+                        project.read(cx).find_worktree(repo_path, cx)
+                    {
+                        trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                            if trusted_worktrees.can_trust(parent_worktree.read(cx).id(), cx) {
+                                trusted_worktrees.trust(
+                                    HashSet::from_iter([PathTrust::AbsPath(
+                                        new_worktree_path.clone(),
+                                    )]),
+                                    project
+                                        .read(cx)
+                                        .remote_connection_options(cx)
+                                        .map(RemoteHostLocation::from),
+                                    cx,
+                                );
+                            }
+                        });
+                    }
+                }
+            })?;
 
             let (connection_options, app_state, is_local) =
                 workspace.update(cx, |workspace, cx| {
@@ -274,7 +303,7 @@ impl WorktreeListDelegate {
                     .update_in(cx, |workspace, window, cx| {
                         workspace.open_workspace_for_paths(
                             replace_current_window,
-                            vec![final_path],
+                            vec![new_worktree_path],
                             window,
                             cx,
                         )
@@ -283,7 +312,7 @@ impl WorktreeListDelegate {
             } else if let Some(connection_options) = connection_options {
                 open_remote_worktree(
                     connection_options,
-                    vec![final_path],
+                    vec![new_worktree_path],
                     app_state,
                     window_handle,
                     replace_current_window,
@@ -421,6 +450,7 @@ async fn open_remote_worktree(
             app_state.user_store.clone(),
             app_state.languages.clone(),
             app_state.fs.clone(),
+            true,
             cx,
         )
     })?;

crates/go_to_line/src/go_to_line.rs 🔗

@@ -268,7 +268,7 @@ impl GoToLine {
                 cx,
                 |s| s.select_anchor_ranges([start..start]),
             );
-            editor.focus_handle(cx).focus(window);
+            editor.focus_handle(cx).focus(window, cx);
             cx.notify()
         });
         self.prev_scroll_position.take();

crates/google_ai/src/google_ai.rs 🔗

@@ -512,6 +512,8 @@ pub enum Model {
     Gemini25Pro,
     #[serde(rename = "gemini-3-pro-preview")]
     Gemini3Pro,
+    #[serde(rename = "gemini-3-flash-preview")]
+    Gemini3Flash,
     #[serde(rename = "custom")]
     Custom {
         name: String,
@@ -534,6 +536,7 @@ impl Model {
             Self::Gemini25Flash => "gemini-2.5-flash",
             Self::Gemini25Pro => "gemini-2.5-pro",
             Self::Gemini3Pro => "gemini-3-pro-preview",
+            Self::Gemini3Flash => "gemini-3-flash-preview",
             Self::Custom { name, .. } => name,
         }
     }
@@ -543,6 +546,7 @@ impl Model {
             Self::Gemini25Flash => "gemini-2.5-flash",
             Self::Gemini25Pro => "gemini-2.5-pro",
             Self::Gemini3Pro => "gemini-3-pro-preview",
+            Self::Gemini3Flash => "gemini-3-flash-preview",
             Self::Custom { name, .. } => name,
         }
     }
@@ -553,6 +557,7 @@ impl Model {
             Self::Gemini25Flash => "Gemini 2.5 Flash",
             Self::Gemini25Pro => "Gemini 2.5 Pro",
             Self::Gemini3Pro => "Gemini 3 Pro",
+            Self::Gemini3Flash => "Gemini 3 Flash",
             Self::Custom {
                 name, display_name, ..
             } => display_name.as_ref().unwrap_or(name),
@@ -561,20 +566,22 @@ impl Model {
 
     pub fn max_token_count(&self) -> u64 {
         match self {
-            Self::Gemini25FlashLite => 1_048_576,
-            Self::Gemini25Flash => 1_048_576,
-            Self::Gemini25Pro => 1_048_576,
-            Self::Gemini3Pro => 1_048_576,
+            Self::Gemini25FlashLite
+            | Self::Gemini25Flash
+            | Self::Gemini25Pro
+            | Self::Gemini3Pro
+            | Self::Gemini3Flash => 1_048_576,
             Self::Custom { max_tokens, .. } => *max_tokens,
         }
     }
 
     pub fn max_output_tokens(&self) -> Option<u64> {
         match self {
-            Model::Gemini25FlashLite => Some(65_536),
-            Model::Gemini25Flash => Some(65_536),
-            Model::Gemini25Pro => Some(65_536),
-            Model::Gemini3Pro => Some(65_536),
+            Model::Gemini25FlashLite
+            | Model::Gemini25Flash
+            | Model::Gemini25Pro
+            | Model::Gemini3Pro
+            | Model::Gemini3Flash => Some(65_536),
             Model::Custom { .. } => None,
         }
     }
@@ -599,6 +606,7 @@ impl Model {
                     budget_tokens: None,
                 }
             }
+            Self::Gemini3Flash => GoogleModelMode::Default,
             Self::Custom { mode, .. } => *mode,
         }
     }

crates/gpui/Cargo.toml 🔗

@@ -198,14 +198,14 @@ wayland-backend = { version = "0.3.3", features = [
     "client_system",
     "dlopen",
 ], optional = true }
-wayland-client = { version = "0.31.2", optional = true }
-wayland-cursor = { version = "0.31.1", optional = true }
-wayland-protocols = { version = "0.31.2", features = [
+wayland-client = { version = "0.31.11", optional = true }
+wayland-cursor = { version = "0.31.11", optional = true }
+wayland-protocols = { version = "0.32.9", features = [
     "client",
     "staging",
     "unstable",
 ], optional = true }
-wayland-protocols-plasma = { version = "0.2.0", features = [
+wayland-protocols-plasma = { version = "0.3.9", features = [
     "client",
 ], optional = true }
 wayland-protocols-wlr = { version = "0.3.9", features = [

crates/gpui/examples/focus_visible.rs 🔗

@@ -29,7 +29,7 @@ impl Example {
         ];
 
         let focus_handle = cx.focus_handle();
-        window.focus(&focus_handle);
+        window.focus(&focus_handle, cx);
 
         Self {
             focus_handle,
@@ -40,13 +40,13 @@ impl Example {
         }
     }
 
-    fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
-        window.focus_next();
+    fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_next(cx);
         self.message = SharedString::from("Pressed Tab - focus-visible border should appear!");
     }
 
-    fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
-        window.focus_prev();
+    fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_prev(cx);
         self.message =
             SharedString::from("Pressed Shift-Tab - focus-visible border should appear!");
     }

crates/gpui/examples/input.rs 🔗

@@ -546,8 +546,15 @@ impl Element for TextElement {
             window.paint_quad(selection)
         }
         let line = prepaint.line.take().unwrap();
-        line.paint(bounds.origin, window.line_height(), window, cx)
-            .unwrap();
+        line.paint(
+            bounds.origin,
+            window.line_height(),
+            gpui::TextAlign::Left,
+            None,
+            window,
+            cx,
+        )
+        .unwrap();
 
         if focus_handle.is_focused(window)
             && let Some(cursor) = prepaint.cursor.take()
@@ -736,7 +743,7 @@ fn main() {
 
         window
             .update(cx, |view, window, cx| {
-                window.focus(&view.text_input.focus_handle(cx));
+                window.focus(&view.text_input.focus_handle(cx), cx);
                 cx.activate(true);
             })
             .unwrap();

crates/gpui/examples/on_window_close_quit.rs 🔗

@@ -55,7 +55,7 @@ fn main() {
                 cx.activate(false);
                 cx.new(|cx| {
                     let focus_handle = cx.focus_handle();
-                    focus_handle.focus(window);
+                    focus_handle.focus(window, cx);
                     ExampleWindow { focus_handle }
                 })
             },
@@ -72,7 +72,7 @@ fn main() {
             |window, cx| {
                 cx.new(|cx| {
                     let focus_handle = cx.focus_handle();
-                    focus_handle.focus(window);
+                    focus_handle.focus(window, cx);
                     ExampleWindow { focus_handle }
                 })
             },

crates/gpui/examples/popover.rs 🔗

@@ -0,0 +1,174 @@
+use gpui::{
+    App, Application, Context, Corner, Div, Hsla, Stateful, Window, WindowOptions, anchored,
+    deferred, div, prelude::*, px,
+};
+
+/// An example show use deferred to create a floating layers.
+struct HelloWorld {
+    open: bool,
+    secondary_open: bool,
+}
+
+fn button(id: &'static str) -> Stateful<Div> {
+    div()
+        .id(id)
+        .bg(gpui::black())
+        .text_color(gpui::white())
+        .px_3()
+        .py_1()
+}
+
+fn popover() -> Div {
+    div()
+        .flex()
+        .flex_col()
+        .items_center()
+        .justify_center()
+        .shadow_lg()
+        .p_3()
+        .rounded_md()
+        .bg(gpui::white())
+        .text_color(gpui::black())
+        .border_1()
+        .text_sm()
+        .border_color(gpui::black().opacity(0.1))
+}
+
+fn line(color: Hsla) -> Div {
+    div().w(px(480.)).h_2().bg(color.opacity(0.25))
+}
+
+impl HelloWorld {
+    fn render_secondary_popover(
+        &mut self,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        button("secondary-btn")
+            .mt_2()
+            .child("Child Popover")
+            .on_click(cx.listener(|this, _, _, cx| {
+                this.secondary_open = true;
+                cx.notify();
+            }))
+            .when(self.secondary_open, |this| {
+                this.child(
+                    // GPUI can't support deferred here yet,
+                    // it was inside another deferred element.
+                    anchored()
+                        .anchor(Corner::TopLeft)
+                        .snap_to_window_with_margin(px(8.))
+                        .child(
+                            popover()
+                                .child("This is second level Popover")
+                                .bg(gpui::white())
+                                .border_color(gpui::blue())
+                                .on_mouse_down_out(cx.listener(|this, _, _, cx| {
+                                    this.secondary_open = false;
+                                    cx.notify();
+                                })),
+                        ),
+                )
+            })
+    }
+}
+
+impl Render for HelloWorld {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .flex()
+            .flex_col()
+            .gap_3()
+            .size_full()
+            .bg(gpui::white())
+            .text_color(gpui::black())
+            .justify_center()
+            .items_center()
+            .child(
+                div()
+                    .flex()
+                    .flex_row()
+                    .gap_4()
+                    .child(
+                        button("popover0").child("Opened Popover").child(
+                            deferred(
+                                anchored()
+                                    .anchor(Corner::TopLeft)
+                                    .snap_to_window_with_margin(px(8.))
+                                    .child(popover().w_96().gap_3().child(
+                                        "This is a default opened Popover, \
+                                        we can use deferred to render it \
+                                        in a floating layer.",
+                                    )),
+                            )
+                            .priority(0),
+                        ),
+                    )
+                    .child(
+                        button("popover1")
+                            .child("Open Popover")
+                            .on_click(cx.listener(|this, _, _, cx| {
+                                this.open = true;
+                                cx.notify();
+                            }))
+                            .when(self.open, |this| {
+                                this.child(
+                                    deferred(
+                                        anchored()
+                                            .anchor(Corner::TopLeft)
+                                            .snap_to_window_with_margin(px(8.))
+                                            .child(
+                                                popover()
+                                                    .w_96()
+                                                    .gap_3()
+                                                    .child(
+                                                        "This is first level Popover, \
+                                                   we can use deferred to render it \
+                                                   in a floating layer.\n\
+                                                   Click outside to close.",
+                                                    )
+                                                    .when(!self.secondary_open, |this| {
+                                                        this.on_mouse_down_out(cx.listener(
+                                                            |this, _, _, cx| {
+                                                                this.open = false;
+                                                                cx.notify();
+                                                            },
+                                                        ))
+                                                    })
+                                                    // Here we need render popover after the content
+                                                    // to ensure it will be on top layer.
+                                                    .child(
+                                                        self.render_secondary_popover(window, cx),
+                                                    ),
+                                            ),
+                                    )
+                                    .priority(1),
+                                )
+                            }),
+                    ),
+            )
+            .child(
+                "Here is an example text rendered, \
+                to ensure the Popover will float above this contents.",
+            )
+            .children([
+                line(gpui::red()),
+                line(gpui::yellow()),
+                line(gpui::blue()),
+                line(gpui::green()),
+            ])
+    }
+}
+
+fn main() {
+    Application::new().run(|cx: &mut App| {
+        cx.open_window(WindowOptions::default(), |_, cx| {
+            cx.new(|_| HelloWorld {
+                open: false,
+                secondary_open: false,
+            })
+        })
+        .unwrap();
+        cx.activate(true);
+    });
+}

crates/gpui/examples/tab_stop.rs 🔗

@@ -22,7 +22,7 @@ impl Example {
         ];
 
         let focus_handle = cx.focus_handle();
-        window.focus(&focus_handle);
+        window.focus(&focus_handle, cx);
 
         Self {
             focus_handle,
@@ -31,13 +31,13 @@ impl Example {
         }
     }
 
-    fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
-        window.focus_next();
+    fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_next(cx);
         self.message = SharedString::from("You have pressed `Tab`.");
     }
 
-    fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
-        window.focus_prev();
+    fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_prev(cx);
         self.message = SharedString::from("You have pressed `Shift-Tab`.");
     }
 }
@@ -130,6 +130,50 @@ impl Render for Example {
                             })),
                     ),
             )
+            .child(
+                div()
+                    .id("group-1")
+                    .tab_index(6)
+                    .tab_group()
+                    .tab_stop(false)
+                    .child(
+                        button("group-1-button-1")
+                            .tab_index(1)
+                            .child("Tab index [6, 1]"),
+                    )
+                    .child(
+                        button("group-1-button-2")
+                            .tab_index(2)
+                            .child("Tab index [6, 2]"),
+                    )
+                    .child(
+                        button("group-1-button-3")
+                            .tab_index(3)
+                            .child("Tab index [6, 3]"),
+                    ),
+            )
+            .child(
+                div()
+                    .id("group-2")
+                    .tab_index(7)
+                    .tab_group()
+                    .tab_stop(false)
+                    .child(
+                        button("group-2-button-1")
+                            .tab_index(1)
+                            .child("Tab index [7, 1]"),
+                    )
+                    .child(
+                        button("group-2-button-2")
+                            .tab_index(2)
+                            .child("Tab index [7, 2]"),
+                    )
+                    .child(
+                        button("group-2-button-3")
+                            .tab_index(3)
+                            .child("Tab index [7, 3]"),
+                    ),
+            )
     }
 }
 

crates/gpui/examples/window.rs 🔗

@@ -5,6 +5,7 @@ use gpui::{
 
 struct SubWindow {
     custom_titlebar: bool,
+    is_dialog: bool,
 }
 
 fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> impl IntoElement {
@@ -23,7 +24,10 @@ fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> imp
 }
 
 impl Render for SubWindow {
-    fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let window_bounds =
+            WindowBounds::Windowed(Bounds::centered(None, size(px(250.0), px(200.0)), cx));
+
         div()
             .flex()
             .flex_col()
@@ -52,8 +56,28 @@ impl Render for SubWindow {
             .child(
                 div()
                     .p_8()
+                    .flex()
+                    .flex_col()
                     .gap_2()
                     .child("SubWindow")
+                    .when(self.is_dialog, |div| {
+                        div.child(button("Open Nested Dialog", move |_, cx| {
+                            cx.open_window(
+                                WindowOptions {
+                                    window_bounds: Some(window_bounds),
+                                    kind: WindowKind::Dialog,
+                                    ..Default::default()
+                                },
+                                |_, cx| {
+                                    cx.new(|_| SubWindow {
+                                        custom_titlebar: false,
+                                        is_dialog: true,
+                                    })
+                                },
+                            )
+                            .unwrap();
+                        }))
+                    })
                     .child(button("Close", |window, _| {
                         window.remove_window();
                     })),
@@ -86,6 +110,7 @@ impl Render for WindowDemo {
                     |_, cx| {
                         cx.new(|_| SubWindow {
                             custom_titlebar: false,
+                            is_dialog: false,
                         })
                     },
                 )
@@ -101,6 +126,39 @@ impl Render for WindowDemo {
                     |_, cx| {
                         cx.new(|_| SubWindow {
                             custom_titlebar: false,
+                            is_dialog: false,
+                        })
+                    },
+                )
+                .unwrap();
+            }))
+            .child(button("Floating", move |_, cx| {
+                cx.open_window(
+                    WindowOptions {
+                        window_bounds: Some(window_bounds),
+                        kind: WindowKind::Floating,
+                        ..Default::default()
+                    },
+                    |_, cx| {
+                        cx.new(|_| SubWindow {
+                            custom_titlebar: false,
+                            is_dialog: false,
+                        })
+                    },
+                )
+                .unwrap();
+            }))
+            .child(button("Dialog", move |_, cx| {
+                cx.open_window(
+                    WindowOptions {
+                        window_bounds: Some(window_bounds),
+                        kind: WindowKind::Dialog,
+                        ..Default::default()
+                    },
+                    |_, cx| {
+                        cx.new(|_| SubWindow {
+                            custom_titlebar: false,
+                            is_dialog: true,
                         })
                     },
                 )
@@ -116,6 +174,7 @@ impl Render for WindowDemo {
                     |_, cx| {
                         cx.new(|_| SubWindow {
                             custom_titlebar: true,
+                            is_dialog: false,
                         })
                     },
                 )
@@ -131,6 +190,7 @@ impl Render for WindowDemo {
                     |_, cx| {
                         cx.new(|_| SubWindow {
                             custom_titlebar: false,
+                            is_dialog: false,
                         })
                     },
                 )
@@ -147,6 +207,7 @@ impl Render for WindowDemo {
                     |_, cx| {
                         cx.new(|_| SubWindow {
                             custom_titlebar: false,
+                            is_dialog: false,
                         })
                     },
                 )
@@ -162,6 +223,7 @@ impl Render for WindowDemo {
                     |_, cx| {
                         cx.new(|_| SubWindow {
                             custom_titlebar: false,
+                            is_dialog: false,
                         })
                     },
                 )
@@ -177,6 +239,7 @@ impl Render for WindowDemo {
                     |_, cx| {
                         cx.new(|_| SubWindow {
                             custom_titlebar: false,
+                            is_dialog: false,
                         })
                     },
                 )

crates/gpui/src/app.rs 🔗

@@ -316,6 +316,7 @@ impl SystemWindowTabController {
             .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
 
         let current_group = current_group?;
+        // TODO: `.keys()` returns arbitrary order, what does "next" mean?
         let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
         let idx = group_ids.iter().position(|g| *g == current_group)?;
         let next_idx = (idx + 1) % group_ids.len();
@@ -340,6 +341,7 @@ impl SystemWindowTabController {
             .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
 
         let current_group = current_group?;
+        // TODO: `.keys()` returns arbitrary order, what does "previous" mean?
         let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
         let idx = group_ids.iter().position(|g| *g == current_group)?;
         let prev_idx = if idx == 0 {
@@ -361,12 +363,9 @@ impl SystemWindowTabController {
 
     /// Get all tabs in the same window.
     pub fn tabs(&self, id: WindowId) -> Option<&Vec<SystemWindowTab>> {
-        let tab_group = self
-            .tab_groups
-            .iter()
-            .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group))?;
-
-        self.tab_groups.get(&tab_group)
+        self.tab_groups
+            .values()
+            .find(|tabs| tabs.iter().any(|tab| tab.id == id))
     }
 
     /// Initialize the visibility of the system window tab controller.
@@ -441,7 +440,7 @@ impl SystemWindowTabController {
     /// Insert a tab into a tab group.
     pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec<SystemWindowTab>) {
         let mut controller = cx.global_mut::<SystemWindowTabController>();
-        let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else {
+        let Some(tab) = tabs.iter().find(|tab| tab.id == id).cloned() else {
             return;
         };
 
@@ -504,16 +503,14 @@ impl SystemWindowTabController {
             return;
         };
 
+        let initial_tabs_len = initial_tabs.len();
         let mut all_tabs = initial_tabs.clone();
-        for tabs in controller.tab_groups.values() {
-            all_tabs.extend(
-                tabs.iter()
-                    .filter(|tab| !initial_tabs.contains(tab))
-                    .cloned(),
-            );
+
+        for (_, mut tabs) in controller.tab_groups.drain() {
+            tabs.retain(|tab| !all_tabs[..initial_tabs_len].contains(tab));
+            all_tabs.extend(tabs);
         }
 
-        controller.tab_groups.clear();
         controller.tab_groups.insert(0, all_tabs);
     }
 
@@ -1080,11 +1077,9 @@ impl App {
         self.platform.window_appearance()
     }
 
-    /// Writes data to the primary selection buffer.
-    /// Only available on Linux.
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-    pub fn write_to_primary(&self, item: ClipboardItem) {
-        self.platform.write_to_primary(item)
+    /// Reads data from the platform clipboard.
+    pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        self.platform.read_from_clipboard()
     }
 
     /// Writes data to the platform clipboard.
@@ -1099,9 +1094,31 @@ impl App {
         self.platform.read_from_primary()
     }
 
-    /// Reads data from the platform clipboard.
-    pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
-        self.platform.read_from_clipboard()
+    /// Writes data to the primary selection buffer.
+    /// Only available on Linux.
+    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    pub fn write_to_primary(&self, item: ClipboardItem) {
+        self.platform.write_to_primary(item)
+    }
+
+    /// Reads data from macOS's "Find" pasteboard.
+    ///
+    /// Used to share the current search string between apps.
+    ///
+    /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
+    #[cfg(target_os = "macos")]
+    pub fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
+        self.platform.read_from_find_pasteboard()
+    }
+
+    /// Writes data to macOS's "Find" pasteboard.
+    ///
+    /// Used to share the current search string between apps.
+    ///
+    /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
+    #[cfg(target_os = "macos")]
+    pub fn write_to_find_pasteboard(&self, item: ClipboardItem) {
+        self.platform.write_to_find_pasteboard(item)
     }
 
     /// Writes credentials to the platform keychain.
@@ -1900,8 +1917,11 @@ impl App {
     pub(crate) fn clear_pending_keystrokes(&mut self) {
         for window in self.windows() {
             window
-                .update(self, |_, window, _| {
-                    window.clear_pending_keystrokes();
+                .update(self, |_, window, cx| {
+                    if window.pending_input_keystrokes().is_some() {
+                        window.clear_pending_keystrokes();
+                        window.pending_input_changed(cx);
+                    }
                 })
                 .ok();
         }

crates/gpui/src/app/async_context.rs 🔗

@@ -487,7 +487,7 @@ impl VisualContext for AsyncWindowContext {
         V: Focusable,
     {
         self.app.update_window(self.window, |_, window, cx| {
-            view.read(cx).focus_handle(cx).focus(window);
+            view.read(cx).focus_handle(cx).focus(window, cx);
         })
     }
 }

crates/gpui/src/app/context.rs 🔗

@@ -285,7 +285,7 @@ impl<'a, T: 'static> Context<'a, T> {
 
     /// Focus the given view in the given window. View type is required to implement Focusable.
     pub fn focus_view<W: Focusable>(&mut self, view: &Entity<W>, window: &mut Window) {
-        window.focus(&view.focus_handle(self));
+        window.focus(&view.focus_handle(self), self);
     }
 
     /// Sets a given callback to be run on the next frame.
@@ -732,7 +732,7 @@ impl<'a, T: 'static> Context<'a, T> {
     {
         let view = self.entity();
         window.defer(self, move |window, cx| {
-            view.read(cx).focus_handle(cx).focus(window)
+            view.read(cx).focus_handle(cx).focus(window, cx)
         })
     }
 }

crates/gpui/src/app/test_context.rs 🔗

@@ -1045,7 +1045,7 @@ impl VisualContext for VisualTestContext {
     fn focus<V: crate::Focusable>(&mut self, view: &Entity<V>) -> Self::Result<()> {
         self.window
             .update(&mut self.cx, |_, window, cx| {
-                view.read(cx).focus_handle(cx).focus(window)
+                view.read(cx).focus_handle(cx).focus(window, cx)
             })
             .unwrap()
     }

crates/gpui/src/elements/div.rs 🔗

@@ -654,7 +654,7 @@ pub trait InteractiveElement: Sized {
     /// Set whether this element is a tab stop.
     ///
     /// When false, the element remains in tab-index order but cannot be reached via keyboard navigation.
-    /// Useful for container elements: focus the container, then call `window.focus_next()` to focus
+    /// Useful for container elements: focus the container, then call `window.focus_next(cx)` to focus
     /// the first tab stop inside it while having the container element itself be unreachable via the keyboard.
     /// Should only be used with `tab_index`.
     fn tab_stop(mut self, tab_stop: bool) -> Self {
@@ -1730,6 +1730,11 @@ impl Interactivity {
                         let clicked_state = clicked_state.borrow();
                         self.active = Some(clicked_state.element);
                     }
+                    if self.hover_style.is_some() || self.group_hover_style.is_some() {
+                        element_state
+                            .hover_state
+                            .get_or_insert_with(Default::default);
+                    }
                     if let Some(active_tooltip) = element_state.active_tooltip.as_ref() {
                         if self.tooltip_builder.is_some() {
                             self.tooltip_id = set_tooltip_on_window(active_tooltip, window);
@@ -2096,12 +2101,12 @@ impl Interactivity {
         // This behavior can be suppressed by using `cx.prevent_default()`.
         if let Some(focus_handle) = self.tracked_focus_handle.clone() {
             let hitbox = hitbox.clone();
-            window.on_mouse_event(move |_: &MouseDownEvent, phase, window, _| {
+            window.on_mouse_event(move |_: &MouseDownEvent, phase, window, cx| {
                 if phase == DispatchPhase::Bubble
                     && hitbox.is_hovered(window)
                     && !window.default_prevented()
                 {
-                    window.focus(&focus_handle);
+                    window.focus(&focus_handle, cx);
                     // If there is a parent that is also focusable, prevent it
                     // from transferring focus because we already did so.
                     window.prevent_default();
@@ -2150,14 +2155,46 @@ impl Interactivity {
         {
             let hitbox = hitbox.clone();
             let was_hovered = hitbox.is_hovered(window);
+            let hover_state = self.hover_style.as_ref().and_then(|_| {
+                element_state
+                    .as_ref()
+                    .and_then(|state| state.hover_state.as_ref())
+                    .cloned()
+            });
             let current_view = window.current_view();
             window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
                 let hovered = hitbox.is_hovered(window);
                 if phase == DispatchPhase::Capture && hovered != was_hovered {
+                    if let Some(hover_state) = &hover_state {
+                        hover_state.borrow_mut().element = hovered;
+                    }
                     cx.notify(current_view);
                 }
             });
         }
+
+        if let Some(group_hover) = self.group_hover_style.as_ref() {
+            if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) {
+                let hover_state = element_state
+                    .as_ref()
+                    .and_then(|element| element.hover_state.as_ref())
+                    .cloned();
+
+                let was_group_hovered = group_hitbox_id.is_hovered(window);
+                let current_view = window.current_view();
+
+                window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
+                    let group_hovered = group_hitbox_id.is_hovered(window);
+                    if phase == DispatchPhase::Capture && group_hovered != was_group_hovered {
+                        if let Some(hover_state) = &hover_state {
+                            hover_state.borrow_mut().group = group_hovered;
+                        }
+                        cx.notify(current_view);
+                    }
+                });
+            }
+        }
+
         let drag_cursor_style = self.base_style.as_ref().mouse_cursor;
 
         let mut drag_listener = mem::take(&mut self.drag_listener);
@@ -2346,8 +2383,8 @@ impl Interactivity {
                         && hitbox.is_hovered(window);
                     let mut was_hovered = was_hovered.borrow_mut();
 
-                    if is_hovered != *was_hovered {
-                        *was_hovered = is_hovered;
+                    if is_hovered != was_hovered.element {
+                        was_hovered.element = is_hovered;
                         drop(was_hovered);
 
                         hover_listener(&is_hovered, window, cx);
@@ -2580,22 +2617,46 @@ impl Interactivity {
             }
         }
 
-        if let Some(hitbox) = hitbox {
-            if !cx.has_active_drag() {
-                if let Some(group_hover) = self.group_hover_style.as_ref()
-                    && let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx)
-                    && group_hitbox_id.is_hovered(window)
-                {
+        if !cx.has_active_drag() {
+            if let Some(group_hover) = self.group_hover_style.as_ref() {
+                let is_group_hovered =
+                    if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) {
+                        group_hitbox_id.is_hovered(window)
+                    } else if let Some(element_state) = element_state.as_ref() {
+                        element_state
+                            .hover_state
+                            .as_ref()
+                            .map(|state| state.borrow().group)
+                            .unwrap_or(false)
+                    } else {
+                        false
+                    };
+
+                if is_group_hovered {
                     style.refine(&group_hover.style);
                 }
+            }
 
-                if let Some(hover_style) = self.hover_style.as_ref()
-                    && hitbox.is_hovered(window)
-                {
+            if let Some(hover_style) = self.hover_style.as_ref() {
+                let is_hovered = if let Some(hitbox) = hitbox {
+                    hitbox.is_hovered(window)
+                } else if let Some(element_state) = element_state.as_ref() {
+                    element_state
+                        .hover_state
+                        .as_ref()
+                        .map(|state| state.borrow().element)
+                        .unwrap_or(false)
+                } else {
+                    false
+                };
+
+                if is_hovered {
                     style.refine(hover_style);
                 }
             }
+        }
 
+        if let Some(hitbox) = hitbox {
             if let Some(drag) = cx.active_drag.take() {
                 let mut can_drop = true;
                 if let Some(can_drop_predicate) = &self.can_drop_predicate {
@@ -2654,7 +2715,7 @@ impl Interactivity {
 pub struct InteractiveElementState {
     pub(crate) focus_handle: Option<FocusHandle>,
     pub(crate) clicked_state: Option<Rc<RefCell<ElementClickedState>>>,
-    pub(crate) hover_state: Option<Rc<RefCell<bool>>>,
+    pub(crate) hover_state: Option<Rc<RefCell<ElementHoverState>>>,
     pub(crate) pending_mouse_down: Option<Rc<RefCell<Option<MouseDownEvent>>>>,
     pub(crate) scroll_offset: Option<Rc<RefCell<Point<Pixels>>>>,
     pub(crate) active_tooltip: Option<Rc<RefCell<Option<ActiveTooltip>>>>,
@@ -2676,6 +2737,16 @@ impl ElementClickedState {
     }
 }
 
+/// Whether or not the element or a group that contains it is hovered.
+#[derive(Copy, Clone, Default, Eq, PartialEq)]
+pub struct ElementHoverState {
+    /// True if this element's group is hovered, false otherwise
+    pub group: bool,
+
+    /// True if this element is hovered, false otherwise
+    pub element: bool,
+}
+
 pub(crate) enum ActiveTooltip {
     /// Currently delaying before showing the tooltip.
     WaitingForShow { _task: Task<()> },

crates/gpui/src/elements/surface.rs 🔗

@@ -29,6 +29,7 @@ pub struct Surface {
 }
 
 /// Create a new surface element.
+#[cfg(target_os = "macos")]
 pub fn surface(source: impl Into<SurfaceSource>) -> Surface {
     Surface {
         source: source.into(),

crates/gpui/src/elements/text.rs 🔗

@@ -2,10 +2,11 @@ use crate::{
     ActiveTooltip, AnyView, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId,
     HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId,
     MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextOverflow,
-    TextRun, TextStyle, TooltipId, WhiteSpace, Window, WrappedLine, WrappedLineLayout,
-    register_tooltip_mouse_handlers, set_tooltip_on_window,
+    TextRun, TextStyle, TooltipId, TruncateFrom, WhiteSpace, Window, WrappedLine,
+    WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window,
 };
 use anyhow::Context as _;
+use itertools::Itertools;
 use smallvec::SmallVec;
 use std::{
     borrow::Cow,
@@ -353,7 +354,7 @@ impl TextLayout {
                     None
                 };
 
-                let (truncate_width, truncation_suffix) =
+                let (truncate_width, truncation_affix, truncate_from) =
                     if let Some(text_overflow) = text_style.text_overflow.clone() {
                         let width = known_dimensions.width.or(match available_space.width {
                             crate::AvailableSpace::Definite(x) => match text_style.line_clamp {
@@ -364,17 +365,24 @@ impl TextLayout {
                         });
 
                         match text_overflow {
-                            TextOverflow::Truncate(s) => (width, s),
+                            TextOverflow::Truncate(s) => (width, s, TruncateFrom::End),
+                            TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start),
                         }
                     } else {
-                        (None, "".into())
+                        (None, "".into(), TruncateFrom::End)
                     };
 
+                // Only use cached layout if:
+                // 1. We have a cached size
+                // 2. wrap_width matches (or both are None)
+                // 3. truncate_width is None (if truncate_width is Some, we need to re-layout
+                //    because the previous layout may have been computed without truncation)
                 if let Some(text_layout) = element_state.0.borrow().as_ref()
-                    && text_layout.size.is_some()
+                    && let Some(size) = text_layout.size
                     && (wrap_width.is_none() || wrap_width == text_layout.wrap_width)
+                    && truncate_width.is_none()
                 {
-                    return text_layout.size.unwrap();
+                    return size;
                 }
 
                 let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
@@ -382,8 +390,9 @@ impl TextLayout {
                     line_wrapper.truncate_line(
                         text.clone(),
                         truncate_width,
-                        &truncation_suffix,
+                        &truncation_affix,
                         &runs,
+                        truncate_from,
                     )
                 } else {
                     (text.clone(), Cow::Borrowed(&*runs))
@@ -597,14 +606,14 @@ impl TextLayout {
             .unwrap()
             .lines
             .iter()
-            .map(|s| s.text.to_string())
-            .collect::<Vec<_>>()
+            .map(|s| &s.text)
             .join("\n")
     }
 
     /// The text for this layout (with soft-wraps as newlines)
     pub fn wrapped_text(&self) -> String {
-        let mut lines = Vec::new();
+        let mut accumulator = String::new();
+
         for wrapped in self.0.borrow().as_ref().unwrap().lines.iter() {
             let mut seen = 0;
             for boundary in wrapped.layout.wrap_boundaries.iter() {
@@ -612,13 +621,16 @@ impl TextLayout {
                     [boundary.glyph_ix]
                     .index;
 
-                lines.push(wrapped.text[seen..index].to_string());
+                accumulator.push_str(&wrapped.text[seen..index]);
+                accumulator.push('\n');
                 seen = index;
             }
-            lines.push(wrapped.text[seen..].to_string());
+            accumulator.push_str(&wrapped.text[seen..]);
+            accumulator.push('\n');
         }
-
-        lines.join("\n")
+        // Remove trailing newline
+        accumulator.pop();
+        accumulator
     }
 }
 

crates/gpui/src/elements/uniform_list.rs 🔗

@@ -712,8 +712,8 @@ mod test {
     #[gpui::test]
     fn test_scroll_strategy_nearest(cx: &mut TestAppContext) {
         use crate::{
-            Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, actions, div,
-            prelude::*, px, uniform_list,
+            Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, div, prelude::*,
+            px, uniform_list,
         };
         use std::ops::Range;
 
@@ -788,7 +788,7 @@ mod test {
 
         let (view, cx) = cx.add_window_view(|window, cx| {
             let focus_handle = cx.focus_handle();
-            window.focus(&focus_handle);
+            window.focus(&focus_handle, cx);
             TestView {
                 scroll_handle: UniformListScrollHandle::new(),
                 index: 0,

crates/gpui/src/executor.rs 🔗

@@ -290,9 +290,19 @@ impl BackgroundExecutor {
         &self,
         future: AnyFuture<R>,
         label: Option<TaskLabel>,
+        #[cfg_attr(
+            target_os = "windows",
+            expect(
+                unused_variables,
+                reason = "Multi priority scheduler is broken on windows"
+            )
+        )]
         priority: Priority,
     ) -> Task<R> {
         let dispatcher = self.dispatcher.clone();
+        #[cfg(target_os = "windows")]
+        let priority = Priority::Medium; // multi-prio scheduler is broken on windows
+
         let (runnable, task) = if let Priority::Realtime(realtime) = priority {
             let location = core::panic::Location::caller();
             let (mut tx, rx) = flume::bounded::<Runnable<RunnableMeta>>(1);

crates/gpui/src/gpui.rs 🔗

@@ -31,7 +31,7 @@ mod path_builder;
 mod platform;
 pub mod prelude;
 mod profiler;
-#[cfg(any(target_os = "windows", target_os = "linux"))]
+#[cfg(target_os = "linux")]
 mod queue;
 mod scene;
 mod shared_string;
@@ -91,7 +91,7 @@ pub use keymap::*;
 pub use path_builder::*;
 pub use platform::*;
 pub use profiler::*;
-#[cfg(any(target_os = "windows", target_os = "linux"))]
+#[cfg(target_os = "linux")]
 pub(crate) use queue::{PriorityQueueReceiver, PriorityQueueSender};
 pub use refineable::*;
 pub use scene::*;

crates/gpui/src/interactive.rs 🔗

@@ -705,8 +705,8 @@ mod test {
         });
 
         window
-            .update(cx, |test_view, window, _cx| {
-                window.focus(&test_view.focus_handle)
+            .update(cx, |test_view, window, cx| {
+                window.focus(&test_view.focus_handle, cx)
             })
             .unwrap();
 

crates/gpui/src/key_dispatch.rs 🔗

@@ -462,6 +462,17 @@ impl DispatchTree {
         (bindings, partial, context_stack)
     }
 
+    /// Find the bindings that can follow the current input sequence.
+    pub fn possible_next_bindings_for_input(
+        &self,
+        input: &[Keystroke],
+        context_stack: &[KeyContext],
+    ) -> Vec<KeyBinding> {
+        self.keymap
+            .borrow()
+            .possible_next_bindings_for_input(input, context_stack)
+    }
+
     /// dispatch_key processes the keystroke
     /// input should be set to the value of `pending` from the previous call to dispatch_key.
     /// This returns three instructions to the input handler:
@@ -610,8 +621,8 @@ impl DispatchTree {
 #[cfg(test)]
 mod tests {
     use crate::{
-        self as gpui, DispatchResult, Element, ElementId, GlobalElementId, InspectorElementId,
-        Keystroke, LayoutId, Style,
+        self as gpui, AppContext, DispatchResult, Element, ElementId, GlobalElementId,
+        InspectorElementId, Keystroke, LayoutId, Style,
     };
     use core::panic;
     use smallvec::SmallVec;
@@ -619,8 +630,8 @@ mod tests {
 
     use crate::{
         Action, ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler,
-        IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, TestAppContext,
-        UTF16Selection, Window,
+        IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, Subscription,
+        TestAppContext, UTF16Selection, Window,
     };
 
     #[derive(PartialEq, Eq)]
@@ -723,6 +734,213 @@ mod tests {
         assert!(!result.pending_has_binding);
     }
 
+    #[crate::test]
+    fn test_pending_input_observers_notified_on_focus_change(cx: &mut TestAppContext) {
+        #[derive(Clone)]
+        struct CustomElement {
+            focus_handle: FocusHandle,
+            text: Rc<RefCell<String>>,
+        }
+
+        impl CustomElement {
+            fn new(cx: &mut Context<Self>) -> Self {
+                Self {
+                    focus_handle: cx.focus_handle(),
+                    text: Rc::default(),
+                }
+            }
+        }
+
+        impl Element for CustomElement {
+            type RequestLayoutState = ();
+
+            type PrepaintState = ();
+
+            fn id(&self) -> Option<ElementId> {
+                Some("custom".into())
+            }
+
+            fn source_location(&self) -> Option<&'static panic::Location<'static>> {
+                None
+            }
+
+            fn request_layout(
+                &mut self,
+                _: Option<&GlobalElementId>,
+                _: Option<&InspectorElementId>,
+                window: &mut Window,
+                cx: &mut App,
+            ) -> (LayoutId, Self::RequestLayoutState) {
+                (window.request_layout(Style::default(), [], cx), ())
+            }
+
+            fn prepaint(
+                &mut self,
+                _: Option<&GlobalElementId>,
+                _: Option<&InspectorElementId>,
+                _: Bounds<Pixels>,
+                _: &mut Self::RequestLayoutState,
+                window: &mut Window,
+                cx: &mut App,
+            ) -> Self::PrepaintState {
+                window.set_focus_handle(&self.focus_handle, cx);
+            }
+
+            fn paint(
+                &mut self,
+                _: Option<&GlobalElementId>,
+                _: Option<&InspectorElementId>,
+                _: Bounds<Pixels>,
+                _: &mut Self::RequestLayoutState,
+                _: &mut Self::PrepaintState,
+                window: &mut Window,
+                cx: &mut App,
+            ) {
+                let mut key_context = KeyContext::default();
+                key_context.add("Terminal");
+                window.set_key_context(key_context);
+                window.handle_input(&self.focus_handle, self.clone(), cx);
+                window.on_action(std::any::TypeId::of::<TestAction>(), |_, _, _, _| {});
+            }
+        }
+
+        impl IntoElement for CustomElement {
+            type Element = Self;
+
+            fn into_element(self) -> Self::Element {
+                self
+            }
+        }
+
+        impl InputHandler for CustomElement {
+            fn selected_text_range(
+                &mut self,
+                _: bool,
+                _: &mut Window,
+                _: &mut App,
+            ) -> Option<UTF16Selection> {
+                None
+            }
+
+            fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option<Range<usize>> {
+                None
+            }
+
+            fn text_for_range(
+                &mut self,
+                _: Range<usize>,
+                _: &mut Option<Range<usize>>,
+                _: &mut Window,
+                _: &mut App,
+            ) -> Option<String> {
+                None
+            }
+
+            fn replace_text_in_range(
+                &mut self,
+                replacement_range: Option<Range<usize>>,
+                text: &str,
+                _: &mut Window,
+                _: &mut App,
+            ) {
+                if replacement_range.is_some() {
+                    unimplemented!()
+                }
+                self.text.borrow_mut().push_str(text)
+            }
+
+            fn replace_and_mark_text_in_range(
+                &mut self,
+                replacement_range: Option<Range<usize>>,
+                new_text: &str,
+                _: Option<Range<usize>>,
+                _: &mut Window,
+                _: &mut App,
+            ) {
+                if replacement_range.is_some() {
+                    unimplemented!()
+                }
+                self.text.borrow_mut().push_str(new_text)
+            }
+
+            fn unmark_text(&mut self, _: &mut Window, _: &mut App) {}
+
+            fn bounds_for_range(
+                &mut self,
+                _: Range<usize>,
+                _: &mut Window,
+                _: &mut App,
+            ) -> Option<Bounds<Pixels>> {
+                None
+            }
+
+            fn character_index_for_point(
+                &mut self,
+                _: Point<Pixels>,
+                _: &mut Window,
+                _: &mut App,
+            ) -> Option<usize> {
+                None
+            }
+        }
+
+        impl Render for CustomElement {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                self.clone()
+            }
+        }
+
+        cx.update(|cx| {
+            cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]);
+            cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]);
+        });
+
+        let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx));
+        let focus_handle = test.update(cx, |test, _| test.focus_handle.clone());
+
+        let pending_input_changed_count = Rc::new(RefCell::new(0usize));
+        let pending_input_changed_count_for_observer = pending_input_changed_count.clone();
+
+        struct PendingInputObserver {
+            _subscription: Subscription,
+        }
+
+        let _observer = cx.update(|window, cx| {
+            cx.new(|cx| PendingInputObserver {
+                _subscription: cx.observe_pending_input(window, move |_, _, _| {
+                    *pending_input_changed_count_for_observer.borrow_mut() += 1;
+                }),
+            })
+        });
+
+        cx.update(|window, cx| {
+            window.focus(&focus_handle, cx);
+            window.activate_window();
+        });
+
+        cx.simulate_keystrokes("ctrl-b");
+
+        let count_after_pending = Rc::new(RefCell::new(0usize));
+        let count_after_pending_for_assertion = count_after_pending.clone();
+
+        cx.update(|window, cx| {
+            assert!(window.has_pending_keystrokes());
+            *count_after_pending.borrow_mut() = *pending_input_changed_count.borrow();
+            assert!(*count_after_pending.borrow() > 0);
+
+            window.focus(&cx.focus_handle(), cx);
+
+            assert!(!window.has_pending_keystrokes());
+        });
+
+        // Focus-triggered pending-input notifications are deferred to the end of the current
+        // effect cycle, so the observer callback should run after the focus update completes.
+        cx.update(|_, _| {
+            let count_after_focus_change = *pending_input_changed_count.borrow();
+            assert!(count_after_focus_change > *count_after_pending_for_assertion.borrow());
+        });
+    }
+
     #[crate::test]
     fn test_input_handler_pending(cx: &mut TestAppContext) {
         #[derive(Clone)]
@@ -876,8 +1094,9 @@ mod tests {
             cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]);
         });
         let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx));
+        let focus_handle = test.update(cx, |test, _| test.focus_handle.clone());
         cx.update(|window, cx| {
-            window.focus(&test.read(cx).focus_handle);
+            window.focus(&focus_handle, cx);
             window.activate_window();
         });
         cx.simulate_keystrokes("ctrl-b [");

crates/gpui/src/keymap.rs 🔗

@@ -215,6 +215,41 @@ impl Keymap {
             Some(contexts.len())
         }
     }
+
+    /// Find the bindings that can follow the current input sequence.
+    pub fn possible_next_bindings_for_input(
+        &self,
+        input: &[Keystroke],
+        context_stack: &[KeyContext],
+    ) -> Vec<KeyBinding> {
+        let mut bindings = self
+            .bindings()
+            .enumerate()
+            .rev()
+            .filter_map(|(ix, binding)| {
+                let depth = self.binding_enabled(binding, context_stack)?;
+                let pending = binding.match_keystrokes(input);
+                match pending {
+                    None => None,
+                    Some(is_pending) => {
+                        if !is_pending || is_no_action(&*binding.action) {
+                            return None;
+                        }
+                        Some((depth, BindingIndex(ix), binding))
+                    }
+                }
+            })
+            .collect::<Vec<_>>();
+
+        bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| {
+            depth_b.cmp(depth_a).then(ix_b.cmp(ix_a))
+        });
+
+        bindings
+            .into_iter()
+            .map(|(_, _, binding)| binding.clone())
+            .collect::<Vec<_>>()
+    }
 }
 
 #[cfg(test)]

crates/gpui/src/platform.rs 🔗

@@ -262,12 +262,18 @@ pub(crate) trait Platform: 'static {
     fn set_cursor_style(&self, style: CursorStyle);
     fn should_auto_hide_scrollbars(&self) -> bool;
 
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-    fn write_to_primary(&self, item: ClipboardItem);
+    fn read_from_clipboard(&self) -> Option<ClipboardItem>;
     fn write_to_clipboard(&self, item: ClipboardItem);
+
     #[cfg(any(target_os = "linux", target_os = "freebsd"))]
     fn read_from_primary(&self) -> Option<ClipboardItem>;
-    fn read_from_clipboard(&self) -> Option<ClipboardItem>;
+    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    fn write_to_primary(&self, item: ClipboardItem);
+
+    #[cfg(target_os = "macos")]
+    fn read_from_find_pasteboard(&self) -> Option<ClipboardItem>;
+    #[cfg(target_os = "macos")]
+    fn write_to_find_pasteboard(&self, item: ClipboardItem);
 
     fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>>;
     fn read_credentials(&self, url: &str) -> Task<Result<Option<(String, Vec<u8>)>>>;
@@ -1348,6 +1354,10 @@ pub enum WindowKind {
     /// docks, notifications or wallpapers.
     #[cfg(all(target_os = "linux", feature = "wayland"))]
     LayerShell(layer_shell::LayerShellOptions),
+
+    /// A window that appears on top of its parent window and blocks interaction with it
+    /// until the modal window is closed
+    Dialog,
 }
 
 /// The appearance of the window, as defined by the operating system.

crates/gpui/src/platform/linux/wayland/client.rs 🔗

@@ -36,12 +36,6 @@ use wayland_client::{
         wl_shm_pool, wl_surface,
     },
 };
-use wayland_protocols::wp::cursor_shape::v1::client::{
-    wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1,
-};
-use wayland_protocols::wp::fractional_scale::v1::client::{
-    wp_fractional_scale_manager_v1, wp_fractional_scale_v1,
-};
 use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{
     self, ZwpPrimarySelectionOfferV1,
 };
@@ -61,6 +55,14 @@ use wayland_protocols::xdg::decoration::zv1::client::{
     zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
 };
 use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
+use wayland_protocols::{
+    wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1},
+    xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1},
+};
+use wayland_protocols::{
+    wp::fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1},
+    xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1,
+};
 use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager};
 use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1};
 use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1;
@@ -122,6 +124,7 @@ pub struct Globals {
     pub layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
     pub blur_manager: Option<org_kde_kwin_blur_manager::OrgKdeKwinBlurManager>,
     pub text_input_manager: Option<zwp_text_input_manager_v3::ZwpTextInputManagerV3>,
+    pub dialog: Option<xdg_wm_dialog_v1::XdgWmDialogV1>,
     pub executor: ForegroundExecutor,
 }
 
@@ -132,6 +135,7 @@ impl Globals {
         qh: QueueHandle<WaylandClientStatePtr>,
         seat: wl_seat::WlSeat,
     ) -> Self {
+        let dialog_v = XdgWmDialogV1::interface().version;
         Globals {
             activation: globals.bind(&qh, 1..=1, ()).ok(),
             compositor: globals
@@ -160,6 +164,7 @@ impl Globals {
             layer_shell: globals.bind(&qh, 1..=5, ()).ok(),
             blur_manager: globals.bind(&qh, 1..=1, ()).ok(),
             text_input_manager: globals.bind(&qh, 1..=1, ()).ok(),
+            dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(),
             executor,
             qh,
         }
@@ -729,10 +734,7 @@ impl LinuxClient for WaylandClient {
     ) -> anyhow::Result<Box<dyn PlatformWindow>> {
         let mut state = self.0.borrow_mut();
 
-        let parent = state
-            .keyboard_focused_window
-            .as_ref()
-            .and_then(|w| w.toplevel());
+        let parent = state.keyboard_focused_window.clone();
 
         let (window, surface_id) = WaylandWindow::new(
             handle,
@@ -751,7 +753,12 @@ impl LinuxClient for WaylandClient {
     fn set_cursor_style(&self, style: CursorStyle) {
         let mut state = self.0.borrow_mut();
 
-        let need_update = state.cursor_style != Some(style);
+        let need_update = state.cursor_style != Some(style)
+            && (state.mouse_focused_window.is_none()
+                || state
+                    .mouse_focused_window
+                    .as_ref()
+                    .is_some_and(|w| !w.is_blocked()));
 
         if need_update {
             let serial = state.serial_tracker.get(SerialKind::MouseEnter);
@@ -1011,7 +1018,7 @@ impl Dispatch<WlCallback, ObjectId> for WaylandClientStatePtr {
     }
 }
 
-fn get_window(
+pub(crate) fn get_window(
     mut state: &mut RefMut<WaylandClientState>,
     surface_id: &ObjectId,
 ) -> Option<WaylandWindowStatePtr> {
@@ -1654,6 +1661,30 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
                 state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32)));
 
                 if let Some(window) = state.mouse_focused_window.clone() {
+                    if window.is_blocked() {
+                        let default_style = CursorStyle::Arrow;
+                        if state.cursor_style != Some(default_style) {
+                            let serial = state.serial_tracker.get(SerialKind::MouseEnter);
+                            state.cursor_style = Some(default_style);
+
+                            if let Some(cursor_shape_device) = &state.cursor_shape_device {
+                                cursor_shape_device.set_shape(serial, default_style.to_shape());
+                            } else {
+                                // cursor-shape-v1 isn't supported, set the cursor using a surface.
+                                let wl_pointer = state
+                                    .wl_pointer
+                                    .clone()
+                                    .expect("window is focused by pointer");
+                                let scale = window.primary_output_scale();
+                                state.cursor.set_icon(
+                                    &wl_pointer,
+                                    serial,
+                                    default_style.to_icon_names(),
+                                    scale,
+                                );
+                            }
+                        }
+                    }
                     if state
                         .keyboard_focused_window
                         .as_ref()
@@ -2225,3 +2256,27 @@ impl Dispatch<zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1, ()>
         }
     }
 }
+
+impl Dispatch<XdgWmDialogV1, ()> for WaylandClientStatePtr {
+    fn event(
+        _: &mut Self,
+        _: &XdgWmDialogV1,
+        _: <XdgWmDialogV1 as Proxy>::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+impl Dispatch<XdgDialogV1, ()> for WaylandClientStatePtr {
+    fn event(
+        _state: &mut Self,
+        _proxy: &XdgDialogV1,
+        _event: <XdgDialogV1 as Proxy>::Event,
+        _data: &(),
+        _conn: &Connection,
+        _qhandle: &QueueHandle<Self>,
+    ) {
+    }
+}

crates/gpui/src/platform/linux/wayland/window.rs 🔗

@@ -7,7 +7,7 @@ use std::{
 };
 
 use blade_graphics as gpu;
-use collections::HashMap;
+use collections::{FxHashSet, HashMap};
 use futures::channel::oneshot::Receiver;
 
 use raw_window_handle as rwh;
@@ -20,7 +20,7 @@ use wayland_protocols::xdg::shell::client::xdg_surface;
 use wayland_protocols::xdg::shell::client::xdg_toplevel::{self};
 use wayland_protocols::{
     wp::fractional_scale::v1::client::wp_fractional_scale_v1,
-    xdg::shell::client::xdg_toplevel::XdgToplevel,
+    xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1,
 };
 use wayland_protocols_plasma::blur::client::org_kde_kwin_blur;
 use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1;
@@ -29,7 +29,7 @@ use crate::{
     AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels,
     PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions,
     ResizeEdge, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance,
-    WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams,
+    WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, get_window,
     layer_shell::LayerShellNotSupportedError, px, size,
 };
 use crate::{
@@ -87,6 +87,8 @@ struct InProgressConfigure {
 pub struct WaylandWindowState {
     surface_state: WaylandSurfaceState,
     acknowledged_first_configure: bool,
+    parent: Option<WaylandWindowStatePtr>,
+    children: FxHashSet<ObjectId>,
     pub surface: wl_surface::WlSurface,
     app_id: Option<String>,
     appearance: WindowAppearance,
@@ -126,7 +128,7 @@ impl WaylandSurfaceState {
         surface: &wl_surface::WlSurface,
         globals: &Globals,
         params: &WindowParams,
-        parent: Option<XdgToplevel>,
+        parent: Option<WaylandWindowStatePtr>,
     ) -> anyhow::Result<Self> {
         // For layer_shell windows, create a layer surface instead of an xdg surface
         if let WindowKind::LayerShell(options) = &params.kind {
@@ -178,10 +180,28 @@ impl WaylandSurfaceState {
             .get_xdg_surface(&surface, &globals.qh, surface.id());
 
         let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id());
-        if params.kind == WindowKind::Floating {
-            toplevel.set_parent(parent.as_ref());
+        let xdg_parent = parent.as_ref().and_then(|w| w.toplevel());
+
+        if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog {
+            toplevel.set_parent(xdg_parent.as_ref());
         }
 
+        let dialog = if params.kind == WindowKind::Dialog {
+            let dialog = globals.dialog.as_ref().map(|dialog| {
+                let xdg_dialog = dialog.get_xdg_dialog(&toplevel, &globals.qh, ());
+                xdg_dialog.set_modal();
+                xdg_dialog
+            });
+
+            if let Some(parent) = parent.as_ref() {
+                parent.add_child(surface.id());
+            }
+
+            dialog
+        } else {
+            None
+        };
+
         if let Some(size) = params.window_min_size {
             toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32);
         }
@@ -198,6 +218,7 @@ impl WaylandSurfaceState {
             xdg_surface,
             toplevel,
             decoration,
+            dialog,
         }))
     }
 }
@@ -206,6 +227,7 @@ pub struct WaylandXdgSurfaceState {
     xdg_surface: xdg_surface::XdgSurface,
     toplevel: xdg_toplevel::XdgToplevel,
     decoration: Option<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1>,
+    dialog: Option<XdgDialogV1>,
 }
 
 pub struct WaylandLayerSurfaceState {
@@ -258,7 +280,13 @@ impl WaylandSurfaceState {
                 xdg_surface,
                 toplevel,
                 decoration: _decoration,
+                dialog,
             }) => {
+                // drop the dialog before toplevel so compositor can explicitly unapply it's effects
+                if let Some(dialog) = dialog {
+                    dialog.destroy();
+                }
+
                 // The role object (toplevel) must always be destroyed before the xdg_surface.
                 // See https://wayland.app/protocols/xdg-shell#xdg_surface:request:destroy
                 toplevel.destroy();
@@ -288,6 +316,7 @@ impl WaylandWindowState {
         globals: Globals,
         gpu_context: &BladeContext,
         options: WindowParams,
+        parent: Option<WaylandWindowStatePtr>,
     ) -> anyhow::Result<Self> {
         let renderer = {
             let raw_window = RawWindow {
@@ -319,6 +348,8 @@ impl WaylandWindowState {
         Ok(Self {
             surface_state,
             acknowledged_first_configure: false,
+            parent,
+            children: FxHashSet::default(),
             surface,
             app_id: None,
             blur: None,
@@ -391,6 +422,10 @@ impl Drop for WaylandWindow {
     fn drop(&mut self) {
         let mut state = self.0.state.borrow_mut();
         let surface_id = state.surface.id();
+        if let Some(parent) = state.parent.as_ref() {
+            parent.state.borrow_mut().children.remove(&surface_id);
+        }
+
         let client = state.client.clone();
 
         state.renderer.destroy();
@@ -448,10 +483,10 @@ impl WaylandWindow {
         client: WaylandClientStatePtr,
         params: WindowParams,
         appearance: WindowAppearance,
-        parent: Option<XdgToplevel>,
+        parent: Option<WaylandWindowStatePtr>,
     ) -> anyhow::Result<(Self, ObjectId)> {
         let surface = globals.compositor.create_surface(&globals.qh, ());
-        let surface_state = WaylandSurfaceState::new(&surface, &globals, &params, parent)?;
+        let surface_state = WaylandSurfaceState::new(&surface, &globals, &params, parent.clone())?;
 
         if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() {
             fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id());
@@ -473,6 +508,7 @@ impl WaylandWindow {
                 globals,
                 gpu_context,
                 params,
+                parent,
             )?)),
             callbacks: Rc::new(RefCell::new(Callbacks::default())),
         });
@@ -501,6 +537,16 @@ impl WaylandWindowStatePtr {
         Rc::ptr_eq(&self.state, &other.state)
     }
 
+    pub fn add_child(&self, child: ObjectId) {
+        let mut state = self.state.borrow_mut();
+        state.children.insert(child);
+    }
+
+    pub fn is_blocked(&self) -> bool {
+        let state = self.state.borrow();
+        !state.children.is_empty()
+    }
+
     pub fn frame(&self) {
         let mut state = self.state.borrow_mut();
         state.surface.frame(&state.globals.qh, state.surface.id());
@@ -818,6 +864,9 @@ impl WaylandWindowStatePtr {
     }
 
     pub fn handle_ime(&self, ime: ImeInput) {
+        if self.is_blocked() {
+            return;
+        }
         let mut state = self.state.borrow_mut();
         if let Some(mut input_handler) = state.input_handler.take() {
             drop(state);
@@ -894,6 +943,21 @@ impl WaylandWindowStatePtr {
     }
 
     pub fn close(&self) {
+        let state = self.state.borrow();
+        let client = state.client.get_client();
+        #[allow(clippy::mutable_key_type)]
+        let children = state.children.clone();
+        drop(state);
+
+        for child in children {
+            let mut client_state = client.borrow_mut();
+            let window = get_window(&mut client_state, &child);
+            drop(client_state);
+
+            if let Some(child) = window {
+                child.close();
+            }
+        }
         let mut callbacks = self.callbacks.borrow_mut();
         if let Some(fun) = callbacks.close.take() {
             fun()
@@ -901,6 +965,9 @@ impl WaylandWindowStatePtr {
     }
 
     pub fn handle_input(&self, input: PlatformInput) {
+        if self.is_blocked() {
+            return;
+        }
         if let Some(ref mut fun) = self.callbacks.borrow_mut().input
             && !fun(input.clone()).propagate
         {
@@ -1025,13 +1092,26 @@ impl PlatformWindow for WaylandWindow {
     fn resize(&mut self, size: Size<Pixels>) {
         let state = self.borrow();
         let state_ptr = self.0.clone();
-        let dp_size = size.to_device_pixels(self.scale_factor());
+
+        // Keep window geometry consistent with configure handling. On Wayland, window geometry is
+        // surface-local: resizing should not attempt to translate the window; the compositor
+        // controls placement. We also account for client-side decoration insets and tiling.
+        let window_geometry = inset_by_tiling(
+            Bounds {
+                origin: Point::default(),
+                size,
+            },
+            state.inset(),
+            state.tiling,
+        )
+        .map(|v| v.0 as i32)
+        .map_size(|v| if v <= 0 { 1 } else { v });
 
         state.surface_state.set_geometry(
-            state.bounds.origin.x.0 as i32,
-            state.bounds.origin.y.0 as i32,
-            dp_size.width.0,
-            dp_size.height.0,
+            window_geometry.origin.x,
+            window_geometry.origin.y,
+            window_geometry.size.width,
+            window_geometry.size.height,
         );
 
         state

crates/gpui/src/platform/linux/x11/client.rs 🔗

@@ -29,7 +29,7 @@ use x11rb::{
     protocol::xkb::ConnectionExt as _,
     protocol::xproto::{
         AtomEnum, ChangeWindowAttributesAux, ClientMessageData, ClientMessageEvent,
-        ConnectionExt as _, EventMask, Visibility,
+        ConnectionExt as _, EventMask, ModMask, Visibility,
     },
     protocol::{Event, randr, render, xinput, xkb, xproto},
     resource_manager::Database,
@@ -222,7 +222,7 @@ pub struct X11ClientState {
 pub struct X11ClientStatePtr(pub Weak<RefCell<X11ClientState>>);
 
 impl X11ClientStatePtr {
-    fn get_client(&self) -> Option<X11Client> {
+    pub fn get_client(&self) -> Option<X11Client> {
         self.0.upgrade().map(X11Client)
     }
 
@@ -752,7 +752,7 @@ impl X11Client {
         }
     }
 
-    fn get_window(&self, win: xproto::Window) -> Option<X11WindowStatePtr> {
+    pub(crate) fn get_window(&self, win: xproto::Window) -> Option<X11WindowStatePtr> {
         let state = self.0.borrow();
         state
             .windows
@@ -789,12 +789,12 @@ impl X11Client {
                 let [atom, arg1, arg2, arg3, arg4] = event.data.as_data32();
                 let mut state = self.0.borrow_mut();
 
-                if atom == state.atoms.WM_DELETE_WINDOW {
+                if atom == state.atoms.WM_DELETE_WINDOW && window.should_close() {
                     // window "x" button clicked by user
-                    if window.should_close() {
-                        // Rest of the close logic is handled in drop_window()
-                        window.close();
-                    }
+                    // Rest of the close logic is handled in drop_window()
+                    drop(state);
+                    window.close();
+                    state = self.0.borrow_mut();
                 } else if atom == state.atoms._NET_WM_SYNC_REQUEST {
                     window.state.borrow_mut().last_sync_counter =
                         Some(x11rb::protocol::sync::Int64 {
@@ -944,6 +944,8 @@ impl X11Client {
                 let window = self.get_window(event.event)?;
                 window.set_active(false);
                 let mut state = self.0.borrow_mut();
+                // Set last scroll values to `None` so that a large delta isn't created if scrolling is done outside the window (the valuator is global)
+                reset_all_pointer_device_scroll_positions(&mut state.pointer_device_states);
                 state.keyboard_focused_window = None;
                 if let Some(compose_state) = state.compose_state.as_mut() {
                     compose_state.reset();
@@ -1018,6 +1020,12 @@ impl X11Client {
                 let modifiers = modifiers_from_state(event.state);
                 state.modifiers = modifiers;
                 state.pre_key_char_down.take();
+
+                // Macros containing modifiers might result in
+                // the modifiers missing from the event.
+                // We therefore update the mask from the global state.
+                update_xkb_mask_from_event_state(&mut state.xkb, event.state);
+
                 let keystroke = {
                     let code = event.detail.into();
                     let mut keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code);
@@ -1083,6 +1091,11 @@ impl X11Client {
                 let modifiers = modifiers_from_state(event.state);
                 state.modifiers = modifiers;
 
+                // Macros containing modifiers might result in
+                // the modifiers missing from the event.
+                // We therefore update the mask from the global state.
+                update_xkb_mask_from_event_state(&mut state.xkb, event.state);
+
                 let keystroke = {
                     let code = event.detail.into();
                     let keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code);
@@ -1205,6 +1218,33 @@ impl X11Client {
             Event::XinputMotion(event) => {
                 let window = self.get_window(event.event)?;
                 let mut state = self.0.borrow_mut();
+                if window.is_blocked() {
+                    // We want to set the cursor to the default arrow
+                    // when the window is blocked
+                    let style = CursorStyle::Arrow;
+
+                    let current_style = state
+                        .cursor_styles
+                        .get(&window.x_window)
+                        .unwrap_or(&CursorStyle::Arrow);
+                    if *current_style != style
+                        && let Some(cursor) = state.get_cursor_icon(style)
+                    {
+                        state.cursor_styles.insert(window.x_window, style);
+                        check_reply(
+                            || "Failed to set cursor style",
+                            state.xcb_connection.change_window_attributes(
+                                window.x_window,
+                                &ChangeWindowAttributesAux {
+                                    cursor: Some(cursor),
+                                    ..Default::default()
+                                },
+                            ),
+                        )
+                        .log_err();
+                        state.xcb_connection.flush().log_err();
+                    };
+                }
                 let pressed_button = pressed_button_from_mask(event.button_mask[0]);
                 let position = point(
                     px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor),
@@ -1478,7 +1518,7 @@ impl LinuxClient for X11Client {
         let parent_window = state
             .keyboard_focused_window
             .and_then(|focused_window| state.windows.get(&focused_window))
-            .map(|window| window.window.x_window);
+            .map(|w| w.window.clone());
         let x_window = state
             .xcb_connection
             .generate_id()
@@ -1533,7 +1573,15 @@ impl LinuxClient for X11Client {
             .cursor_styles
             .get(&focused_window)
             .unwrap_or(&CursorStyle::Arrow);
-        if *current_style == style {
+
+        let window = state
+            .mouse_focused_window
+            .and_then(|w| state.windows.get(&w));
+
+        let should_change = *current_style != style
+            && (window.is_none() || window.is_some_and(|w| !w.is_blocked()));
+
+        if !should_change {
             return;
         }
 
@@ -2516,3 +2564,19 @@ fn get_dpi_factor((width_px, height_px): (u32, u32), (width_mm, height_mm): (u64
 fn valid_scale_factor(scale_factor: f32) -> bool {
     scale_factor.is_sign_positive() && scale_factor.is_normal()
 }
+
+#[inline]
+fn update_xkb_mask_from_event_state(xkb: &mut xkbc::State, event_state: xproto::KeyButMask) {
+    let depressed_mods = event_state.remove((ModMask::LOCK | ModMask::M2).bits());
+    let latched_mods = xkb.serialize_mods(xkbc::STATE_MODS_LATCHED);
+    let locked_mods = xkb.serialize_mods(xkbc::STATE_MODS_LOCKED);
+    let locked_layout = xkb.serialize_layout(xkbc::STATE_LAYOUT_LOCKED);
+    xkb.update_mask(
+        depressed_mods.into(),
+        latched_mods,
+        locked_mods,
+        0,
+        0,
+        locked_layout,
+    );
+}

crates/gpui/src/platform/linux/x11/window.rs 🔗

@@ -11,6 +11,7 @@ use crate::{
 };
 
 use blade_graphics as gpu;
+use collections::FxHashSet;
 use raw_window_handle as rwh;
 use util::{ResultExt, maybe};
 use x11rb::{
@@ -74,6 +75,7 @@ x11rb::atom_manager! {
         _NET_WM_WINDOW_TYPE,
         _NET_WM_WINDOW_TYPE_NOTIFICATION,
         _NET_WM_WINDOW_TYPE_DIALOG,
+        _NET_WM_STATE_MODAL,
         _NET_WM_SYNC,
         _NET_SUPPORTED,
         _MOTIF_WM_HINTS,
@@ -249,6 +251,8 @@ pub struct Callbacks {
 
 pub struct X11WindowState {
     pub destroyed: bool,
+    parent: Option<X11WindowStatePtr>,
+    children: FxHashSet<xproto::Window>,
     client: X11ClientStatePtr,
     executor: ForegroundExecutor,
     atoms: XcbAtoms,
@@ -394,7 +398,7 @@ impl X11WindowState {
         atoms: &XcbAtoms,
         scale_factor: f32,
         appearance: WindowAppearance,
-        parent_window: Option<xproto::Window>,
+        parent_window: Option<X11WindowStatePtr>,
     ) -> anyhow::Result<Self> {
         let x_screen_index = params
             .display_id
@@ -427,6 +431,7 @@ impl X11WindowState {
             // https://stackoverflow.com/questions/43218127/x11-xlib-xcb-creating-a-window-requires-border-pixel-if-specifying-colormap-wh
             .border_pixel(visual_set.black_pixel)
             .colormap(colormap)
+            .override_redirect((params.kind == WindowKind::PopUp) as u32)
             .event_mask(
                 xproto::EventMask::EXPOSURE
                     | xproto::EventMask::STRUCTURE_NOTIFY
@@ -546,8 +551,8 @@ impl X11WindowState {
                 )?;
             }
 
-            if params.kind == WindowKind::Floating {
-                if let Some(parent_window) = parent_window {
+            if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog {
+                if let Some(parent_window) = parent_window.as_ref().map(|w| w.x_window) {
                     // WM_TRANSIENT_FOR hint indicating the main application window. For floating windows, we set
                     // a parent window (WM_TRANSIENT_FOR) such that the window manager knows where to
                     // place the floating window in relation to the main window.
@@ -563,11 +568,23 @@ impl X11WindowState {
                         ),
                     )?;
                 }
+            }
+
+            let parent = if params.kind == WindowKind::Dialog
+                && let Some(parent) = parent_window
+            {
+                parent.add_child(x_window);
+
+                Some(parent)
+            } else {
+                None
+            };
 
+            if params.kind == WindowKind::Dialog {
                 // _NET_WM_WINDOW_TYPE_DIALOG indicates that this is a dialog (floating) window
                 // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html
                 check_reply(
-                    || "X11 ChangeProperty32 setting window type for floating window failed.",
+                    || "X11 ChangeProperty32 setting window type for dialog window failed.",
                     xcb.change_property32(
                         xproto::PropMode::REPLACE,
                         x_window,
@@ -576,6 +593,20 @@ impl X11WindowState {
                         &[atoms._NET_WM_WINDOW_TYPE_DIALOG],
                     ),
                 )?;
+
+                // We set the modal state for dialog windows, so that the window manager
+                // can handle it appropriately (e.g., prevent interaction with the parent window
+                // while the dialog is open).
+                check_reply(
+                    || "X11 ChangeProperty32 setting modal state for dialog window failed.",
+                    xcb.change_property32(
+                        xproto::PropMode::REPLACE,
+                        x_window,
+                        atoms._NET_WM_STATE,
+                        xproto::AtomEnum::ATOM,
+                        &[atoms._NET_WM_STATE_MODAL],
+                    ),
+                )?;
             }
 
             check_reply(
@@ -667,6 +698,8 @@ impl X11WindowState {
             let display = Rc::new(X11Display::new(xcb, scale_factor, x_screen_index)?);
 
             Ok(Self {
+                parent,
+                children: FxHashSet::default(),
                 client,
                 executor,
                 display,
@@ -720,6 +753,11 @@ pub(crate) struct X11Window(pub X11WindowStatePtr);
 impl Drop for X11Window {
     fn drop(&mut self) {
         let mut state = self.0.state.borrow_mut();
+
+        if let Some(parent) = state.parent.as_ref() {
+            parent.state.borrow_mut().children.remove(&self.0.x_window);
+        }
+
         state.renderer.destroy();
 
         let destroy_x_window = maybe!({
@@ -734,8 +772,6 @@ impl Drop for X11Window {
         .log_err();
 
         if destroy_x_window.is_some() {
-            // Mark window as destroyed so that we can filter out when X11 events
-            // for it still come in.
             state.destroyed = true;
 
             let this_ptr = self.0.clone();
@@ -773,7 +809,7 @@ impl X11Window {
         atoms: &XcbAtoms,
         scale_factor: f32,
         appearance: WindowAppearance,
-        parent_window: Option<xproto::Window>,
+        parent_window: Option<X11WindowStatePtr>,
     ) -> anyhow::Result<Self> {
         let ptr = X11WindowStatePtr {
             state: Rc::new(RefCell::new(X11WindowState::new(
@@ -979,7 +1015,31 @@ impl X11WindowStatePtr {
         Ok(())
     }
 
+    pub fn add_child(&self, child: xproto::Window) {
+        let mut state = self.state.borrow_mut();
+        state.children.insert(child);
+    }
+
+    pub fn is_blocked(&self) -> bool {
+        let state = self.state.borrow();
+        !state.children.is_empty()
+    }
+
     pub fn close(&self) {
+        let state = self.state.borrow();
+        let client = state.client.clone();
+        #[allow(clippy::mutable_key_type)]
+        let children = state.children.clone();
+        drop(state);
+
+        if let Some(client) = client.get_client() {
+            for child in children {
+                if let Some(child_window) = client.get_window(child) {
+                    child_window.close();
+                }
+            }
+        }
+
         let mut callbacks = self.callbacks.borrow_mut();
         if let Some(fun) = callbacks.close.take() {
             fun()
@@ -994,6 +1054,9 @@ impl X11WindowStatePtr {
     }
 
     pub fn handle_input(&self, input: PlatformInput) {
+        if self.is_blocked() {
+            return;
+        }
         if let Some(ref mut fun) = self.callbacks.borrow_mut().input
             && !fun(input.clone()).propagate
         {
@@ -1016,6 +1079,9 @@ impl X11WindowStatePtr {
     }
 
     pub fn handle_ime_commit(&self, text: String) {
+        if self.is_blocked() {
+            return;
+        }
         let mut state = self.state.borrow_mut();
         if let Some(mut input_handler) = state.input_handler.take() {
             drop(state);
@@ -1026,6 +1092,9 @@ impl X11WindowStatePtr {
     }
 
     pub fn handle_ime_preedit(&self, text: String) {
+        if self.is_blocked() {
+            return;
+        }
         let mut state = self.state.borrow_mut();
         if let Some(mut input_handler) = state.input_handler.take() {
             drop(state);
@@ -1036,6 +1105,9 @@ impl X11WindowStatePtr {
     }
 
     pub fn handle_ime_unmark(&self) {
+        if self.is_blocked() {
+            return;
+        }
         let mut state = self.state.borrow_mut();
         if let Some(mut input_handler) = state.input_handler.take() {
             drop(state);
@@ -1046,6 +1118,9 @@ impl X11WindowStatePtr {
     }
 
     pub fn handle_ime_delete(&self) {
+        if self.is_blocked() {
+            return;
+        }
         let mut state = self.state.borrow_mut();
         if let Some(mut input_handler) = state.input_handler.take() {
             drop(state);

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

@@ -5,6 +5,7 @@ mod display;
 mod display_link;
 mod events;
 mod keyboard;
+mod pasteboard;
 
 #[cfg(feature = "screen-capture")]
 mod screen_capture;
@@ -21,8 +22,6 @@ use metal_renderer as renderer;
 #[cfg(feature = "macos-blade")]
 use crate::platform::blade as renderer;
 
-mod attributed_string;
-
 #[cfg(feature = "font-kit")]
 mod open_type;
 

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

@@ -1,129 +0,0 @@
-use cocoa::base::id;
-use cocoa::foundation::NSRange;
-use objc::{class, msg_send, sel, sel_impl};
-
-/// The `cocoa` crate does not define NSAttributedString (and related Cocoa classes),
-/// which are needed for copying rich text (that is, text intermingled with images)
-/// to the clipboard. This adds access to those APIs.
-#[allow(non_snake_case)]
-pub trait NSAttributedString: Sized {
-    unsafe fn alloc(_: Self) -> id {
-        msg_send![class!(NSAttributedString), alloc]
-    }
-
-    unsafe fn init_attributed_string(self, string: id) -> id;
-    unsafe fn appendAttributedString_(self, attr_string: id);
-    unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
-    unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
-    unsafe fn string(self) -> id;
-}
-
-impl NSAttributedString for id {
-    unsafe fn init_attributed_string(self, string: id) -> id {
-        msg_send![self, initWithString: string]
-    }
-
-    unsafe fn appendAttributedString_(self, attr_string: id) {
-        let _: () = msg_send![self, appendAttributedString: attr_string];
-    }
-
-    unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
-        msg_send![self, RTFDFromRange: range documentAttributes: attrs]
-    }
-
-    unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
-        msg_send![self, RTFFromRange: range documentAttributes: attrs]
-    }
-
-    unsafe fn string(self) -> id {
-        msg_send![self, string]
-    }
-}
-
-pub trait NSMutableAttributedString: NSAttributedString {
-    unsafe fn alloc(_: Self) -> id {
-        msg_send![class!(NSMutableAttributedString), alloc]
-    }
-}
-
-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::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() {
-        // TODO move these to parent module once it's actually ready to be used
-        #[allow(non_snake_case)]
-        pub trait NSTextAttachment: Sized {
-            unsafe fn alloc(_: Self) -> id {
-                msg_send![class!(NSTextAttachment), alloc]
-            }
-        }
-
-        impl NSTextAttachment for id {}
-
-        unsafe {
-            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 = 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: 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 = 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];
-
-            ///////////////////////////////////////////////////
-            // pasteboard.clearContents();
-
-            let rtfd_data = attr_string.RTFDFromRange_documentAttributes_(
-                NSRange::new(0, msg_send![attr_string, length]),
-                nil,
-            );
-            assert_ne!(rtfd_data, nil);
-            // if rtfd_data != nil {
-            //     pasteboard.setData_forType(rtfd_data, NSPasteboardTypeRTFD);
-            // }
-
-            // let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
-            //     NSRange::new(0, attributed_string.length()),
-            //     nil,
-            // );
-            // if rtf_data != nil {
-            //     pasteboard.setData_forType(rtf_data, NSPasteboardTypeRTF);
-            // }
-
-            // let plain_text = attributed_string.string();
-            // pasteboard.setString_forType(plain_text, NSPasteboardTypeString);
-        }
-    }
-}

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

@@ -52,6 +52,11 @@ pub fn apply_features_and_fallbacks(
             &kCFTypeDictionaryKeyCallBacks,
             &kCFTypeDictionaryValueCallBacks,
         );
+
+        for value in &values {
+            CFRelease(*value as _);
+        }
+
         let new_descriptor = CTFontDescriptorCreateWithAttributes(attrs);
         CFRelease(attrs as _);
         let new_descriptor = CTFontDescriptor::wrap_under_create_rule(new_descriptor);

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

@@ -0,0 +1,344 @@
+use core::slice;
+use std::ffi::c_void;
+
+use cocoa::{
+    appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF},
+    base::{id, nil},
+    foundation::NSData,
+};
+use objc::{msg_send, runtime::Object, sel, sel_impl};
+use strum::IntoEnumIterator as _;
+
+use crate::{
+    ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash,
+    platform::mac::ns_string,
+};
+
+pub struct Pasteboard {
+    inner: id,
+    text_hash_type: id,
+    metadata_type: id,
+}
+
+impl Pasteboard {
+    pub fn general() -> Self {
+        unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) }
+    }
+
+    pub fn find() -> Self {
+        unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) }
+    }
+
+    #[cfg(test)]
+    pub fn unique() -> Self {
+        unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) }
+    }
+
+    unsafe fn new(inner: id) -> Self {
+        Self {
+            inner,
+            text_hash_type: unsafe { ns_string("zed-text-hash") },
+            metadata_type: unsafe { ns_string("zed-metadata") },
+        }
+    }
+
+    pub fn read(&self) -> Option<ClipboardItem> {
+        // First, see if it's a string.
+        unsafe {
+            let pasteboard_types: id = self.inner.types();
+            let string_type: id = ns_string("public.utf8-plain-text");
+
+            if msg_send![pasteboard_types, containsObject: string_type] {
+                let data = self.inner.dataForType(string_type);
+                if data == nil {
+                    return None;
+                } else if data.bytes().is_null() {
+                    // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
+                    // "If the length of the NSData object is 0, this property returns nil."
+                    return Some(self.read_string(&[]));
+                } else {
+                    let bytes =
+                        slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
+
+                    return Some(self.read_string(bytes));
+                }
+            }
+
+            // If it wasn't a string, try the various supported image types.
+            for format in ImageFormat::iter() {
+                if let Some(item) = self.read_image(format) {
+                    return Some(item);
+                }
+            }
+        }
+
+        // If it wasn't a string or a supported image type, give up.
+        None
+    }
+
+    fn read_image(&self, format: ImageFormat) -> Option<ClipboardItem> {
+        let mut ut_type: UTType = format.into();
+
+        unsafe {
+            let types: id = self.inner.types();
+            if msg_send![types, containsObject: ut_type.inner()] {
+                self.data_for_type(ut_type.inner_mut()).map(|bytes| {
+                    let bytes = bytes.to_vec();
+                    let id = hash(&bytes);
+
+                    ClipboardItem {
+                        entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
+                    }
+                })
+            } else {
+                None
+            }
+        }
+    }
+
+    fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem {
+        unsafe {
+            let text = String::from_utf8_lossy(text_bytes).to_string();
+            let metadata = self
+                .data_for_type(self.text_hash_type)
+                .and_then(|hash_bytes| {
+                    let hash_bytes = hash_bytes.try_into().ok()?;
+                    let hash = u64::from_be_bytes(hash_bytes);
+                    let metadata = self.data_for_type(self.metadata_type)?;
+
+                    if hash == ClipboardString::text_hash(&text) {
+                        String::from_utf8(metadata.to_vec()).ok()
+                    } else {
+                        None
+                    }
+                });
+
+            ClipboardItem {
+                entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
+            }
+        }
+    }
+
+    unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> {
+        unsafe {
+            let data = self.inner.dataForType(kind);
+            if data == nil {
+                None
+            } else {
+                Some(slice::from_raw_parts(
+                    data.bytes() as *mut u8,
+                    data.length() as usize,
+                ))
+            }
+        }
+    }
+
+    pub fn write(&self, item: ClipboardItem) {
+        unsafe {
+            match item.entries.as_slice() {
+                [] => {
+                    // Writing an empty list of entries just clears the clipboard.
+                    self.inner.clearContents();
+                }
+                [ClipboardEntry::String(string)] => {
+                    self.write_plaintext(string);
+                }
+                [ClipboardEntry::Image(image)] => {
+                    self.write_image(image);
+                }
+                [ClipboardEntry::ExternalPaths(_)] => {}
+                _ => {
+                    // Agus NB: We're currently only writing string entries to the clipboard when we have more than one.
+                    //
+                    // This was the existing behavior before I refactored the outer clipboard code:
+                    // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110
+                    //
+                    // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor.
+
+                    let mut combined = ClipboardString {
+                        text: String::new(),
+                        metadata: None,
+                    };
+
+                    for entry in item.entries {
+                        match entry {
+                            ClipboardEntry::String(text) => {
+                                combined.text.push_str(&text.text());
+                                if combined.metadata.is_none() {
+                                    combined.metadata = text.metadata;
+                                }
+                            }
+                            _ => {}
+                        }
+                    }
+
+                    self.write_plaintext(&combined);
+                }
+            }
+        }
+    }
+
+    fn write_plaintext(&self, string: &ClipboardString) {
+        unsafe {
+            self.inner.clearContents();
+
+            let text_bytes = NSData::dataWithBytes_length_(
+                nil,
+                string.text.as_ptr() as *const c_void,
+                string.text.len() as u64,
+            );
+            self.inner
+                .setData_forType(text_bytes, NSPasteboardTypeString);
+
+            if let Some(metadata) = string.metadata.as_ref() {
+                let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
+                let hash_bytes = NSData::dataWithBytes_length_(
+                    nil,
+                    hash_bytes.as_ptr() as *const c_void,
+                    hash_bytes.len() as u64,
+                );
+                self.inner.setData_forType(hash_bytes, self.text_hash_type);
+
+                let metadata_bytes = NSData::dataWithBytes_length_(
+                    nil,
+                    metadata.as_ptr() as *const c_void,
+                    metadata.len() as u64,
+                );
+                self.inner
+                    .setData_forType(metadata_bytes, self.metadata_type);
+            }
+        }
+    }
+
+    unsafe fn write_image(&self, image: &Image) {
+        unsafe {
+            self.inner.clearContents();
+
+            let bytes = NSData::dataWithBytes_length_(
+                nil,
+                image.bytes.as_ptr() as *const c_void,
+                image.bytes.len() as u64,
+            );
+
+            self.inner
+                .setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
+        }
+    }
+}
+
+#[link(name = "AppKit", kind = "framework")]
+unsafe extern "C" {
+    /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc)
+    pub static NSPasteboardNameFind: id;
+}
+
+impl From<ImageFormat> for UTType {
+    fn from(value: ImageFormat) -> Self {
+        match value {
+            ImageFormat::Png => Self::png(),
+            ImageFormat::Jpeg => Self::jpeg(),
+            ImageFormat::Tiff => Self::tiff(),
+            ImageFormat::Webp => Self::webp(),
+            ImageFormat::Gif => Self::gif(),
+            ImageFormat::Bmp => Self::bmp(),
+            ImageFormat::Svg => Self::svg(),
+            ImageFormat::Ico => Self::ico(),
+        }
+    }
+}
+
+// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
+pub struct UTType(id);
+
+impl UTType {
+    pub fn png() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
+        Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
+    }
+
+    pub fn jpeg() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
+        Self(unsafe { ns_string("public.jpeg") })
+    }
+
+    pub fn gif() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
+        Self(unsafe { ns_string("com.compuserve.gif") })
+    }
+
+    pub fn webp() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
+        Self(unsafe { ns_string("org.webmproject.webp") })
+    }
+
+    pub fn bmp() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
+        Self(unsafe { ns_string("com.microsoft.bmp") })
+    }
+
+    pub fn svg() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
+        Self(unsafe { ns_string("public.svg-image") })
+    }
+
+    pub fn ico() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
+        Self(unsafe { ns_string("com.microsoft.ico") })
+    }
+
+    pub fn tiff() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
+        Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
+    }
+
+    fn inner(&self) -> *const Object {
+        self.0
+    }
+
+    pub fn inner_mut(&self) -> *mut Object {
+        self.0 as *mut _
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData};
+
+    use crate::{ClipboardEntry, ClipboardItem, ClipboardString};
+
+    use super::*;
+
+    #[test]
+    fn test_string() {
+        let pasteboard = Pasteboard::unique();
+        assert_eq!(pasteboard.read(), None);
+
+        let item = ClipboardItem::new_string("1".to_string());
+        pasteboard.write(item.clone());
+        assert_eq!(pasteboard.read(), Some(item));
+
+        let item = ClipboardItem {
+            entries: vec![ClipboardEntry::String(
+                ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
+            )],
+        };
+        pasteboard.write(item.clone());
+        assert_eq!(pasteboard.read(), Some(item));
+
+        let text_from_other_app = "text from other app";
+        unsafe {
+            let bytes = NSData::dataWithBytes_length_(
+                nil,
+                text_from_other_app.as_ptr() as *const c_void,
+                text_from_other_app.len() as u64,
+            );
+            pasteboard
+                .inner
+                .setData_forType(bytes, NSPasteboardTypeString);
+        }
+        assert_eq!(
+            pasteboard.read(),
+            Some(ClipboardItem::new_string(text_from_other_app.to_string()))
+        );
+    }
+}

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

@@ -1,29 +1,24 @@
 use super::{
-    BoolExt, MacKeyboardLayout, MacKeyboardMapper,
-    attributed_string::{NSAttributedString, NSMutableAttributedString},
-    events::key_to_native,
-    ns_string, renderer,
+    BoolExt, MacKeyboardLayout, MacKeyboardMapper, events::key_to_native, ns_string, renderer,
 };
 use crate::{
-    Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString,
-    CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher,
-    MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform,
-    PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
-    PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash,
+    Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor,
+    KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu,
+    PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper,
+    PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, WindowAppearance,
+    WindowParams, platform::mac::pasteboard::Pasteboard,
 };
 use anyhow::{Context as _, anyhow};
 use block::ConcreteBlock;
 use cocoa::{
     appkit::{
         NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
-        NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard,
-        NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString,
-        NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow,
+        NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel,
+        NSVisualEffectState, NSVisualEffectView, NSWindow,
     },
     base::{BOOL, NO, YES, id, nil, selector},
     foundation::{
-        NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString,
-        NSUInteger, NSURL,
+        NSArray, NSAutoreleasePool, NSBundle, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL,
     },
 };
 use core_foundation::{
@@ -49,7 +44,6 @@ use ptr::null_mut;
 use semver::Version;
 use std::{
     cell::Cell,
-    convert::TryInto,
     ffi::{CStr, OsStr, c_void},
     os::{raw::c_char, unix::ffi::OsStrExt},
     path::{Path, PathBuf},
@@ -58,7 +52,6 @@ use std::{
     slice, str,
     sync::{Arc, OnceLock},
 };
-use strum::IntoEnumIterator;
 use util::{
     ResultExt,
     command::{new_smol_command, new_std_command},
@@ -164,9 +157,8 @@ pub(crate) struct MacPlatformState {
     text_system: Arc<dyn PlatformTextSystem>,
     renderer_context: renderer::Context,
     headless: bool,
-    pasteboard: id,
-    text_hash_pasteboard_type: id,
-    metadata_pasteboard_type: id,
+    general_pasteboard: Pasteboard,
+    find_pasteboard: Pasteboard,
     reopen: Option<Box<dyn FnMut()>>,
     on_keyboard_layout_change: Option<Box<dyn FnMut()>>,
     quit: Option<Box<dyn FnMut()>>,
@@ -206,9 +198,8 @@ impl MacPlatform {
             background_executor: BackgroundExecutor::new(dispatcher.clone()),
             foreground_executor: ForegroundExecutor::new(dispatcher),
             renderer_context: renderer::Context::default(),
-            pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) },
-            text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") },
-            metadata_pasteboard_type: unsafe { ns_string("zed-metadata") },
+            general_pasteboard: Pasteboard::general(),
+            find_pasteboard: Pasteboard::find(),
             reopen: None,
             quit: None,
             menu_command: None,
@@ -224,20 +215,6 @@ impl MacPlatform {
         }))
     }
 
-    unsafe fn read_from_pasteboard(&self, pasteboard: *mut Object, kind: id) -> Option<&[u8]> {
-        unsafe {
-            let data = pasteboard.dataForType(kind);
-            if data == nil {
-                None
-            } else {
-                Some(slice::from_raw_parts(
-                    data.bytes() as *mut u8,
-                    data.length() as usize,
-                ))
-            }
-        }
-    }
-
     unsafe fn create_menu_bar(
         &self,
         menus: &Vec<Menu>,
@@ -1034,119 +1011,24 @@ impl Platform for MacPlatform {
         }
     }
 
-    fn write_to_clipboard(&self, item: ClipboardItem) {
-        use crate::ClipboardEntry;
-
-        unsafe {
-            // We only want to use NSAttributedString if there are multiple entries to write.
-            if item.entries.len() <= 1 {
-                match item.entries.first() {
-                    Some(entry) => match entry {
-                        ClipboardEntry::String(string) => {
-                            self.write_plaintext_to_clipboard(string);
-                        }
-                        ClipboardEntry::Image(image) => {
-                            self.write_image_to_clipboard(image);
-                        }
-                        ClipboardEntry::ExternalPaths(_) => {}
-                    },
-                    None => {
-                        // Writing an empty list of entries just clears the clipboard.
-                        let state = self.0.lock();
-                        state.pasteboard.clearContents();
-                    }
-                }
-            } else {
-                let mut any_images = false;
-                let attributed_string = {
-                    let mut buf = NSMutableAttributedString::alloc(nil)
-                        // TODO can we skip this? Or at least part of it?
-                        .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(ns_string(&text))
-                                .autorelease();
-
-                            buf.appendAttributedString_(to_append);
-                        }
-                    }
-
-                    buf
-                };
-
-                let state = self.0.lock();
-                state.pasteboard.clearContents();
-
-                // Only set rich text clipboard types if we actually have 1+ images to include.
-                if any_images {
-                    let rtfd_data = attributed_string.RTFDFromRange_documentAttributes_(
-                        NSRange::new(0, msg_send![attributed_string, length]),
-                        nil,
-                    );
-                    if rtfd_data != nil {
-                        state
-                            .pasteboard
-                            .setData_forType(rtfd_data, NSPasteboardTypeRTFD);
-                    }
-
-                    let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
-                        NSRange::new(0, attributed_string.length()),
-                        nil,
-                    );
-                    if rtf_data != nil {
-                        state
-                            .pasteboard
-                            .setData_forType(rtf_data, NSPasteboardTypeRTF);
-                    }
-                }
-
-                let plain_text = attributed_string.string();
-                state
-                    .pasteboard
-                    .setString_forType(plain_text, NSPasteboardTypeString);
-            }
-        }
-    }
-
     fn read_from_clipboard(&self) -> Option<ClipboardItem> {
         let state = self.0.lock();
-        let pasteboard = state.pasteboard;
-
-        // First, see if it's a string.
-        unsafe {
-            let types: id = pasteboard.types();
-            let string_type: id = ns_string("public.utf8-plain-text");
-
-            if msg_send![types, containsObject: string_type] {
-                let data = pasteboard.dataForType(string_type);
-                if data == nil {
-                    return None;
-                } else if data.bytes().is_null() {
-                    // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
-                    // "If the length of the NSData object is 0, this property returns nil."
-                    return Some(self.read_string_from_clipboard(&state, &[]));
-                } else {
-                    let bytes =
-                        slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
+        state.general_pasteboard.read()
+    }
 
-                    return Some(self.read_string_from_clipboard(&state, bytes));
-                }
-            }
+    fn write_to_clipboard(&self, item: ClipboardItem) {
+        let state = self.0.lock();
+        state.general_pasteboard.write(item);
+    }
 
-            // If it wasn't a string, try the various supported image types.
-            for format in ImageFormat::iter() {
-                if let Some(item) = try_clipboard_image(pasteboard, format) {
-                    return Some(item);
-                }
-            }
-        }
+    fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
+        let state = self.0.lock();
+        state.find_pasteboard.read()
+    }
 
-        // If it wasn't a string or a supported image type, give up.
-        None
+    fn write_to_find_pasteboard(&self, item: ClipboardItem) {
+        let state = self.0.lock();
+        state.find_pasteboard.write(item);
     }
 
     fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>> {
@@ -1255,116 +1137,6 @@ impl Platform for MacPlatform {
     }
 }
 
-impl MacPlatform {
-    unsafe fn read_string_from_clipboard(
-        &self,
-        state: &MacPlatformState,
-        text_bytes: &[u8],
-    ) -> ClipboardItem {
-        unsafe {
-            let text = String::from_utf8_lossy(text_bytes).to_string();
-            let metadata = self
-                .read_from_pasteboard(state.pasteboard, state.text_hash_pasteboard_type)
-                .and_then(|hash_bytes| {
-                    let hash_bytes = hash_bytes.try_into().ok()?;
-                    let hash = u64::from_be_bytes(hash_bytes);
-                    let metadata = self
-                        .read_from_pasteboard(state.pasteboard, state.metadata_pasteboard_type)?;
-
-                    if hash == ClipboardString::text_hash(&text) {
-                        String::from_utf8(metadata.to_vec()).ok()
-                    } else {
-                        None
-                    }
-                });
-
-            ClipboardItem {
-                entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
-            }
-        }
-    }
-
-    unsafe fn write_plaintext_to_clipboard(&self, string: &ClipboardString) {
-        unsafe {
-            let state = self.0.lock();
-            state.pasteboard.clearContents();
-
-            let text_bytes = NSData::dataWithBytes_length_(
-                nil,
-                string.text.as_ptr() as *const c_void,
-                string.text.len() as u64,
-            );
-            state
-                .pasteboard
-                .setData_forType(text_bytes, NSPasteboardTypeString);
-
-            if let Some(metadata) = string.metadata.as_ref() {
-                let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
-                let hash_bytes = NSData::dataWithBytes_length_(
-                    nil,
-                    hash_bytes.as_ptr() as *const c_void,
-                    hash_bytes.len() as u64,
-                );
-                state
-                    .pasteboard
-                    .setData_forType(hash_bytes, state.text_hash_pasteboard_type);
-
-                let metadata_bytes = NSData::dataWithBytes_length_(
-                    nil,
-                    metadata.as_ptr() as *const c_void,
-                    metadata.len() as u64,
-                );
-                state
-                    .pasteboard
-                    .setData_forType(metadata_bytes, state.metadata_pasteboard_type);
-            }
-        }
-    }
-
-    unsafe fn write_image_to_clipboard(&self, image: &Image) {
-        unsafe {
-            let state = self.0.lock();
-            state.pasteboard.clearContents();
-
-            let bytes = NSData::dataWithBytes_length_(
-                nil,
-                image.bytes.as_ptr() as *const c_void,
-                image.bytes.len() as u64,
-            );
-
-            state
-                .pasteboard
-                .setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
-        }
-    }
-}
-
-fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option<ClipboardItem> {
-    let mut ut_type: UTType = format.into();
-
-    unsafe {
-        let types: id = pasteboard.types();
-        if msg_send![types, containsObject: ut_type.inner()] {
-            let data = pasteboard.dataForType(ut_type.inner_mut());
-            if data == nil {
-                None
-            } else {
-                let bytes = Vec::from(slice::from_raw_parts(
-                    data.bytes() as *mut u8,
-                    data.length() as usize,
-                ));
-                let id = hash(&bytes);
-
-                Some(ClipboardItem {
-                    entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
-                })
-            }
-        } else {
-            None
-        }
-    }
-}
-
 unsafe fn path_from_objc(path: id) -> PathBuf {
     let len = msg_send![path, lengthOfBytesUsingEncoding: NSUTF8StringEncoding];
     let bytes = unsafe { path.UTF8String() as *const u8 };
@@ -1605,120 +1377,3 @@ mod security {
     pub const errSecUserCanceled: OSStatus = -128;
     pub const errSecItemNotFound: OSStatus = -25300;
 }
-
-impl From<ImageFormat> for UTType {
-    fn from(value: ImageFormat) -> Self {
-        match value {
-            ImageFormat::Png => Self::png(),
-            ImageFormat::Jpeg => Self::jpeg(),
-            ImageFormat::Tiff => Self::tiff(),
-            ImageFormat::Webp => Self::webp(),
-            ImageFormat::Gif => Self::gif(),
-            ImageFormat::Bmp => Self::bmp(),
-            ImageFormat::Svg => Self::svg(),
-            ImageFormat::Ico => Self::ico(),
-        }
-    }
-}
-
-// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
-struct UTType(id);
-
-impl UTType {
-    pub fn png() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
-        Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
-    }
-
-    pub fn jpeg() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
-        Self(unsafe { ns_string("public.jpeg") })
-    }
-
-    pub fn gif() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
-        Self(unsafe { ns_string("com.compuserve.gif") })
-    }
-
-    pub fn webp() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
-        Self(unsafe { ns_string("org.webmproject.webp") })
-    }
-
-    pub fn bmp() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
-        Self(unsafe { ns_string("com.microsoft.bmp") })
-    }
-
-    pub fn svg() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
-        Self(unsafe { ns_string("public.svg-image") })
-    }
-
-    pub fn ico() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
-        Self(unsafe { ns_string("com.microsoft.ico") })
-    }
-
-    pub fn tiff() -> Self {
-        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
-        Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
-    }
-
-    fn inner(&self) -> *const Object {
-        self.0
-    }
-
-    fn inner_mut(&self) -> *mut Object {
-        self.0 as *mut _
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use crate::ClipboardItem;
-
-    use super::*;
-
-    #[test]
-    fn test_clipboard() {
-        let platform = build_platform();
-        assert_eq!(platform.read_from_clipboard(), None);
-
-        let item = ClipboardItem::new_string("1".to_string());
-        platform.write_to_clipboard(item.clone());
-        assert_eq!(platform.read_from_clipboard(), Some(item));
-
-        let item = ClipboardItem {
-            entries: vec![ClipboardEntry::String(
-                ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
-            )],
-        };
-        platform.write_to_clipboard(item.clone());
-        assert_eq!(platform.read_from_clipboard(), Some(item));
-
-        let text_from_other_app = "text from other app";
-        unsafe {
-            let bytes = NSData::dataWithBytes_length_(
-                nil,
-                text_from_other_app.as_ptr() as *const c_void,
-                text_from_other_app.len() as u64,
-            );
-            platform
-                .0
-                .lock()
-                .pasteboard
-                .setData_forType(bytes, NSPasteboardTypeString);
-        }
-        assert_eq!(
-            platform.read_from_clipboard(),
-            Some(ClipboardItem::new_string(text_from_other_app.to_string()))
-        );
-    }
-
-    fn build_platform() -> MacPlatform {
-        let platform = MacPlatform::new(false);
-        platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) };
-        platform
-    }
-}

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

@@ -110,13 +110,21 @@ impl ScreenCaptureSource for MacScreenCaptureSource {
             let _: id = msg_send![configuration, setHeight: meta.resolution.height.0 as i64];
             let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate];
 
+            // Stream contains filter, configuration, and delegate internally so we release them here
+            // to prevent a memory leak when steam is dropped
+            let _: () = msg_send![filter, release];
+            let _: () = msg_send![configuration, release];
+            let _: () = msg_send![delegate, release];
+
             let (mut tx, rx) = oneshot::channel();
 
             let mut error: id = nil;
             let _: () = msg_send![stream, addStreamOutput:output type:SCStreamOutputTypeScreen sampleHandlerQueue:0 error:&mut error as *mut id];
             if error != nil {
                 let message: id = msg_send![error, localizedDescription];
-                tx.send(Err(anyhow!("failed to add stream  output {message:?}")))
+                let _: () = msg_send![stream, release];
+                let _: () = msg_send![output, release];
+                tx.send(Err(anyhow!("failed to add stream output {message:?}")))
                     .ok();
                 return rx;
             }
@@ -132,8 +140,10 @@ impl ScreenCaptureSource for MacScreenCaptureSource {
                         };
                         Ok(Box::new(stream) as Box<dyn ScreenCaptureStream>)
                     } else {
+                        let _: () = msg_send![stream, release];
+                        let _: () = msg_send![output, release];
                         let message: id = msg_send![error, localizedDescription];
-                        Err(anyhow!("failed to stop screen capture stream {message:?}"))
+                        Err(anyhow!("failed to start screen capture stream {message:?}"))
                     };
                     if let Some(tx) = tx.borrow_mut().take() {
                         tx.send(result).ok();

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

@@ -8,6 +8,7 @@ use anyhow::anyhow;
 use cocoa::appkit::CGFloat;
 use collections::HashMap;
 use core_foundation::{
+    array::{CFArray, CFArrayRef},
     attributed_string::CFMutableAttributedString,
     base::{CFRange, TCFType},
     number::CFNumber,
@@ -21,8 +22,10 @@ use core_graphics::{
 };
 use core_text::{
     font::CTFont,
+    font_collection::CTFontCollectionRef,
     font_descriptor::{
-        kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait, kCTFontWidthTrait,
+        CTFontDescriptor, kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait,
+        kCTFontWidthTrait,
     },
     line::CTLine,
     string_attributes::kCTFontAttributeName,
@@ -97,7 +100,26 @@ impl PlatformTextSystem for MacTextSystem {
     fn all_font_names(&self) -> Vec<String> {
         let mut names = Vec::new();
         let collection = core_text::font_collection::create_for_all_families();
-        let Some(descriptors) = collection.get_descriptors() else {
+        // NOTE: We intentionally avoid using `collection.get_descriptors()` here because
+        // it has a memory leak bug in core-text v21.0.0. The upstream code uses
+        // `wrap_under_get_rule` but `CTFontCollectionCreateMatchingFontDescriptors`
+        // follows the Create Rule (caller owns the result), so it should use
+        // `wrap_under_create_rule`. We call the function directly with correct memory management.
+        unsafe extern "C" {
+            fn CTFontCollectionCreateMatchingFontDescriptors(
+                collection: CTFontCollectionRef,
+            ) -> CFArrayRef;
+        }
+        let descriptors: Option<CFArray<CTFontDescriptor>> = unsafe {
+            let array_ref =
+                CTFontCollectionCreateMatchingFontDescriptors(collection.as_concrete_TypeRef());
+            if array_ref.is_null() {
+                None
+            } else {
+                Some(CFArray::wrap_under_create_rule(array_ref))
+            }
+        };
+        let Some(descriptors) = descriptors else {
             return names;
         };
         for descriptor in descriptors.into_iter() {

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

@@ -62,9 +62,12 @@ static mut BLURRED_VIEW_CLASS: *const Class = ptr::null();
 #[allow(non_upper_case_globals)]
 const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask =
     NSWindowStyleMask::from_bits_retain(1 << 7);
+// WindowLevel const value ref: https://docs.rs/core-graphics2/0.4.1/src/core_graphics2/window_level.rs.html
 #[allow(non_upper_case_globals)]
 const NSNormalWindowLevel: NSInteger = 0;
 #[allow(non_upper_case_globals)]
+const NSFloatingWindowLevel: NSInteger = 3;
+#[allow(non_upper_case_globals)]
 const NSPopUpWindowLevel: NSInteger = 101;
 #[allow(non_upper_case_globals)]
 const NSTrackingMouseEnteredAndExited: NSUInteger = 0x01;
@@ -423,6 +426,8 @@ struct MacWindowState {
     select_previous_tab_callback: Option<Box<dyn FnMut()>>,
     toggle_tab_bar_callback: Option<Box<dyn FnMut()>>,
     activated_least_once: bool,
+    // The parent window if this window is a sheet (Dialog kind)
+    sheet_parent: Option<id>,
 }
 
 impl MacWindowState {
@@ -622,11 +627,16 @@ impl MacWindow {
             }
 
             let native_window: id = match kind {
-                WindowKind::Normal | WindowKind::Floating => msg_send![WINDOW_CLASS, alloc],
+                WindowKind::Normal => {
+                    msg_send![WINDOW_CLASS, alloc]
+                }
                 WindowKind::PopUp => {
                     style_mask |= NSWindowStyleMaskNonactivatingPanel;
                     msg_send![PANEL_CLASS, alloc]
                 }
+                WindowKind::Floating | WindowKind::Dialog => {
+                    msg_send![PANEL_CLASS, alloc]
+                }
             };
 
             let display = display_id
@@ -729,6 +739,7 @@ impl MacWindow {
                 select_previous_tab_callback: None,
                 toggle_tab_bar_callback: None,
                 activated_least_once: false,
+                sheet_parent: None,
             })));
 
             (*native_window).set_ivar(
@@ -779,9 +790,18 @@ impl MacWindow {
             content_view.addSubview_(native_view.autorelease());
             native_window.makeFirstResponder_(native_view);
 
+            let app: id = NSApplication::sharedApplication(nil);
+            let main_window: id = msg_send![app, mainWindow];
+            let mut sheet_parent = None;
+
             match kind {
                 WindowKind::Normal | WindowKind::Floating => {
-                    native_window.setLevel_(NSNormalWindowLevel);
+                    if kind == WindowKind::Floating {
+                        // Let the window float keep above normal windows.
+                        native_window.setLevel_(NSFloatingWindowLevel);
+                    } else {
+                        native_window.setLevel_(NSNormalWindowLevel);
+                    }
                     native_window.setAcceptsMouseMovedEvents_(YES);
 
                     if let Some(tabbing_identifier) = tabbing_identifier {
@@ -816,10 +836,23 @@ impl MacWindow {
                         NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary
                     );
                 }
+                WindowKind::Dialog => {
+                    if !main_window.is_null() {
+                        let parent = {
+                            let active_sheet: id = msg_send![main_window, attachedSheet];
+                            if active_sheet.is_null() {
+                                main_window
+                            } else {
+                                active_sheet
+                            }
+                        };
+                        let _: () =
+                            msg_send![parent, beginSheet: native_window completionHandler: nil];
+                        sheet_parent = Some(parent);
+                    }
+                }
             }
 
-            let app = NSApplication::sharedApplication(nil);
-            let main_window: id = msg_send![app, mainWindow];
             if allows_automatic_window_tabbing
                 && !main_window.is_null()
                 && main_window != native_window
@@ -861,7 +894,11 @@ impl MacWindow {
             // the window position might be incorrect if the main screen (the screen that contains the window that has focus)
             //  is different from the primary screen.
             NSWindow::setFrameTopLeftPoint_(native_window, window_rect.origin);
-            window.0.lock().move_traffic_light();
+            {
+                let mut window_state = window.0.lock();
+                window_state.move_traffic_light();
+                window_state.sheet_parent = sheet_parent;
+            }
 
             pool.drain();
 
@@ -938,6 +975,7 @@ impl Drop for MacWindow {
         let mut this = self.0.lock();
         this.renderer.destroy();
         let window = this.native_window;
+        let sheet_parent = this.sheet_parent.take();
         this.display_link.take();
         unsafe {
             this.native_window.setDelegate_(nil);
@@ -946,6 +984,9 @@ impl Drop for MacWindow {
         this.executor
             .spawn(async move {
                 unsafe {
+                    if let Some(parent) = sheet_parent {
+                        let _: () = msg_send![parent, endSheet: window];
+                    }
                     window.close();
                     window.autorelease();
                 }
@@ -1190,6 +1231,7 @@ impl PlatformWindow for MacWindow {
             let (done_tx, done_rx) = oneshot::channel();
             let done_tx = Cell::new(Some(done_tx));
             let block = ConcreteBlock::new(move |answer: NSInteger| {
+                let _: () = msg_send![alert, release];
                 if let Some(done_tx) = done_tx.take() {
                     let _ = done_tx.send(answer.try_into().unwrap());
                 }

crates/gpui/src/platform/test/platform.rs 🔗

@@ -32,6 +32,8 @@ pub(crate) struct TestPlatform {
     current_clipboard_item: Mutex<Option<ClipboardItem>>,
     #[cfg(any(target_os = "linux", target_os = "freebsd"))]
     current_primary_item: Mutex<Option<ClipboardItem>>,
+    #[cfg(target_os = "macos")]
+    current_find_pasteboard_item: Mutex<Option<ClipboardItem>>,
     pub(crate) prompts: RefCell<TestPrompts>,
     screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
     pub opened_url: RefCell<Option<String>>,
@@ -117,6 +119,8 @@ impl TestPlatform {
             current_clipboard_item: Mutex::new(None),
             #[cfg(any(target_os = "linux", target_os = "freebsd"))]
             current_primary_item: Mutex::new(None),
+            #[cfg(target_os = "macos")]
+            current_find_pasteboard_item: Mutex::new(None),
             weak: weak.clone(),
             opened_url: Default::default(),
             #[cfg(target_os = "windows")]
@@ -398,9 +402,8 @@ impl Platform for TestPlatform {
         false
     }
 
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
-    fn write_to_primary(&self, item: ClipboardItem) {
-        *self.current_primary_item.lock() = Some(item);
+    fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        self.current_clipboard_item.lock().clone()
     }
 
     fn write_to_clipboard(&self, item: ClipboardItem) {
@@ -412,8 +415,19 @@ impl Platform for TestPlatform {
         self.current_primary_item.lock().clone()
     }
 
-    fn read_from_clipboard(&self) -> Option<ClipboardItem> {
-        self.current_clipboard_item.lock().clone()
+    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    fn write_to_primary(&self, item: ClipboardItem) {
+        *self.current_primary_item.lock() = Some(item);
+    }
+
+    #[cfg(target_os = "macos")]
+    fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
+        self.current_find_pasteboard_item.lock().clone()
+    }
+
+    #[cfg(target_os = "macos")]
+    fn write_to_find_pasteboard(&self, item: ClipboardItem) {
+        *self.current_find_pasteboard_item.lock() = Some(item);
     }
 
     fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {

crates/gpui/src/platform/windows/dispatcher.rs 🔗

@@ -4,31 +4,24 @@ use std::{
     time::{Duration, Instant},
 };
 
-use anyhow::Context;
+use flume::Sender;
 use util::ResultExt;
 use windows::{
-    System::Threading::{
-        ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority,
-    },
+    System::Threading::{ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler},
     Win32::{
         Foundation::{LPARAM, WPARAM},
-        System::Threading::{
-            GetCurrentThread, HIGH_PRIORITY_CLASS, SetPriorityClass, SetThreadPriority,
-            THREAD_PRIORITY_HIGHEST, THREAD_PRIORITY_TIME_CRITICAL,
-        },
         UI::WindowsAndMessaging::PostMessageW,
     },
 };
 
 use crate::{
-    GLOBAL_THREAD_TIMINGS, HWND, PlatformDispatcher, Priority, PriorityQueueSender,
-    RealtimePriority, RunnableVariant, SafeHwnd, THREAD_TIMINGS, TaskLabel, TaskTiming,
-    ThreadTaskTimings, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, profiler,
+    GLOBAL_THREAD_TIMINGS, HWND, PlatformDispatcher, RunnableVariant, SafeHwnd, THREAD_TIMINGS,
+    TaskLabel, TaskTiming, ThreadTaskTimings, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD,
 };
 
 pub(crate) struct WindowsDispatcher {
     pub(crate) wake_posted: AtomicBool,
-    main_sender: PriorityQueueSender<RunnableVariant>,
+    main_sender: Sender<RunnableVariant>,
     main_thread_id: ThreadId,
     pub(crate) platform_window_handle: SafeHwnd,
     validation_number: usize,
@@ -36,7 +29,7 @@ pub(crate) struct WindowsDispatcher {
 
 impl WindowsDispatcher {
     pub(crate) fn new(
-        main_sender: PriorityQueueSender<RunnableVariant>,
+        main_sender: Sender<RunnableVariant>,
         platform_window_handle: HWND,
         validation_number: usize,
     ) -> Self {
@@ -52,7 +45,7 @@ impl WindowsDispatcher {
         }
     }
 
-    fn dispatch_on_threadpool(&self, priority: WorkItemPriority, runnable: RunnableVariant) {
+    fn dispatch_on_threadpool(&self, runnable: RunnableVariant) {
         let handler = {
             let mut task_wrapper = Some(runnable);
             WorkItemHandler::new(move |_| {
@@ -60,8 +53,7 @@ impl WindowsDispatcher {
                 Ok(())
             })
         };
-
-        ThreadPool::RunWithPriorityAsync(&handler, priority).log_err();
+        ThreadPool::RunAsync(&handler).log_err();
     }
 
     fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) {
@@ -87,7 +79,7 @@ impl WindowsDispatcher {
                     start,
                     end: None,
                 };
-                profiler::add_task_timing(timing);
+                Self::add_task_timing(timing);
 
                 runnable.run();
 
@@ -99,7 +91,7 @@ impl WindowsDispatcher {
                     start,
                     end: None,
                 };
-                profiler::add_task_timing(timing);
+                Self::add_task_timing(timing);
 
                 runnable.run();
 
@@ -110,7 +102,23 @@ impl WindowsDispatcher {
         let end = Instant::now();
         timing.end = Some(end);
 
-        profiler::add_task_timing(timing);
+        Self::add_task_timing(timing);
+    }
+
+    pub(crate) fn add_task_timing(timing: TaskTiming) {
+        THREAD_TIMINGS.with(|timings| {
+            let mut timings = timings.lock();
+            let timings = &mut timings.timings;
+
+            if let Some(last_timing) = timings.iter_mut().rev().next() {
+                if last_timing.location == timing.location {
+                    last_timing.end = timing.end;
+                    return;
+                }
+            }
+
+            timings.push_back(timing);
+        });
     }
 }
 
@@ -138,22 +146,20 @@ impl PlatformDispatcher for WindowsDispatcher {
         current().id() == self.main_thread_id
     }
 
-    fn dispatch(&self, runnable: RunnableVariant, label: Option<TaskLabel>, priority: Priority) {
-        let priority = match priority {
-            Priority::Realtime(_) => unreachable!(),
-            Priority::High => WorkItemPriority::High,
-            Priority::Medium => WorkItemPriority::Normal,
-            Priority::Low => WorkItemPriority::Low,
-        };
-        self.dispatch_on_threadpool(priority, runnable);
-
+    fn dispatch(
+        &self,
+        runnable: RunnableVariant,
+        label: Option<TaskLabel>,
+        _priority: gpui::Priority,
+    ) {
+        self.dispatch_on_threadpool(runnable);
         if let Some(label) = label {
             log::debug!("TaskLabel: {label:?}");
         }
     }
 
-    fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) {
-        match self.main_sender.send(priority, runnable) {
+    fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: gpui::Priority) {
+        match self.main_sender.send(runnable) {
             Ok(_) => {
                 if !self.wake_posted.swap(true, Ordering::AcqRel) {
                     unsafe {
@@ -185,27 +191,8 @@ impl PlatformDispatcher for WindowsDispatcher {
         self.dispatch_on_threadpool_after(runnable, duration);
     }
 
-    fn spawn_realtime(&self, priority: RealtimePriority, f: Box<dyn FnOnce() + Send>) {
-        std::thread::spawn(move || {
-            // SAFETY: always safe to call
-            let thread_handle = unsafe { GetCurrentThread() };
-
-            let thread_priority = match priority {
-                RealtimePriority::Audio => THREAD_PRIORITY_TIME_CRITICAL,
-                RealtimePriority::Other => THREAD_PRIORITY_HIGHEST,
-            };
-
-            // SAFETY: thread_handle is a valid handle to a thread
-            unsafe { SetPriorityClass(thread_handle, HIGH_PRIORITY_CLASS) }
-                .context("thread priority class")
-                .log_err();
-
-            // SAFETY: thread_handle is a valid handle to a thread
-            unsafe { SetThreadPriority(thread_handle, thread_priority) }
-                .context("thread priority")
-                .log_err();
-
-            f();
-        });
+    fn spawn_realtime(&self, _priority: crate::RealtimePriority, _f: Box<dyn FnOnce() + Send>) {
+        // disabled on windows for now.
+        unimplemented!();
     }
 }

crates/gpui/src/platform/windows/events.rs 🔗

@@ -40,6 +40,11 @@ impl WindowsWindowInner {
         lparam: LPARAM,
     ) -> LRESULT {
         let handled = match msg {
+            // eagerly activate the window, so calls to `active_window` will work correctly
+            WM_MOUSEACTIVATE => {
+                unsafe { SetActiveWindow(handle).log_err() };
+                None
+            }
             WM_ACTIVATE => self.handle_activate_msg(wparam),
             WM_CREATE => self.handle_create_msg(handle),
             WM_MOVE => self.handle_move_msg(handle, lparam),
@@ -243,8 +248,7 @@ impl WindowsWindowInner {
 
     fn handle_timer_msg(&self, handle: HWND, wparam: WPARAM) -> Option<isize> {
         if wparam.0 == SIZE_MOVE_LOOP_TIMER_ID {
-            let mut runnables = self.main_receiver.clone().try_iter();
-            while let Some(Ok(runnable)) = runnables.next() {
+            for runnable in self.main_receiver.drain() {
                 WindowsDispatcher::execute_runnable(runnable);
             }
             self.handle_paint_msg(handle)
@@ -266,6 +270,14 @@ impl WindowsWindowInner {
 
     fn handle_destroy_msg(&self, handle: HWND) -> Option<isize> {
         let callback = { self.state.callbacks.close.take() };
+        // Re-enable parent window if this was a modal dialog
+        if let Some(parent_hwnd) = self.parent_hwnd {
+            unsafe {
+                let _ = EnableWindow(parent_hwnd, true);
+                let _ = SetForegroundWindow(parent_hwnd);
+            }
+        }
+
         if let Some(callback) = callback {
             callback();
         }

crates/gpui/src/platform/windows/platform.rs 🔗

@@ -51,7 +51,7 @@ struct WindowsPlatformInner {
     raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
     // The below members will never change throughout the entire lifecycle of the app.
     validation_number: usize,
-    main_receiver: PriorityQueueReceiver<RunnableVariant>,
+    main_receiver: flume::Receiver<RunnableVariant>,
     dispatcher: Arc<WindowsDispatcher>,
 }
 
@@ -98,7 +98,7 @@ impl WindowsPlatform {
             OleInitialize(None).context("unable to initialize Windows OLE")?;
         }
         let directx_devices = DirectXDevices::new().context("Creating DirectX devices")?;
-        let (main_sender, main_receiver) = PriorityQueueReceiver::new();
+        let (main_sender, main_receiver) = flume::unbounded::<RunnableVariant>();
         let validation_number = if usize::BITS == 64 {
             rand::random::<u64>() as usize
         } else {
@@ -659,7 +659,7 @@ impl Platform for WindowsPlatform {
             if let Err(err) = result {
                 // ERROR_NOT_FOUND means the credential doesn't exist.
                 // Return Ok(None) to match macOS and Linux behavior.
-                if err.code().0 == ERROR_NOT_FOUND.0 as i32 {
+                if err.code() == ERROR_NOT_FOUND.to_hresult() {
                     return Ok(None);
                 }
                 return Err(err.into());
@@ -857,24 +857,22 @@ impl WindowsPlatformInner {
                     }
                     break 'tasks;
                 }
-                let mut main_receiver = self.main_receiver.clone();
-                match main_receiver.try_pop() {
-                    Ok(Some(runnable)) => WindowsDispatcher::execute_runnable(runnable),
-                    _ => break 'timeout_loop,
+                match self.main_receiver.try_recv() {
+                    Err(_) => break 'timeout_loop,
+                    Ok(runnable) => WindowsDispatcher::execute_runnable(runnable),
                 }
             }
 
             // Someone could enqueue a Runnable here. The flag is still true, so they will not PostMessage.
             // We need to check for those Runnables after we clear the flag.
             self.dispatcher.wake_posted.store(false, Ordering::Release);
-            let mut main_receiver = self.main_receiver.clone();
-            match main_receiver.try_pop() {
-                Ok(Some(runnable)) => {
+            match self.main_receiver.try_recv() {
+                Err(_) => break 'tasks,
+                Ok(runnable) => {
                     self.dispatcher.wake_posted.store(true, Ordering::Release);
 
                     WindowsDispatcher::execute_runnable(runnable);
                 }
-                _ => break 'tasks,
             }
         }
 
@@ -936,7 +934,7 @@ pub(crate) struct WindowCreationInfo {
     pub(crate) windows_version: WindowsVersion,
     pub(crate) drop_target_helper: IDropTargetHelper,
     pub(crate) validation_number: usize,
-    pub(crate) main_receiver: PriorityQueueReceiver<RunnableVariant>,
+    pub(crate) main_receiver: flume::Receiver<RunnableVariant>,
     pub(crate) platform_window_handle: HWND,
     pub(crate) disable_direct_composition: bool,
     pub(crate) directx_devices: DirectXDevices,
@@ -949,8 +947,8 @@ struct PlatformWindowCreateContext {
     inner: Option<Result<Rc<WindowsPlatformInner>>>,
     raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
     validation_number: usize,
-    main_sender: Option<PriorityQueueSender<RunnableVariant>>,
-    main_receiver: Option<PriorityQueueReceiver<RunnableVariant>>,
+    main_sender: Option<flume::Sender<RunnableVariant>>,
+    main_receiver: Option<flume::Receiver<RunnableVariant>>,
     directx_devices: Option<DirectXDevices>,
     dispatcher: Option<Arc<WindowsDispatcher>>,
 }

crates/gpui/src/platform/windows/window.rs 🔗

@@ -81,8 +81,9 @@ pub(crate) struct WindowsWindowInner {
     pub(crate) executor: ForegroundExecutor,
     pub(crate) windows_version: WindowsVersion,
     pub(crate) validation_number: usize,
-    pub(crate) main_receiver: PriorityQueueReceiver<RunnableVariant>,
+    pub(crate) main_receiver: flume::Receiver<RunnableVariant>,
     pub(crate) platform_window_handle: HWND,
+    pub(crate) parent_hwnd: Option<HWND>,
 }
 
 impl WindowsWindowState {
@@ -241,6 +242,7 @@ impl WindowsWindowInner {
             main_receiver: context.main_receiver.clone(),
             platform_window_handle: context.platform_window_handle,
             system_settings: WindowsSystemSettings::new(context.display),
+            parent_hwnd: context.parent_hwnd,
         }))
     }
 
@@ -362,12 +364,13 @@ struct WindowCreateContext {
     windows_version: WindowsVersion,
     drop_target_helper: IDropTargetHelper,
     validation_number: usize,
-    main_receiver: PriorityQueueReceiver<RunnableVariant>,
+    main_receiver: flume::Receiver<RunnableVariant>,
     platform_window_handle: HWND,
     appearance: WindowAppearance,
     disable_direct_composition: bool,
     directx_devices: DirectXDevices,
     invalidate_devices: Arc<AtomicBool>,
+    parent_hwnd: Option<HWND>,
 }
 
 impl WindowsWindow {
@@ -390,6 +393,20 @@ impl WindowsWindow {
             invalidate_devices,
         } = creation_info;
         register_window_class(icon);
+        let parent_hwnd = if params.kind == WindowKind::Dialog {
+            let parent_window = unsafe { GetActiveWindow() };
+            if parent_window.is_invalid() {
+                None
+            } else {
+                // Disable the parent window to make this dialog modal
+                unsafe {
+                    EnableWindow(parent_window, false).as_bool();
+                };
+                Some(parent_window)
+            }
+        } else {
+            None
+        };
         let hide_title_bar = params
             .titlebar
             .as_ref()
@@ -416,8 +433,14 @@ impl WindowsWindow {
             if params.is_minimizable {
                 dwstyle |= WS_MINIMIZEBOX;
             }
+            let dwexstyle = if params.kind == WindowKind::Dialog {
+                dwstyle |= WS_POPUP | WS_CAPTION;
+                WS_EX_DLGMODALFRAME
+            } else {
+                WS_EX_APPWINDOW
+            };
 
-            (WS_EX_APPWINDOW, dwstyle)
+            (dwexstyle, dwstyle)
         };
         if !disable_direct_composition {
             dwexstyle |= WS_EX_NOREDIRECTIONBITMAP;
@@ -449,6 +472,7 @@ impl WindowsWindow {
             disable_direct_composition,
             directx_devices,
             invalidate_devices,
+            parent_hwnd,
         };
         let creation_result = unsafe {
             CreateWindowExW(
@@ -460,7 +484,7 @@ impl WindowsWindow {
                 CW_USEDEFAULT,
                 CW_USEDEFAULT,
                 CW_USEDEFAULT,
-                None,
+                parent_hwnd,
                 None,
                 Some(hinstance.into()),
                 Some(&context as *const _ as *const _),

crates/gpui/src/queue.rs 🔗

@@ -1,4 +1,5 @@
 use std::{
+    collections::VecDeque,
     fmt,
     iter::FusedIterator,
     sync::{Arc, atomic::AtomicUsize},
@@ -9,9 +10,9 @@ use rand::{Rng, SeedableRng, rngs::SmallRng};
 use crate::Priority;
 
 struct PriorityQueues<T> {
-    high_priority: Vec<T>,
-    medium_priority: Vec<T>,
-    low_priority: Vec<T>,
+    high_priority: VecDeque<T>,
+    medium_priority: VecDeque<T>,
+    low_priority: VecDeque<T>,
 }
 
 impl<T> PriorityQueues<T> {
@@ -42,9 +43,9 @@ impl<T> PriorityQueueState<T> {
         let mut queues = self.queues.lock();
         match priority {
             Priority::Realtime(_) => unreachable!(),
-            Priority::High => queues.high_priority.push(item),
-            Priority::Medium => queues.medium_priority.push(item),
-            Priority::Low => queues.low_priority.push(item),
+            Priority::High => queues.high_priority.push_back(item),
+            Priority::Medium => queues.medium_priority.push_back(item),
+            Priority::Low => queues.low_priority.push_back(item),
         };
         self.condvar.notify_one();
         Ok(())
@@ -141,9 +142,9 @@ impl<T> PriorityQueueReceiver<T> {
     pub(crate) fn new() -> (PriorityQueueSender<T>, Self) {
         let state = PriorityQueueState {
             queues: parking_lot::Mutex::new(PriorityQueues {
-                high_priority: Vec::new(),
-                medium_priority: Vec::new(),
-                low_priority: Vec::new(),
+                high_priority: VecDeque::new(),
+                medium_priority: VecDeque::new(),
+                low_priority: VecDeque::new(),
             }),
             condvar: parking_lot::Condvar::new(),
             receiver_count: AtomicUsize::new(1),
@@ -226,7 +227,7 @@ impl<T> PriorityQueueReceiver<T> {
         if !queues.high_priority.is_empty() {
             let flip = self.rand.random_ratio(P::High.probability(), mass);
             if flip {
-                return Ok(queues.high_priority.pop());
+                return Ok(queues.high_priority.pop_front());
             }
             mass -= P::High.probability();
         }
@@ -234,7 +235,7 @@ impl<T> PriorityQueueReceiver<T> {
         if !queues.medium_priority.is_empty() {
             let flip = self.rand.random_ratio(P::Medium.probability(), mass);
             if flip {
-                return Ok(queues.medium_priority.pop());
+                return Ok(queues.medium_priority.pop_front());
             }
             mass -= P::Medium.probability();
         }
@@ -242,7 +243,7 @@ impl<T> PriorityQueueReceiver<T> {
         if !queues.low_priority.is_empty() {
             let flip = self.rand.random_ratio(P::Low.probability(), mass);
             if flip {
-                return Ok(queues.low_priority.pop());
+                return Ok(queues.low_priority.pop_front());
             }
         }
 

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>,
@@ -330,9 +334,13 @@ pub enum WhiteSpace {
 /// How to truncate text that overflows the width of the element
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 pub enum TextOverflow {
-    /// Truncate the text when it doesn't fit, and represent this truncation by displaying the
-    /// provided string.
+    /// Truncate the text at the end when it doesn't fit, and represent this truncation by
+    /// displaying the provided string (e.g., "very long te…").
     Truncate(SharedString),
+    /// Truncate the text at the start when it doesn't fit, and represent this truncation by
+    /// displaying the provided string at the beginning (e.g., "…ong text here").
+    /// Typically more adequate for file paths where the end is more important than the beginning.
+    TruncateStart(SharedString),
 }
 
 /// How to align text within the element
@@ -772,6 +780,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 🔗

@@ -75,13 +75,21 @@ pub trait Styled: Sized {
         self
     }
 
-    /// Sets the truncate overflowing text with an ellipsis (…) if needed.
+    /// Sets the truncate overflowing text with an ellipsis (…) at the end if needed.
     /// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis)
     fn text_ellipsis(mut self) -> Self {
         self.text_style().text_overflow = Some(TextOverflow::Truncate(ELLIPSIS));
         self
     }
 
+    /// Sets the truncate overflowing text with an ellipsis (…) at the start if needed.
+    /// Typically more adequate for file paths where the end is more important than the beginning.
+    /// Note: This doesn't exist in Tailwind CSS.
+    fn text_ellipsis_start(mut self) -> Self {
+        self.text_style().text_overflow = Some(TextOverflow::TruncateStart(ELLIPSIS));
+        self
+    }
+
     /// Sets the text overflow behavior of the element.
     fn text_overflow(mut self, overflow: TextOverflow) -> Self {
         self.text_style().text_overflow = Some(overflow);
@@ -637,6 +645,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/gpui/src/test.rs 🔗

@@ -69,7 +69,10 @@ pub fn run_test(
                         std::mem::forget(error);
                     } else {
                         if is_multiple_runs {
-                            eprintln!("failing seed: {}", seed);
+                            eprintln!("failing seed: {seed}");
+                            eprintln!(
+                                "You can rerun from this seed by setting the environmental variable SEED to {seed}"
+                            );
                         }
                         if let Some(on_fail_fn) = on_fail_fn {
                             on_fail_fn()

crates/gpui/src/text_system/line.rs 🔗

@@ -64,6 +64,8 @@ impl ShapedLine {
         &self,
         origin: Point<Pixels>,
         line_height: Pixels,
+        align: TextAlign,
+        align_width: Option<Pixels>,
         window: &mut Window,
         cx: &mut App,
     ) -> Result<()> {
@@ -71,8 +73,8 @@ impl ShapedLine {
             origin,
             &self.layout,
             line_height,
-            TextAlign::default(),
-            None,
+            align,
+            align_width,
             &self.decoration_runs,
             &[],
             window,
@@ -87,6 +89,8 @@ impl ShapedLine {
         &self,
         origin: Point<Pixels>,
         line_height: Pixels,
+        align: TextAlign,
+        align_width: Option<Pixels>,
         window: &mut Window,
         cx: &mut App,
     ) -> Result<()> {
@@ -94,8 +98,8 @@ impl ShapedLine {
             origin,
             &self.layout,
             line_height,
-            TextAlign::default(),
-            None,
+            align,
+            align_width,
             &self.decoration_runs,
             &[],
             window,

crates/gpui/src/text_system/line_wrapper.rs 🔗

@@ -2,6 +2,15 @@ use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun,
 use collections::HashMap;
 use std::{borrow::Cow, iter, sync::Arc};
 
+/// Determines whether to truncate text from the start or end.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum TruncateFrom {
+    /// Truncate text from the start.
+    Start,
+    /// Truncate text from the end.
+    End,
+}
+
 /// The GPUI line wrapper, used to wrap lines of text to a given width.
 pub struct LineWrapper {
     platform_text_system: Arc<dyn PlatformTextSystem>,
@@ -128,40 +137,83 @@ impl LineWrapper {
         })
     }
 
-    /// Truncate a line of text to the given width with this wrapper's font and font size.
-    pub fn truncate_line<'a>(
+    /// Determines if a line should be truncated based on its width.
+    ///
+    /// Returns the truncation index in `line`.
+    pub fn should_truncate_line(
         &mut self,
-        line: SharedString,
+        line: &str,
         truncate_width: Pixels,
-        truncation_suffix: &str,
-        runs: &'a [TextRun],
-    ) -> (SharedString, Cow<'a, [TextRun]>) {
+        truncation_affix: &str,
+        truncate_from: TruncateFrom,
+    ) -> Option<usize> {
         let mut width = px(0.);
-        let mut suffix_width = truncation_suffix
+        let suffix_width = truncation_affix
             .chars()
             .map(|c| self.width_for_char(c))
             .fold(px(0.0), |a, x| a + x);
-        let mut char_indices = line.char_indices();
         let mut truncate_ix = 0;
-        for (ix, c) in char_indices {
-            if width + suffix_width < truncate_width {
-                truncate_ix = ix;
-            }
 
-            let char_width = self.width_for_char(c);
-            width += char_width;
+        match truncate_from {
+            TruncateFrom::Start => {
+                for (ix, c) in line.char_indices().rev() {
+                    if width + suffix_width < truncate_width {
+                        truncate_ix = ix;
+                    }
 
-            if width.floor() > truncate_width {
-                let result =
-                    SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix));
-                let mut runs = runs.to_vec();
-                update_runs_after_truncation(&result, truncation_suffix, &mut runs);
+                    let char_width = self.width_for_char(c);
+                    width += char_width;
 
-                return (result, Cow::Owned(runs));
+                    if width.floor() > truncate_width {
+                        return Some(truncate_ix);
+                    }
+                }
+            }
+            TruncateFrom::End => {
+                for (ix, c) in line.char_indices() {
+                    if width + suffix_width < truncate_width {
+                        truncate_ix = ix;
+                    }
+
+                    let char_width = self.width_for_char(c);
+                    width += char_width;
+
+                    if width.floor() > truncate_width {
+                        return Some(truncate_ix);
+                    }
+                }
             }
         }
 
-        (line, Cow::Borrowed(runs))
+        None
+    }
+
+    /// Truncate a line of text to the given width with this wrapper's font and font size.
+    pub fn truncate_line<'a>(
+        &mut self,
+        line: SharedString,
+        truncate_width: Pixels,
+        truncation_affix: &str,
+        runs: &'a [TextRun],
+        truncate_from: TruncateFrom,
+    ) -> (SharedString, Cow<'a, [TextRun]>) {
+        if let Some(truncate_ix) =
+            self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from)
+        {
+            let result = match truncate_from {
+                TruncateFrom::Start => {
+                    SharedString::from(format!("{truncation_affix}{}", &line[truncate_ix + 1..]))
+                }
+                TruncateFrom::End => {
+                    SharedString::from(format!("{}{truncation_affix}", &line[..truncate_ix]))
+                }
+            };
+            let mut runs = runs.to_vec();
+            update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from);
+            (result, Cow::Owned(runs))
+        } else {
+            (line, Cow::Borrowed(runs))
+        }
     }
 
     /// Any character in this list should be treated as a word character,
@@ -182,6 +234,11 @@ impl LineWrapper {
         // Cyrillic for Russian, Ukrainian, etc.
         // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
         matches!(c, '\u{0400}'..='\u{04FF}') ||
+
+        // Vietnamese (https://vietunicode.sourceforge.net/charset/)
+        matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
+        matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
+
         // Some other known special characters that should be treated as word characters,
         // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`,
         // `2^3`, `a~b`, `a=1`, `Self::new`, etc.
@@ -225,15 +282,35 @@ impl LineWrapper {
     }
 }
 
-fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec<TextRun>) {
+fn update_runs_after_truncation(
+    result: &str,
+    ellipsis: &str,
+    runs: &mut Vec<TextRun>,
+    truncate_from: TruncateFrom,
+) {
     let mut truncate_at = result.len() - ellipsis.len();
-    for (run_index, run) in runs.iter_mut().enumerate() {
-        if run.len <= truncate_at {
-            truncate_at -= run.len;
-        } else {
-            run.len = truncate_at + ellipsis.len();
-            runs.truncate(run_index + 1);
-            break;
+    match truncate_from {
+        TruncateFrom::Start => {
+            for (run_index, run) in runs.iter_mut().enumerate().rev() {
+                if run.len <= truncate_at {
+                    truncate_at -= run.len;
+                } else {
+                    run.len = truncate_at + ellipsis.len();
+                    runs.splice(..run_index, std::iter::empty());
+                    break;
+                }
+            }
+        }
+        TruncateFrom::End => {
+            for (run_index, run) in runs.iter_mut().enumerate() {
+                if run.len <= truncate_at {
+                    truncate_at -= run.len;
+                } else {
+                    run.len = truncate_at + ellipsis.len();
+                    runs.truncate(run_index + 1);
+                    break;
+                }
+            }
         }
     }
 }
@@ -483,7 +560,7 @@ mod tests {
     }
 
     #[test]
-    fn test_truncate_line() {
+    fn test_truncate_line_end() {
         let mut wrapper = build_wrapper();
 
         fn perform_test(
@@ -494,8 +571,13 @@ mod tests {
         ) {
             let dummy_run_lens = vec![text.len()];
             let dummy_runs = generate_test_runs(&dummy_run_lens);
-            let (result, dummy_runs) =
-                wrapper.truncate_line(text.into(), px(220.), ellipsis, &dummy_runs);
+            let (result, dummy_runs) = wrapper.truncate_line(
+                text.into(),
+                px(220.),
+                ellipsis,
+                &dummy_runs,
+                TruncateFrom::End,
+            );
             assert_eq!(result, expected);
             assert_eq!(dummy_runs.first().unwrap().len, result.len());
         }
@@ -521,7 +603,50 @@ mod tests {
     }
 
     #[test]
-    fn test_truncate_multiple_runs() {
+    fn test_truncate_line_start() {
+        let mut wrapper = build_wrapper();
+
+        fn perform_test(
+            wrapper: &mut LineWrapper,
+            text: &'static str,
+            expected: &'static str,
+            ellipsis: &str,
+        ) {
+            let dummy_run_lens = vec![text.len()];
+            let dummy_runs = generate_test_runs(&dummy_run_lens);
+            let (result, dummy_runs) = wrapper.truncate_line(
+                text.into(),
+                px(220.),
+                ellipsis,
+                &dummy_runs,
+                TruncateFrom::Start,
+            );
+            assert_eq!(result, expected);
+            assert_eq!(dummy_runs.first().unwrap().len, result.len());
+        }
+
+        perform_test(
+            &mut wrapper,
+            "aaaa bbbb cccc ddddd eeee fff gg",
+            "cccc ddddd eeee fff gg",
+            "",
+        );
+        perform_test(
+            &mut wrapper,
+            "aaaa bbbb cccc ddddd eeee fff gg",
+            "…ccc ddddd eeee fff gg",
+            "…",
+        );
+        perform_test(
+            &mut wrapper,
+            "aaaa bbbb cccc ddddd eeee fff gg",
+            "......dddd eeee fff gg",
+            "......",
+        );
+    }
+
+    #[test]
+    fn test_truncate_multiple_runs_end() {
         let mut wrapper = build_wrapper();
 
         fn perform_test(
@@ -534,7 +659,7 @@ mod tests {
         ) {
             let dummy_runs = generate_test_runs(run_lens);
             let (result, dummy_runs) =
-                wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs);
+                wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End);
             assert_eq!(result, expected);
             for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
                 assert_eq!(run.len, *result_len);
@@ -580,10 +705,75 @@ mod tests {
     }
 
     #[test]
-    fn test_update_run_after_truncation() {
+    fn test_truncate_multiple_runs_start() {
+        let mut wrapper = build_wrapper();
+
+        #[track_caller]
+        fn perform_test(
+            wrapper: &mut LineWrapper,
+            text: &'static str,
+            expected: &str,
+            run_lens: &[usize],
+            result_run_len: &[usize],
+            line_width: Pixels,
+        ) {
+            let dummy_runs = generate_test_runs(run_lens);
+            let (result, dummy_runs) = wrapper.truncate_line(
+                text.into(),
+                line_width,
+                "…",
+                &dummy_runs,
+                TruncateFrom::Start,
+            );
+            assert_eq!(result, expected);
+            for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
+                assert_eq!(run.len, *result_len);
+            }
+        }
+        // Case 0: Normal
+        // Text: abcdefghijkl
+        // Runs: Run0 { len: 12, ... }
+        //
+        // Truncate res: …ijkl (truncate_at = 9)
+        // Run res: Run0 { string: …ijkl, len: 7, ... }
+        perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.));
+        // Case 1: Drop some runs
+        // Text: abcdefghijkl
+        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+        //
+        // Truncate res: …ghijkl (truncate_at = 7)
+        // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len:
+        // 4, ... }
+        perform_test(
+            &mut wrapper,
+            "abcdefghijkl",
+            "…ghijkl",
+            &[4, 4, 4],
+            &[5, 4],
+            px(70.),
+        );
+        // Case 2: Truncate at start of some run
+        // Text: abcdefghijkl
+        // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+        //
+        // Truncate res: abcdefgh… (truncate_at = 3)
+        // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len:
+        // 4, ... }, Run2 { string: ijkl, len: 4, ... }
+        perform_test(
+            &mut wrapper,
+            "abcdefghijkl",
+            "…efghijkl",
+            &[4, 4, 4],
+            &[3, 4, 4],
+            px(90.),
+        );
+    }
+
+    #[test]
+    fn test_update_run_after_truncation_end() {
         fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
             let mut dummy_runs = generate_test_runs(run_lens);
-            update_runs_after_truncation(result, "…", &mut dummy_runs);
+            update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End);
             for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
                 assert_eq!(run.len, *result_len);
             }
@@ -618,7 +808,12 @@ mod tests {
         #[track_caller]
         fn assert_word(word: &str) {
             for c in word.chars() {
-                assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c);
+                assert!(
+                    LineWrapper::is_word_char(c),
+                    "assertion failed for '{}' (unicode 0x{:x})",
+                    c,
+                    c as u32
+                );
             }
         }
 
@@ -661,6 +856,8 @@ mod tests {
         assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
         // Cyrillic
         assert_word("АБВГДЕЖЗИЙКЛМНОП");
+        // Vietnamese (https://github.com/zed-industries/zed/issues/23245)
+        assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
 
         // non-word characters
         assert_not_word("你好");

crates/gpui/src/window.rs 🔗

@@ -345,8 +345,8 @@ impl FocusHandle {
     }
 
     /// Moves the focus to the element associated with this handle.
-    pub fn focus(&self, window: &mut Window) {
-        window.focus(self)
+    pub fn focus(&self, window: &mut Window, cx: &mut App) {
+        window.focus(self, cx)
     }
 
     /// Obtains whether the element associated with this handle is currently focused.
@@ -876,7 +876,9 @@ pub struct Window {
     active: Rc<Cell<bool>>,
     hovered: Rc<Cell<bool>>,
     pub(crate) needs_present: Rc<Cell<bool>>,
-    pub(crate) last_input_timestamp: Rc<Cell<Instant>>,
+    /// Tracks recent input event timestamps to determine if input is arriving at a high rate.
+    /// Used to selectively enable VRR optimization only when input rate exceeds 60fps.
+    pub(crate) input_rate_tracker: Rc<RefCell<InputRateTracker>>,
     last_input_modality: InputModality,
     pub(crate) refreshing: bool,
     pub(crate) activation_observers: SubscriberSet<(), AnyObserver>,
@@ -897,6 +899,51 @@ struct ModifierState {
     saw_keystroke: bool,
 }
 
+/// Tracks input event timestamps to determine if input is arriving at a high rate.
+/// Used for selective VRR (Variable Refresh Rate) optimization.
+#[derive(Clone, Debug)]
+pub(crate) struct InputRateTracker {
+    timestamps: Vec<Instant>,
+    window: Duration,
+    inputs_per_second: u32,
+    sustain_until: Instant,
+    sustain_duration: Duration,
+}
+
+impl Default for InputRateTracker {
+    fn default() -> Self {
+        Self {
+            timestamps: Vec::new(),
+            window: Duration::from_millis(100),
+            inputs_per_second: 60,
+            sustain_until: Instant::now(),
+            sustain_duration: Duration::from_secs(1),
+        }
+    }
+}
+
+impl InputRateTracker {
+    pub fn record_input(&mut self) {
+        let now = Instant::now();
+        self.timestamps.push(now);
+        self.prune_old_timestamps(now);
+
+        let min_events = self.inputs_per_second as u128 * self.window.as_millis() / 1000;
+        if self.timestamps.len() as u128 >= min_events {
+            self.sustain_until = now + self.sustain_duration;
+        }
+    }
+
+    pub fn is_high_rate(&self) -> bool {
+        Instant::now() < self.sustain_until
+    }
+
+    fn prune_old_timestamps(&mut self, now: Instant) {
+        self.timestamps
+            .retain(|&t| now.duration_since(t) <= self.window);
+    }
+}
+
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
 pub(crate) enum DrawPhase {
     None,
@@ -1047,7 +1094,7 @@ impl Window {
         let hovered = Rc::new(Cell::new(platform_window.is_hovered()));
         let needs_present = Rc::new(Cell::new(false));
         let next_frame_callbacks: Rc<RefCell<Vec<FrameCallback>>> = Default::default();
-        let last_input_timestamp = Rc::new(Cell::new(Instant::now()));
+        let input_rate_tracker = Rc::new(RefCell::new(InputRateTracker::default()));
 
         platform_window
             .request_decorations(window_decorations.unwrap_or(WindowDecorations::Server));
@@ -1075,7 +1122,7 @@ impl Window {
             let active = active.clone();
             let needs_present = needs_present.clone();
             let next_frame_callbacks = next_frame_callbacks.clone();
-            let last_input_timestamp = last_input_timestamp.clone();
+            let input_rate_tracker = input_rate_tracker.clone();
             move |request_frame_options| {
                 let next_frame_callbacks = next_frame_callbacks.take();
                 if !next_frame_callbacks.is_empty() {
@@ -1088,12 +1135,12 @@ impl Window {
                         .log_err();
                 }
 
-                // Keep presenting the current scene for 1 extra second since the
-                // last input to prevent the display from underclocking the refresh rate.
+                // Keep presenting if input was recently arriving at a high rate (>= 60fps).
+                // Once high-rate input is detected, we sustain presentation for 1 second
+                // to prevent display underclocking during active input.
                 let needs_present = request_frame_options.require_presentation
                     || needs_present.get()
-                    || (active.get()
-                        && last_input_timestamp.get().elapsed() < Duration::from_secs(1));
+                    || (active.get() && input_rate_tracker.borrow_mut().is_high_rate());
 
                 if invalidator.is_dirty() || request_frame_options.force_render {
                     measure("frame duration", || {
@@ -1101,7 +1148,6 @@ impl Window {
                             .update(&mut cx, |_, window, cx| {
                                 let arena_clear_needed = window.draw(cx);
                                 window.present();
-                                // drop the arena elements after present to reduce latency
                                 arena_clear_needed.clear();
                             })
                             .log_err();
@@ -1299,7 +1345,7 @@ impl Window {
             active,
             hovered,
             needs_present,
-            last_input_timestamp,
+            input_rate_tracker,
             last_input_modality: InputModality::Mouse,
             refreshing: false,
             activation_observers: SubscriberSet::new(),
@@ -1436,13 +1482,25 @@ impl Window {
     }
 
     /// Move focus to the element associated with the given [`FocusHandle`].
-    pub fn focus(&mut self, handle: &FocusHandle) {
+    pub fn focus(&mut self, handle: &FocusHandle, cx: &mut App) {
         if !self.focus_enabled || self.focus == Some(handle.id) {
             return;
         }
 
         self.focus = Some(handle.id);
         self.clear_pending_keystrokes();
+
+        // Avoid re-entrant entity updates by deferring observer notifications to the end of the
+        // current effect cycle, and only for this window.
+        let window_handle = self.handle;
+        cx.defer(move |cx| {
+            window_handle
+                .update(cx, |_, window, cx| {
+                    window.pending_input_changed(cx);
+                })
+                .ok();
+        });
+
         self.refresh();
     }
 
@@ -1463,24 +1521,24 @@ impl Window {
     }
 
     /// Move focus to next tab stop.
-    pub fn focus_next(&mut self) {
+    pub fn focus_next(&mut self, cx: &mut App) {
         if !self.focus_enabled {
             return;
         }
 
         if let Some(handle) = self.rendered_frame.tab_stops.next(self.focus.as_ref()) {
-            self.focus(&handle)
+            self.focus(&handle, cx)
         }
     }
 
     /// Move focus to previous tab stop.
-    pub fn focus_prev(&mut self) {
+    pub fn focus_prev(&mut self, cx: &mut App) {
         if !self.focus_enabled {
             return;
         }
 
         if let Some(handle) = self.rendered_frame.tab_stops.prev(self.focus.as_ref()) {
-            self.focus(&handle)
+            self.focus(&handle, cx)
         }
     }
 
@@ -1961,7 +2019,7 @@ impl Window {
     }
 
     /// Determine whether the given action is available along the dispatch path to the currently focused element.
-    pub fn is_action_available(&self, action: &dyn Action, cx: &mut App) -> bool {
+    pub fn is_action_available(&self, action: &dyn Action, cx: &App) -> bool {
         let node_id =
             self.focus_node_id_in_rendered_frame(self.focused(cx).map(|handle| handle.id));
         self.rendered_frame
@@ -1969,6 +2027,14 @@ impl Window {
             .is_action_available(action, node_id)
     }
 
+    /// Determine whether the given action is available along the dispatch path to the given focus_handle.
+    pub fn is_action_available_in(&self, action: &dyn Action, focus_handle: &FocusHandle) -> bool {
+        let node_id = self.focus_node_id_in_rendered_frame(Some(focus_handle.id));
+        self.rendered_frame
+            .dispatch_tree
+            .is_action_available(action, node_id)
+    }
+
     /// The position of the mouse relative to the window.
     pub fn mouse_position(&self) -> Point<Pixels> {
         self.mouse_position
@@ -3671,8 +3737,6 @@ impl Window {
     /// Dispatch a mouse or keyboard event on the window.
     #[profiling::function]
     pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult {
-        self.last_input_timestamp.set(Instant::now());
-
         // Track whether this input was keyboard-based for focus-visible styling
         self.last_input_modality = match &event {
             PlatformInput::KeyDown(_) | PlatformInput::ModifiersChanged(_) => {
@@ -3773,6 +3837,10 @@ impl Window {
             self.dispatch_key_event(any_key_event, cx);
         }
 
+        if self.invalidator.is_dirty() {
+            self.input_rate_tracker.borrow_mut().record_input();
+        }
+
         DispatchEventResult {
             propagate: cx.propagate_event,
             default_prevented: self.default_prevented,
@@ -4012,7 +4080,7 @@ impl Window {
         self.dispatch_keystroke_observers(event, None, context_stack, cx);
     }
 
-    fn pending_input_changed(&mut self, cx: &mut App) {
+    pub(crate) fn pending_input_changed(&mut self, cx: &mut App) {
         self.pending_input_observers
             .clone()
             .retain(&(), |callback| callback(self, cx));
@@ -4430,6 +4498,13 @@ impl Window {
         dispatch_tree.highest_precedence_binding_for_action(action, &context_stack)
     }
 
+    /// Find the bindings that can follow the current input sequence for the current context stack.
+    pub fn possible_bindings_for_input(&self, input: &[Keystroke]) -> Vec<KeyBinding> {
+        self.rendered_frame
+            .dispatch_tree
+            .possible_next_bindings_for_input(input, &self.context_stack())
+    }
+
     fn context_stack_for_focus_handle(
         &self,
         focus_handle: &FocusHandle,
@@ -4939,7 +5014,7 @@ impl<V: 'static> From<WindowHandle<V>> for AnyWindowHandle {
 }
 
 /// A handle to a window with any root view type, which can be downcast to a window with a specific root view type.
-#[derive(Copy, Clone, PartialEq, Eq, Hash)]
+#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
 pub struct AnyWindowHandle {
     pub(crate) id: WindowId,
     state_type: TypeId,

crates/gpui/src/window/prompts.rs 🔗

@@ -44,10 +44,10 @@ impl PromptHandle {
             if let Some(sender) = sender.take() {
                 sender.send(e.0).ok();
                 window_handle
-                    .update(cx, |_, window, _cx| {
+                    .update(cx, |_, window, cx| {
                         window.prompt.take();
                         if let Some(previous_focus) = &previous_focus {
-                            window.focus(previous_focus);
+                            window.focus(previous_focus, cx);
                         }
                     })
                     .ok();
@@ -55,7 +55,7 @@ impl PromptHandle {
         })
         .detach();
 
-        window.focus(&view.focus_handle(cx));
+        window.focus(&view.focus_handle(cx), cx);
 
         RenderablePromptHandle {
             view: Box::new(view),

crates/gpui_macros/src/derive_visual_context.rs 🔗

@@ -62,7 +62,7 @@ pub fn derive_visual_context(input: TokenStream) -> TokenStream {
                 V: gpui::Focusable,
             {
                 let focus_handle = gpui::Focusable::focus_handle(entity, self.#app_variable);
-                self.#window_variable.focus(&focus_handle)
+                self.#window_variable.focus(&focus_handle, self.#app_variable)
             }
         }
     };

crates/image_viewer/src/image_viewer.rs 🔗

@@ -11,7 +11,7 @@ use gpui::{
     InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Task, WeakEntity,
     Window, canvas, div, fill, img, opaque_grey, point, size,
 };
-use language::{DiskState, File as _};
+use language::File as _;
 use persistence::IMAGE_VIEWER;
 use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent};
 use settings::Settings;
@@ -195,7 +195,7 @@ impl Item for ImageView {
     }
 
     fn has_deleted_file(&self, cx: &App) -> bool {
-        self.image_item.read(cx).file.disk_state() == DiskState::Deleted
+        self.image_item.read(cx).file.disk_state().is_deleted()
     }
     fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
         workspace::item::ItemBufferKind::Singleton

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);
 }
@@ -900,7 +911,7 @@ impl KeymapEditor {
             .focus_handle(cx)
             .contains_focused(window, cx)
         {
-            window.focus(&self.filter_editor.focus_handle(cx));
+            window.focus(&self.filter_editor.focus_handle(cx), cx);
         } else {
             self.filter_editor.update(cx, |editor, cx| {
                 editor.select_all(&Default::default(), window, cx);
@@ -937,7 +948,7 @@ impl KeymapEditor {
             if let Some(scroll_strategy) = scroll {
                 self.scroll_to_item(index, scroll_strategy, cx);
             }
-            window.focus(&self.focus_handle);
+            window.focus(&self.focus_handle, cx);
             cx.notify();
         }
     }
@@ -987,7 +998,7 @@ impl KeymapEditor {
             });
 
             let context_menu_handle = context_menu.focus_handle(cx);
-            window.defer(cx, move |window, _cx| window.focus(&context_menu_handle));
+            window.defer(cx, move |window, cx| window.focus(&context_menu_handle, cx));
             let subscription = cx.subscribe_in(
                 &context_menu,
                 window,
@@ -1003,7 +1014,7 @@ impl KeymapEditor {
 
     fn dismiss_context_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.context_menu.take();
-        window.focus(&self.focus_handle);
+        window.focus(&self.focus_handle, cx);
         cx.notify();
     }
 
@@ -1219,7 +1230,7 @@ impl KeymapEditor {
                         window,
                         cx,
                     );
-                    window.focus(&modal.focus_handle(cx));
+                    window.focus(&modal.focus_handle(cx), cx);
                     modal
                 });
             })
@@ -1327,7 +1338,7 @@ impl KeymapEditor {
                     editor.stop_recording(&StopRecording, window, cx);
                     editor.clear_keystrokes(&ClearKeystrokes, window, cx);
                 });
-                window.focus(&self.filter_editor.focus_handle(cx));
+                window.focus(&self.filter_editor.focus_handle(cx), cx);
             }
         }
     }
@@ -2687,32 +2698,32 @@ impl KeybindingEditorModalFocusState {
             .map(|i| i as i32)
     }
 
-    fn focus_index(&self, mut index: i32, window: &mut Window) {
+    fn focus_index(&self, mut index: i32, window: &mut Window, cx: &mut App) {
         if index < 0 {
             index = self.handles.len() as i32 - 1;
         }
         if index >= self.handles.len() as i32 {
             index = 0;
         }
-        window.focus(&self.handles[index as usize]);
+        window.focus(&self.handles[index as usize], cx);
     }
 
-    fn focus_next(&self, window: &mut Window, cx: &App) {
+    fn focus_next(&self, window: &mut Window, cx: &mut App) {
         let index_to_focus = if let Some(index) = self.focused_index(window, cx) {
             index + 1
         } else {
             0
         };
-        self.focus_index(index_to_focus, window);
+        self.focus_index(index_to_focus, window, cx);
     }
 
-    fn focus_previous(&self, window: &mut Window, cx: &App) {
+    fn focus_previous(&self, window: &mut Window, cx: &mut App) {
         let index_to_focus = if let Some(index) = self.focused_index(window, cx) {
             index - 1
         } else {
             self.handles.len() as i32 - 1
         };
-        self.focus_index(index_to_focus, window);
+        self.focus_index(index_to_focus, window, cx);
     }
 }
 
@@ -2746,7 +2757,7 @@ impl ActionArgumentsEditor {
     ) -> Self {
         let focus_handle = cx.focus_handle();
         cx.on_focus_in(&focus_handle, window, |this, window, cx| {
-            this.editor.focus_handle(cx).focus(window);
+            this.editor.focus_handle(cx).focus(window, cx);
         })
         .detach();
         let editor = cx.new(|cx| {
@@ -2799,7 +2810,7 @@ impl ActionArgumentsEditor {
 
                 this.update_in(cx, |this, window, cx| {
                     if this.editor.focus_handle(cx).is_focused(window) {
-                        editor.focus_handle(cx).focus(window);
+                        editor.focus_handle(cx).focus(window, cx);
                     }
                     this.editor = editor;
                     this.backup_temp_dir = backup_temp_dir;

crates/keymap_editor/src/ui_components/keystroke_input.rs 🔗

@@ -388,7 +388,7 @@ impl KeystrokeInput {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        window.focus(&self.inner_focus_handle);
+        window.focus(&self.inner_focus_handle, cx);
         self.clear_keystrokes(&ClearKeystrokes, window, cx);
         self.previous_modifiers = window.modifiers();
         #[cfg(test)]
@@ -407,7 +407,7 @@ impl KeystrokeInput {
         if !self.is_recording(window) {
             return;
         }
-        window.focus(&self.outer_focus_handle);
+        window.focus(&self.outer_focus_handle, cx);
         if let Some(close_keystrokes_start) = self.close_keystrokes_start.take()
             && close_keystrokes_start < self.keystrokes.len()
         {

crates/language/Cargo.toml 🔗

@@ -32,6 +32,7 @@ async-trait.workspace = true
 clock.workspace = true
 collections.workspace = true
 ec4rs.workspace = true
+encoding_rs.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
@@ -48,6 +49,7 @@ rand = { workspace = true, optional = true }
 regex.workspace = true
 rpc.workspace = true
 schemars.workspace = true
+semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true

crates/language/src/buffer.rs 🔗

@@ -8,8 +8,8 @@ use crate::{
     outline::OutlineItem,
     row_chunk::RowChunks,
     syntax_map::{
-        SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatch,
-        SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint,
+        MAX_BYTES_TO_QUERY, SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures,
+        SyntaxMapMatch, SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint,
     },
     task_context::RunnableRange,
     text_diff::text_diff,
@@ -25,6 +25,7 @@ use anyhow::{Context as _, Result};
 use clock::Lamport;
 pub use clock::ReplicaId;
 use collections::{HashMap, HashSet};
+use encoding_rs::Encoding;
 use fs::MTime;
 use futures::channel::oneshot;
 use gpui::{
@@ -131,6 +132,8 @@ pub struct Buffer {
     change_bits: Vec<rc::Weak<Cell<bool>>>,
     _subscriptions: Vec<gpui::Subscription>,
     tree_sitter_data: Arc<TreeSitterData>,
+    encoding: &'static Encoding,
+    has_bom: bool,
 }
 
 #[derive(Debug)]
@@ -424,6 +427,9 @@ pub enum DiskState {
     Present { mtime: MTime },
     /// Deleted file that was previously present.
     Deleted,
+    /// An old version of a file that was previously present
+    /// usually from a version control system. e.g. A git blob
+    Historic { was_deleted: bool },
 }
 
 impl DiskState {
@@ -433,6 +439,7 @@ impl DiskState {
             DiskState::New => None,
             DiskState::Present { mtime } => Some(mtime),
             DiskState::Deleted => None,
+            DiskState::Historic { .. } => None,
         }
     }
 
@@ -441,6 +448,16 @@ impl DiskState {
             DiskState::New => false,
             DiskState::Present { .. } => true,
             DiskState::Deleted => false,
+            DiskState::Historic { .. } => false,
+        }
+    }
+
+    /// Returns true if this state represents a deleted file.
+    pub fn is_deleted(&self) -> bool {
+        match self {
+            DiskState::Deleted => true,
+            DiskState::Historic { was_deleted } => *was_deleted,
+            _ => false,
         }
     }
 }
@@ -1100,6 +1117,8 @@ impl Buffer {
             has_conflict: false,
             change_bits: Default::default(),
             _subscriptions: Vec::new(),
+            encoding: encoding_rs::UTF_8,
+            has_bom: false,
         }
     }
 
@@ -1383,6 +1402,26 @@ impl Buffer {
         self.saved_mtime
     }
 
+    /// Returns the character encoding of the buffer's file.
+    pub fn encoding(&self) -> &'static Encoding {
+        self.encoding
+    }
+
+    /// Sets the character encoding of the buffer.
+    pub fn set_encoding(&mut self, encoding: &'static Encoding) {
+        self.encoding = encoding;
+    }
+
+    /// Returns whether the buffer has a Byte Order Mark.
+    pub fn has_bom(&self) -> bool {
+        self.has_bom
+    }
+
+    /// Sets whether the buffer has a Byte Order Mark.
+    pub fn set_has_bom(&mut self, has_bom: bool) {
+        self.has_bom = has_bom;
+    }
+
     /// Assign a language to the buffer.
     pub fn set_language_async(&mut self, language: Option<Arc<Language>>, cx: &mut Context<Self>) {
         self.set_language_(language, cfg!(any(test, feature = "test-support")), cx);
@@ -1465,19 +1504,23 @@ impl Buffer {
         let (tx, rx) = futures::channel::oneshot::channel();
         let prev_version = self.text.version();
         self.reload_task = Some(cx.spawn(async move |this, cx| {
-            let Some((new_mtime, new_text)) = this.update(cx, |this, cx| {
+            let Some((new_mtime, load_bytes_task, encoding)) = this.update(cx, |this, cx| {
                 let file = this.file.as_ref()?.as_local()?;
-
-                Some((file.disk_state().mtime(), file.load(cx)))
+                Some((
+                    file.disk_state().mtime(),
+                    file.load_bytes(cx),
+                    this.encoding,
+                ))
             })?
             else {
                 return Ok(());
             };
 
-            let new_text = new_text.await?;
-            let diff = this
-                .update(cx, |this, cx| this.diff(new_text.clone(), cx))?
-                .await;
+            let bytes = load_bytes_task.await?;
+            let (cow, _encoding_used, _has_errors) = encoding.decode(&bytes);
+            let new_text = cow.into_owned();
+
+            let diff = this.update(cx, |this, cx| this.diff(new_text, cx))?.await;
             this.update(cx, |this, cx| {
                 if this.version() == diff.base_version {
                     this.finalize_last_transaction();
@@ -1776,9 +1819,7 @@ impl Buffer {
         self.syntax_map.lock().did_parse(syntax_snapshot);
         self.request_autoindent(cx);
         self.parse_status.0.send(ParseStatus::Idle).unwrap();
-        if self.text.version() != *self.tree_sitter_data.version() {
-            self.invalidate_tree_sitter_data(self.text.snapshot());
-        }
+        self.invalidate_tree_sitter_data(self.text.snapshot());
         cx.emit(BufferEvent::Reparsed);
         cx.notify();
     }
@@ -2247,6 +2288,7 @@ impl Buffer {
                 None => true,
             },
             DiskState::Deleted => false,
+            DiskState::Historic { .. } => false,
         }
     }
 
@@ -3216,15 +3258,22 @@ impl BufferSnapshot {
         struct StartPosition {
             start: Point,
             suffix: SharedString,
+            language: Arc<Language>,
         }
 
         // Find the suggested indentation ranges based on the syntax tree.
         let start = Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0);
         let end = Point::new(row_range.end, 0);
         let range = (start..end).to_offset(&self.text);
-        let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| {
-            Some(&grammar.indents_config.as_ref()?.query)
-        });
+        let mut matches = self.syntax.matches_with_options(
+            range.clone(),
+            &self.text,
+            TreeSitterOptions {
+                max_bytes_to_query: Some(MAX_BYTES_TO_QUERY),
+                max_start_depth: None,
+            },
+            |grammar| Some(&grammar.indents_config.as_ref()?.query),
+        );
         let indent_configs = matches
             .grammars()
             .iter()
@@ -3253,6 +3302,7 @@ impl BufferSnapshot {
                     start_positions.push(StartPosition {
                         start: Point::from_ts_point(capture.node.start_position()),
                         suffix: suffix.clone(),
+                        language: mat.language.clone(),
                     });
                 }
             }
@@ -3303,8 +3353,7 @@ impl BufferSnapshot {
             // set its end to the outdent position
             if let Some(range_to_truncate) = indent_ranges
                 .iter_mut()
-                .filter(|indent_range| indent_range.contains(&outdent_position))
-                .next_back()
+                .rfind(|indent_range| indent_range.contains(&outdent_position))
             {
                 range_to_truncate.end = outdent_position;
             }
@@ -3314,7 +3363,7 @@ impl BufferSnapshot {
 
         // Find the suggested indentation increases and decreased based on regexes.
         let mut regex_outdent_map = HashMap::default();
-        let mut last_seen_suffix: HashMap<String, Vec<Point>> = HashMap::default();
+        let mut last_seen_suffix: HashMap<String, Vec<StartPosition>> = HashMap::default();
         let mut start_positions_iter = start_positions.iter().peekable();
 
         let mut indent_change_rows = Vec::<(u32, Ordering)>::new();
@@ -3322,14 +3371,21 @@ impl BufferSnapshot {
             Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0)
                 ..Point::new(row_range.end, 0),
             |row, line| {
-                if config
+                let indent_len = self.indent_size_for_line(row).len;
+                let row_language = self.language_at(Point::new(row, indent_len)).cloned();
+                let row_language_config = row_language
+                    .as_ref()
+                    .map(|lang| lang.config())
+                    .unwrap_or(config);
+
+                if row_language_config
                     .decrease_indent_pattern
                     .as_ref()
                     .is_some_and(|regex| regex.is_match(line))
                 {
                     indent_change_rows.push((row, Ordering::Less));
                 }
-                if config
+                if row_language_config
                     .increase_indent_pattern
                     .as_ref()
                     .is_some_and(|regex| regex.is_match(line))
@@ -3338,16 +3394,16 @@ impl BufferSnapshot {
                 }
                 while let Some(pos) = start_positions_iter.peek() {
                     if pos.start.row < row {
-                        let pos = start_positions_iter.next().unwrap();
+                        let pos = start_positions_iter.next().unwrap().clone();
                         last_seen_suffix
                             .entry(pos.suffix.to_string())
                             .or_default()
-                            .push(pos.start);
+                            .push(pos);
                     } else {
                         break;
                     }
                 }
-                for rule in &config.decrease_indent_patterns {
+                for rule in &row_language_config.decrease_indent_patterns {
                     if rule.pattern.as_ref().is_some_and(|r| r.is_match(line)) {
                         let row_start_column = self.indent_size_for_line(row).len;
                         let basis_row = rule
@@ -3355,10 +3411,16 @@ impl BufferSnapshot {
                             .iter()
                             .filter_map(|valid_suffix| last_seen_suffix.get(valid_suffix))
                             .flatten()
-                            .filter(|start_point| start_point.column <= row_start_column)
-                            .max_by_key(|start_point| start_point.row);
-                        if let Some(outdent_to_row) = basis_row {
-                            regex_outdent_map.insert(row, outdent_to_row.row);
+                            .filter(|pos| {
+                                row_language
+                                    .as_ref()
+                                    .or(self.language.as_ref())
+                                    .is_some_and(|lang| Arc::ptr_eq(lang, &pos.language))
+                            })
+                            .filter(|pos| pos.start.column <= row_start_column)
+                            .max_by_key(|pos| pos.start.row);
+                        if let Some(outdent_to) = basis_row {
+                            regex_outdent_map.insert(row, outdent_to.start.row);
                         }
                         break;
                     }
@@ -4317,14 +4379,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) =
@@ -4338,11 +4398,15 @@ impl BufferSnapshot {
             let mut opens = Vec::new();
             let mut color_pairs = Vec::new();
 
-            let mut matches = self
-                .syntax
-                .matches(chunk_range.clone(), &self.text, |grammar| {
-                    grammar.brackets_config.as_ref().map(|c| &c.query)
-                });
+            let mut matches = self.syntax.matches_with_options(
+                chunk_range.clone(),
+                &self.text,
+                TreeSitterOptions {
+                    max_bytes_to_query: Some(MAX_BYTES_TO_QUERY),
+                    max_start_depth: None,
+                },
+                |grammar| grammar.brackets_config.as_ref().map(|c| &c.query),
+            );
             let configs = matches
                 .grammars()
                 .iter()

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/src/buffer_tests.rs 🔗

@@ -1141,6 +1141,104 @@ fn test_text_objects(cx: &mut App) {
     )
 }
 
+#[gpui::test]
+fn test_text_objects_with_has_parent_predicate(cx: &mut App) {
+    use std::borrow::Cow;
+
+    // Create a language with a custom text_objects query that uses #has-parent?
+    // This query only matches closure_expression when it's inside a call_expression
+    let language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            matcher: LanguageMatcher {
+                path_suffixes: vec!["rs".to_string()],
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::LANGUAGE.into()),
+    )
+    .with_queries(LanguageQueries {
+        text_objects: Some(Cow::from(indoc! {r#"
+            ; Only match closures that are arguments to function calls
+            (closure_expression) @function.around
+              (#has-parent? @function.around arguments)
+        "#})),
+        ..Default::default()
+    })
+    .expect("Could not parse queries");
+
+    let (text, ranges) = marked_text_ranges(
+        indoc! {r#"
+            fn main() {
+                let standalone = |x| x + 1;
+                let result = foo(|y| y * ˇ2);
+            }"#
+        },
+        false,
+    );
+
+    let buffer = cx.new(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(language), cx));
+    let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
+
+    let matches = snapshot
+        .text_object_ranges(ranges[0].clone(), TreeSitterOptions::default())
+        .map(|(range, text_object)| (&text[range], text_object))
+        .collect::<Vec<_>>();
+
+    // Should only match the closure inside foo(), not the standalone closure
+    assert_eq!(matches, &[("|y| y * 2", TextObject::AroundFunction),]);
+}
+
+#[gpui::test]
+fn test_text_objects_with_not_has_parent_predicate(cx: &mut App) {
+    use std::borrow::Cow;
+
+    // Create a language with a custom text_objects query that uses #not-has-parent?
+    // This query only matches closure_expression when it's NOT inside a call_expression
+    let language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            matcher: LanguageMatcher {
+                path_suffixes: vec!["rs".to_string()],
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::LANGUAGE.into()),
+    )
+    .with_queries(LanguageQueries {
+        text_objects: Some(Cow::from(indoc! {r#"
+            ; Only match closures that are NOT arguments to function calls
+            (closure_expression) @function.around
+              (#not-has-parent? @function.around arguments)
+        "#})),
+        ..Default::default()
+    })
+    .expect("Could not parse queries");
+
+    let (text, ranges) = marked_text_ranges(
+        indoc! {r#"
+            fn main() {
+                let standalone = |x| x +ˇ 1;
+                let result = foo(|y| y * 2);
+            }"#
+        },
+        false,
+    );
+
+    let buffer = cx.new(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(language), cx));
+    let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
+
+    let matches = snapshot
+        .text_object_ranges(ranges[0].clone(), TreeSitterOptions::default())
+        .map(|(range, text_object)| (&text[range], text_object))
+        .collect::<Vec<_>>();
+
+    // Should only match the standalone closure, not the one inside foo()
+    assert_eq!(matches, &[("|x| x + 1", TextObject::AroundFunction),]);
+}
+
 #[gpui::test]
 fn test_enclosing_bracket_ranges(cx: &mut App) {
     #[track_caller]

crates/language/src/language.rs 🔗

@@ -43,6 +43,7 @@ pub use manifest::{ManifestDelegate, ManifestName, ManifestProvider, ManifestQue
 use parking_lot::Mutex;
 use regex::Regex;
 use schemars::{JsonSchema, SchemaGenerator, json_schema};
+use semver::Version;
 use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
 use serde_json::Value;
 use settings::WorktreeId;
@@ -329,6 +330,10 @@ impl CachedLspAdapter {
             .cloned()
             .unwrap_or_else(|| language_name.lsp_id())
     }
+
+    pub fn process_prompt_response(&self, context: &PromptResponseContext, cx: &mut AsyncApp) {
+        self.adapter.process_prompt_response(context, cx)
+    }
 }
 
 /// [`LspAdapterDelegate`] allows [`LspAdapter]` implementations to interface with the application
@@ -347,13 +352,24 @@ pub trait LspAdapterDelegate: Send + Sync {
     async fn npm_package_installed_version(
         &self,
         package_name: &str,
-    ) -> Result<Option<(PathBuf, String)>>;
+    ) -> Result<Option<(PathBuf, Version)>>;
     async fn which(&self, command: &OsStr) -> Option<PathBuf>;
     async fn shell_env(&self) -> HashMap<String, String>;
     async fn read_text_file(&self, path: &RelPath) -> Result<String>;
     async fn try_exec(&self, binary: LanguageServerBinary) -> Result<()>;
 }
 
+/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt.
+/// This allows adapters to intercept preference selections (like "Always" or "Never")
+/// and potentially persist them to Zed's settings.
+#[derive(Debug, Clone)]
+pub struct PromptResponseContext {
+    /// The original message shown to the user
+    pub message: String,
+    /// The action (button) the user selected
+    pub selected_action: lsp::MessageActionItem,
+}
+
 #[async_trait(?Send)]
 pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
     fn name(&self) -> LanguageServerName;
@@ -510,6 +526,11 @@ pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
     fn is_extension(&self) -> bool {
         false
     }
+
+    /// Called when a user responds to a ShowMessageRequest from this language server.
+    /// This allows adapters to intercept preference selections (like "Always" or "Never")
+    /// for settings that should be persisted to Zed's settings file.
+    fn process_prompt_response(&self, _context: &PromptResponseContext, _cx: &mut AsyncApp) {}
 }
 
 pub trait LspInstaller {
@@ -806,6 +827,15 @@ pub struct LanguageConfig {
     /// Delimiters and configuration for recognizing and formatting documentation comments.
     #[serde(default, alias = "documentation")]
     pub documentation_comment: Option<BlockCommentConfig>,
+    /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
+    #[serde(default)]
+    pub unordered_list: Vec<Arc<str>>,
+    /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `).
+    #[serde(default)]
+    pub ordered_list: Vec<OrderedListConfig>,
+    /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `).
+    #[serde(default)]
+    pub task_list: Option<TaskListConfig>,
     /// A list of additional regex patterns that should be treated as prefixes
     /// for creating boundaries during rewrapping, ensuring content from one
     /// prefixed section doesn't merge with another (e.g., markdown list items).
@@ -877,6 +907,24 @@ pub struct DecreaseIndentConfig {
     pub valid_after: Vec<String>,
 }
 
+/// Configuration for continuing ordered lists with auto-incrementing numbers.
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct OrderedListConfig {
+    /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `).
+    pub pattern: String,
+    /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `).
+    pub format: String,
+}
+
+/// Configuration for continuing task lists on newline.
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct TaskListConfig {
+    /// The list markers to match (e.g., `- [ ] `, `- [x] `).
+    pub prefixes: Vec<Arc<str>>,
+    /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `).
+    pub continuation: Arc<str>,
+}
+
 #[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
 pub struct LanguageMatcher {
     /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`.
@@ -1047,6 +1095,9 @@ impl Default for LanguageConfig {
             line_comments: Default::default(),
             block_comment: Default::default(),
             documentation_comment: Default::default(),
+            unordered_list: Default::default(),
+            ordered_list: Default::default(),
+            task_list: Default::default(),
             rewrap_prefixes: Default::default(),
             scope_opt_in_language_servers: Default::default(),
             overrides: Default::default(),
@@ -2132,6 +2183,21 @@ impl LanguageScope {
         self.language.config.documentation_comment.as_ref()
     }
 
+    /// Returns list markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
+    pub fn unordered_list(&self) -> &[Arc<str>] {
+        &self.language.config.unordered_list
+    }
+
+    /// Returns configuration for ordered lists with auto-incrementing numbers (e.g., `1. ` becomes `2. `).
+    pub fn ordered_list(&self) -> &[OrderedListConfig] {
+        &self.language.config.ordered_list
+    }
+
+    /// Returns configuration for task list continuation, if any (e.g., `- [x] ` continues as `- [ ] `).
+    pub fn task_list(&self) -> Option<&TaskListConfig> {
+        self.language.config.task_list.as_ref()
+    }
+
     /// Returns additional regex patterns that act as prefix markers for creating
     /// boundaries during rewrapping.
     ///
@@ -2425,7 +2491,10 @@ impl CodeLabel {
             "invalid filter range"
         );
         runs.iter().for_each(|(range, _)| {
-            assert!(text.get(range.clone()).is_some(), "invalid run range");
+            assert!(
+                text.get(range.clone()).is_some(),
+                "invalid run range with inputs. Requested range {range:?} in text '{text}'",
+            );
         });
         Self {
             runs,

crates/language/src/language_settings.rs 🔗

@@ -122,6 +122,10 @@ pub struct LanguageSettings {
     pub whitespace_map: WhitespaceMap,
     /// Whether to start a new line with a comment when a previous line is a comment as well.
     pub extend_comment_on_newline: bool,
+    /// Whether to continue markdown lists when pressing enter.
+    pub extend_list_on_newline: bool,
+    /// Whether to indent list items when pressing tab after a list marker.
+    pub indent_list_on_tab: bool,
     /// Inlay hint related settings.
     pub inlay_hints: InlayHintSettings,
     /// Whether to automatically close brackets.
@@ -567,6 +571,8 @@ impl settings::Settings for AllLanguageSettings {
                     tab: SharedString::new(whitespace_map.tab.unwrap().to_string()),
                 },
                 extend_comment_on_newline: settings.extend_comment_on_newline.unwrap(),
+                extend_list_on_newline: settings.extend_list_on_newline.unwrap(),
+                indent_list_on_tab: settings.indent_list_on_tab.unwrap(),
                 inlay_hints: InlayHintSettings {
                     enabled: inlay_hints.enabled.unwrap(),
                     show_value_hints: inlay_hints.show_value_hints.unwrap(),

crates/language/src/syntax_map.rs 🔗

@@ -19,7 +19,12 @@ use std::{
 use streaming_iterator::StreamingIterator;
 use sum_tree::{Bias, Dimensions, SeekTarget, SumTree};
 use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point, Rope, ToOffset, ToPoint};
-use tree_sitter::{Node, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatches, Tree};
+use tree_sitter::{
+    Node, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatch, QueryMatches,
+    QueryPredicateArg, Tree,
+};
+
+pub const MAX_BYTES_TO_QUERY: usize = 16 * 1024;
 
 pub struct SyntaxMap {
     snapshot: SyntaxSnapshot,
@@ -80,6 +85,7 @@ struct SyntaxMapMatchesLayer<'a> {
     next_captures: Vec<QueryCapture<'a>>,
     has_next: bool,
     matches: QueryMatches<'a, 'a, TextProvider<'a>, &'a [u8]>,
+    query: &'a Query,
     grammar_index: usize,
     _query_cursor: QueryCursorHandle,
 }
@@ -1096,12 +1102,15 @@ impl<'a> SyntaxMapCaptures<'a> {
 
 #[derive(Default)]
 pub struct TreeSitterOptions {
-    max_start_depth: Option<u32>,
+    pub max_start_depth: Option<u32>,
+    pub max_bytes_to_query: Option<usize>,
 }
+
 impl TreeSitterOptions {
     pub fn max_start_depth(max_start_depth: u32) -> Self {
         Self {
             max_start_depth: Some(max_start_depth),
+            max_bytes_to_query: None,
         }
     }
 }
@@ -1135,6 +1144,14 @@ impl<'a> SyntaxMapMatches<'a> {
             };
             cursor.set_max_start_depth(options.max_start_depth);
 
+            if let Some(max_bytes_to_query) = options.max_bytes_to_query {
+                let midpoint = (range.start + range.end) / 2;
+                let containing_range_start = midpoint.saturating_sub(max_bytes_to_query / 2);
+                let containing_range_end =
+                    containing_range_start.saturating_add(max_bytes_to_query);
+                cursor.set_containing_byte_range(containing_range_start..containing_range_end);
+            }
+
             cursor.set_byte_range(range.clone());
             let matches = cursor.matches(query, layer.node(), TextProvider(text));
             let grammar_index = result
@@ -1150,6 +1167,7 @@ impl<'a> SyntaxMapMatches<'a> {
                 depth: layer.depth,
                 grammar_index,
                 matches,
+                query,
                 next_pattern_index: 0,
                 next_captures: Vec::new(),
                 has_next: false,
@@ -1247,13 +1265,20 @@ impl SyntaxMapCapturesLayer<'_> {
 
 impl SyntaxMapMatchesLayer<'_> {
     fn advance(&mut self) {
-        if let Some(mat) = self.matches.next() {
-            self.next_captures.clear();
-            self.next_captures.extend_from_slice(mat.captures);
-            self.next_pattern_index = mat.pattern_index;
-            self.has_next = true;
-        } else {
-            self.has_next = false;
+        loop {
+            if let Some(mat) = self.matches.next() {
+                if !satisfies_custom_predicates(self.query, mat) {
+                    continue;
+                }
+                self.next_captures.clear();
+                self.next_captures.extend_from_slice(mat.captures);
+                self.next_pattern_index = mat.pattern_index;
+                self.has_next = true;
+                return;
+            } else {
+                self.has_next = false;
+                return;
+            }
         }
     }
 
@@ -1282,6 +1307,39 @@ impl<'a> Iterator for SyntaxMapCaptures<'a> {
     }
 }
 
+fn satisfies_custom_predicates(query: &Query, mat: &QueryMatch) -> bool {
+    for predicate in query.general_predicates(mat.pattern_index) {
+        let satisfied = match predicate.operator.as_ref() {
+            "has-parent?" => has_parent(&predicate.args, mat),
+            "not-has-parent?" => !has_parent(&predicate.args, mat),
+            _ => true,
+        };
+        if !satisfied {
+            return false;
+        }
+    }
+    true
+}
+
+fn has_parent(args: &[QueryPredicateArg], mat: &QueryMatch) -> bool {
+    let (
+        Some(QueryPredicateArg::Capture(capture_ix)),
+        Some(QueryPredicateArg::String(parent_kind)),
+    ) = (args.first(), args.get(1))
+    else {
+        return false;
+    };
+
+    let Some(capture) = mat.captures.iter().find(|c| c.index == *capture_ix) else {
+        return false;
+    };
+
+    capture
+        .node
+        .parent()
+        .is_some_and(|p| p.kind() == parent_kind.as_ref())
+}
+
 fn join_ranges(
     a: impl Iterator<Item = Range<usize>>,
     b: impl Iterator<Item = Range<usize>>,
@@ -1642,6 +1700,10 @@ impl<'a> SyntaxLayer<'a> {
 
         let mut query_cursor = QueryCursorHandle::new();
         query_cursor.set_byte_range(offset.saturating_sub(1)..offset.saturating_add(1));
+        query_cursor.set_containing_byte_range(
+            offset.saturating_sub(MAX_BYTES_TO_QUERY / 2)
+                ..offset.saturating_add(MAX_BYTES_TO_QUERY / 2),
+        );
 
         let mut smallest_match: Option<(u32, Range<usize>)> = None;
         let mut matches = query_cursor.matches(&config.query, self.node(), text);
@@ -1928,6 +1990,8 @@ impl Drop for QueryCursorHandle {
         let mut cursor = self.0.take().unwrap();
         cursor.set_byte_range(0..usize::MAX);
         cursor.set_point_range(Point::zero().to_ts_point()..Point::MAX.to_ts_point());
+        cursor.set_containing_byte_range(0..usize::MAX);
+        cursor.set_containing_point_range(Point::zero().to_ts_point()..Point::MAX.to_ts_point());
         QUERY_CURSORS.lock().push(cursor)
     }
 }

crates/language/src/syntax_map/syntax_map_tests.rs 🔗

@@ -1133,8 +1133,8 @@ fn check_interpolation(
             check_node_edits(
                 depth,
                 range,
-                old_node.child(i).unwrap(),
-                new_node.child(i).unwrap(),
+                old_node.child(i as u32).unwrap(),
+                new_node.child(i as u32).unwrap(),
                 old_buffer,
                 new_buffer,
                 edits,

crates/language/src/text_diff.rs 🔗

@@ -48,7 +48,6 @@ pub fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range<usize>, Arc<str>)
 ///
 /// Returns a tuple of (old_ranges, new_ranges) where each vector contains
 /// the byte ranges of changed words in the respective text.
-/// Whitespace-only changes are excluded from the results.
 pub fn word_diff_ranges(
     old_text: &str,
     new_text: &str,
@@ -62,23 +61,23 @@ pub fn word_diff_ranges(
     let mut new_ranges: Vec<Range<usize>> = Vec::new();
 
     diff_internal(&input, |old_byte_range, new_byte_range, _, _| {
-        for range in split_on_whitespace(old_text, &old_byte_range) {
+        if !old_byte_range.is_empty() {
             if let Some(last) = old_ranges.last_mut()
-                && last.end >= range.start
+                && last.end >= old_byte_range.start
             {
-                last.end = range.end;
+                last.end = old_byte_range.end;
             } else {
-                old_ranges.push(range);
+                old_ranges.push(old_byte_range);
             }
         }
 
-        for range in split_on_whitespace(new_text, &new_byte_range) {
+        if !new_byte_range.is_empty() {
             if let Some(last) = new_ranges.last_mut()
-                && last.end >= range.start
+                && last.end >= new_byte_range.start
             {
-                last.end = range.end;
+                last.end = new_byte_range.end;
             } else {
-                new_ranges.push(range);
+                new_ranges.push(new_byte_range);
             }
         }
     });
@@ -86,50 +85,6 @@ pub fn word_diff_ranges(
     (old_ranges, new_ranges)
 }
 
-fn split_on_whitespace(text: &str, range: &Range<usize>) -> Vec<Range<usize>> {
-    if range.is_empty() {
-        return Vec::new();
-    }
-
-    let slice = &text[range.clone()];
-    let mut ranges = Vec::new();
-    let mut offset = 0;
-
-    for line in slice.lines() {
-        let line_start = offset;
-        let line_end = line_start + line.len();
-        offset = line_end + 1;
-        let trimmed = line.trim();
-
-        if !trimmed.is_empty() {
-            let leading = line.len() - line.trim_start().len();
-            let trailing = line.len() - line.trim_end().len();
-            let trimmed_start = range.start + line_start + leading;
-            let trimmed_end = range.start + line_end - trailing;
-
-            let original_line_start = text[..range.start + line_start]
-                .rfind('\n')
-                .map(|i| i + 1)
-                .unwrap_or(0);
-            let original_line_end = text[range.start + line_start..]
-                .find('\n')
-                .map(|i| range.start + line_start + i)
-                .unwrap_or(text.len());
-            let original_line = &text[original_line_start..original_line_end];
-            let original_trimmed_start =
-                original_line_start + (original_line.len() - original_line.trim_start().len());
-            let original_trimmed_end =
-                original_line_end - (original_line.len() - original_line.trim_end().len());
-
-            if trimmed_start > original_trimmed_start || trimmed_end < original_trimmed_end {
-                ranges.push(trimmed_start..trimmed_end);
-            }
-        }
-    }
-
-    ranges
-}
-
 pub struct DiffOptions {
     pub language_scope: Option<LanguageScope>,
     pub max_word_diff_len: usize,

crates/language/src/toolchain.rs 🔗

@@ -4,7 +4,10 @@
 //! which is a set of tools used to interact with the projects written in said language.
 //! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override.
 
-use std::{path::PathBuf, sync::Arc};
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 
 use async_trait::async_trait;
 use collections::HashMap;
@@ -36,7 +39,7 @@ pub struct Toolchain {
 /// - Only in the subproject they're currently in.
 #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
 pub enum ToolchainScope {
-    Subproject(WorktreeId, Arc<RelPath>),
+    Subproject(Arc<Path>, Arc<RelPath>),
     Project,
     /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines.
     Global,

crates/language_model/src/language_model.rs 🔗

@@ -797,11 +797,26 @@ pub enum AuthenticateError {
     Other(#[from] anyhow::Error),
 }
 
+/// Either a built-in icon name or a path to an external SVG.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum IconOrSvg {
+    /// A built-in icon from Zed's icon set.
+    Icon(IconName),
+    /// Path to a custom SVG icon file.
+    Svg(SharedString),
+}
+
+impl Default for IconOrSvg {
+    fn default() -> Self {
+        Self::Icon(IconName::ZedAssistant)
+    }
+}
+
 pub trait LanguageModelProvider: 'static {
     fn id(&self) -> LanguageModelProviderId;
     fn name(&self) -> LanguageModelProviderName;
-    fn icon(&self) -> IconName {
-        IconName::ZedAssistant
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::default()
     }
     fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>>;
     fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>>;
@@ -820,7 +835,7 @@ pub trait LanguageModelProvider: 'static {
     fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>>;
 }
 
-#[derive(Default, Clone)]
+#[derive(Default, Clone, PartialEq, Eq)]
 pub enum ConfigurationViewTargetAgent {
     #[default]
     ZedAgent,

crates/language_model/src/registry.rs 🔗

@@ -2,12 +2,16 @@ use crate::{
     LanguageModel, LanguageModelId, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderState,
 };
-use collections::BTreeMap;
+use collections::{BTreeMap, HashSet};
 use gpui::{App, Context, Entity, EventEmitter, Global, prelude::*};
 use std::{str::FromStr, sync::Arc};
 use thiserror::Error;
 use util::maybe;
 
+/// Function type for checking if a built-in provider should be hidden.
+/// Returns Some(extension_id) if the provider should be hidden when that extension is installed.
+pub type BuiltinProviderHidingFn = Box<dyn Fn(&str) -> Option<&'static str> + Send + Sync>;
+
 pub fn init(cx: &mut App) {
     let registry = cx.new(|_cx| LanguageModelRegistry::default());
     cx.set_global(GlobalLanguageModelRegistry(registry));
@@ -48,6 +52,11 @@ pub struct LanguageModelRegistry {
     thread_summary_model: Option<ConfiguredModel>,
     providers: BTreeMap<LanguageModelProviderId, Arc<dyn LanguageModelProvider>>,
     inline_alternatives: Vec<Arc<dyn LanguageModel>>,
+    /// Set of installed extension IDs that provide language models.
+    /// Used to determine which built-in providers should be hidden.
+    installed_llm_extension_ids: HashSet<Arc<str>>,
+    /// Function to check if a built-in provider should be hidden by an extension.
+    builtin_provider_hiding_fn: Option<BuiltinProviderHidingFn>,
 }
 
 #[derive(Debug)]
@@ -104,6 +113,8 @@ pub enum Event {
     ProviderStateChanged(LanguageModelProviderId),
     AddedProvider(LanguageModelProviderId),
     RemovedProvider(LanguageModelProviderId),
+    /// Emitted when provider visibility changes due to extension install/uninstall.
+    ProvidersChanged,
 }
 
 impl EventEmitter<Event> for LanguageModelRegistry {}
@@ -183,6 +194,60 @@ impl LanguageModelRegistry {
         providers
     }
 
+    /// Returns providers, filtering out hidden built-in providers.
+    pub fn visible_providers(&self) -> Vec<Arc<dyn LanguageModelProvider>> {
+        self.providers()
+            .into_iter()
+            .filter(|p| !self.should_hide_provider(&p.id()))
+            .collect()
+    }
+
+    /// Sets the function used to check if a built-in provider should be hidden.
+    pub fn set_builtin_provider_hiding_fn(&mut self, hiding_fn: BuiltinProviderHidingFn) {
+        self.builtin_provider_hiding_fn = Some(hiding_fn);
+    }
+
+    /// Called when an extension is installed/loaded.
+    /// If the extension provides language models, track it so we can hide the corresponding built-in.
+    pub fn extension_installed(&mut self, extension_id: Arc<str>, cx: &mut Context<Self>) {
+        if self.installed_llm_extension_ids.insert(extension_id) {
+            cx.emit(Event::ProvidersChanged);
+            cx.notify();
+        }
+    }
+
+    /// Called when an extension is uninstalled/unloaded.
+    pub fn extension_uninstalled(&mut self, extension_id: &str, cx: &mut Context<Self>) {
+        if self.installed_llm_extension_ids.remove(extension_id) {
+            cx.emit(Event::ProvidersChanged);
+            cx.notify();
+        }
+    }
+
+    /// Sync the set of installed LLM extension IDs.
+    pub fn sync_installed_llm_extensions(
+        &mut self,
+        extension_ids: HashSet<Arc<str>>,
+        cx: &mut Context<Self>,
+    ) {
+        if extension_ids != self.installed_llm_extension_ids {
+            self.installed_llm_extension_ids = extension_ids;
+            cx.emit(Event::ProvidersChanged);
+            cx.notify();
+        }
+    }
+
+    /// Returns true if a provider should be hidden from the UI.
+    /// Built-in providers are hidden when their corresponding extension is installed.
+    pub fn should_hide_provider(&self, provider_id: &LanguageModelProviderId) -> bool {
+        if let Some(ref hiding_fn) = self.builtin_provider_hiding_fn {
+            if let Some(extension_id) = hiding_fn(&provider_id.0) {
+                return self.installed_llm_extension_ids.contains(extension_id);
+            }
+        }
+        false
+    }
+
     pub fn configuration_error(
         &self,
         model: Option<ConfiguredModel>,
@@ -416,4 +481,132 @@ mod tests {
         let providers = registry.read(cx).providers();
         assert!(providers.is_empty());
     }
+
+    #[gpui::test]
+    fn test_provider_hiding_on_extension_install(cx: &mut App) {
+        let registry = cx.new(|_| LanguageModelRegistry::default());
+
+        let provider = Arc::new(FakeLanguageModelProvider::default());
+        let provider_id = provider.id();
+
+        registry.update(cx, |registry, cx| {
+            registry.register_provider(provider.clone(), cx);
+
+            registry.set_builtin_provider_hiding_fn(Box::new(|id| {
+                if id == "fake" {
+                    Some("fake-extension")
+                } else {
+                    None
+                }
+            }));
+        });
+
+        let visible = registry.read(cx).visible_providers();
+        assert_eq!(visible.len(), 1);
+        assert_eq!(visible[0].id(), provider_id);
+
+        registry.update(cx, |registry, cx| {
+            registry.extension_installed("fake-extension".into(), cx);
+        });
+
+        let visible = registry.read(cx).visible_providers();
+        assert!(visible.is_empty());
+
+        let all = registry.read(cx).providers();
+        assert_eq!(all.len(), 1);
+    }
+
+    #[gpui::test]
+    fn test_provider_unhiding_on_extension_uninstall(cx: &mut App) {
+        let registry = cx.new(|_| LanguageModelRegistry::default());
+
+        let provider = Arc::new(FakeLanguageModelProvider::default());
+        let provider_id = provider.id();
+
+        registry.update(cx, |registry, cx| {
+            registry.register_provider(provider.clone(), cx);
+
+            registry.set_builtin_provider_hiding_fn(Box::new(|id| {
+                if id == "fake" {
+                    Some("fake-extension")
+                } else {
+                    None
+                }
+            }));
+
+            registry.extension_installed("fake-extension".into(), cx);
+        });
+
+        let visible = registry.read(cx).visible_providers();
+        assert!(visible.is_empty());
+
+        registry.update(cx, |registry, cx| {
+            registry.extension_uninstalled("fake-extension", cx);
+        });
+
+        let visible = registry.read(cx).visible_providers();
+        assert_eq!(visible.len(), 1);
+        assert_eq!(visible[0].id(), provider_id);
+    }
+
+    #[gpui::test]
+    fn test_should_hide_provider(cx: &mut App) {
+        let registry = cx.new(|_| LanguageModelRegistry::default());
+
+        registry.update(cx, |registry, cx| {
+            registry.set_builtin_provider_hiding_fn(Box::new(|id| {
+                if id == "anthropic" {
+                    Some("anthropic")
+                } else if id == "openai" {
+                    Some("openai")
+                } else {
+                    None
+                }
+            }));
+
+            registry.extension_installed("anthropic".into(), cx);
+        });
+
+        let registry_read = registry.read(cx);
+
+        assert!(registry_read.should_hide_provider(&LanguageModelProviderId("anthropic".into())));
+
+        assert!(!registry_read.should_hide_provider(&LanguageModelProviderId("openai".into())));
+
+        assert!(!registry_read.should_hide_provider(&LanguageModelProviderId("unknown".into())));
+    }
+
+    #[gpui::test]
+    fn test_sync_installed_llm_extensions(cx: &mut App) {
+        let registry = cx.new(|_| LanguageModelRegistry::default());
+
+        let provider = Arc::new(FakeLanguageModelProvider::default());
+
+        registry.update(cx, |registry, cx| {
+            registry.register_provider(provider.clone(), cx);
+
+            registry.set_builtin_provider_hiding_fn(Box::new(|id| {
+                if id == "fake" {
+                    Some("fake-extension")
+                } else {
+                    None
+                }
+            }));
+        });
+
+        let mut extension_ids = HashSet::default();
+        extension_ids.insert(Arc::from("fake-extension"));
+
+        registry.update(cx, |registry, cx| {
+            registry.sync_installed_llm_extensions(extension_ids, cx);
+        });
+
+        assert!(registry.read(cx).visible_providers().is_empty());
+
+        registry.update(cx, |registry, cx| {
+            registry.sync_installed_llm_extensions(HashSet::default(), cx);
+        });
+
+        assert_eq!(registry.read(cx).visible_providers().len(), 1);
+    }
 }

crates/language_model/src/request.rs 🔗

@@ -8,6 +8,7 @@ use gpui::{
     App, AppContext as _, DevicePixels, Image, ImageFormat, ObjectFit, SharedString, Size, Task,
     point, px, size,
 };
+use image::GenericImageView as _;
 use image::codecs::png::PngEncoder;
 use serde::{Deserialize, Serialize};
 use util::ResultExt;
@@ -19,7 +20,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 +63,7 @@ impl LanguageModelImage {
         }
 
         Some(Self {
-            size: size(DevicePixels(width?), DevicePixels(height?)),
+            size: Some(size(DevicePixels(width?), DevicePixels(height?))),
             source: SharedString::from(source.to_string()),
         })
     }
@@ -79,11 +81,21 @@ impl std::fmt::Debug for LanguageModelImage {
 /// Anthropic wants uploaded images to be smaller than this in both dimensions.
 const ANTHROPIC_SIZE_LIMIT: f32 = 1568.;
 
+/// Default per-image hard limit (in bytes) for the encoded image payload we send upstream.
+///
+/// NOTE: `LanguageModelImage.source` is base64-encoded PNG bytes (without the `data:` prefix).
+/// This limit is enforced on the encoded PNG bytes *before* base64 encoding.
+const DEFAULT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
+
+/// Conservative cap on how many times we'll attempt to shrink/re-encode an image to fit
+/// `DEFAULT_IMAGE_MAX_BYTES`.
+const MAX_IMAGE_DOWNSCALE_PASSES: usize = 8;
+
 impl LanguageModelImage {
     pub fn empty() -> Self {
         Self {
             source: "".into(),
-            size: size(DevicePixels(0), DevicePixels(0)),
+            size: None,
         }
     }
 
@@ -111,43 +123,79 @@ impl LanguageModelImage {
             let height = dynamic_image.height();
             let image_size = size(DevicePixels(width as i32), DevicePixels(height as i32));
 
-            let base64_image = {
-                if image_size.width.0 > ANTHROPIC_SIZE_LIMIT as i32
-                    || image_size.height.0 > ANTHROPIC_SIZE_LIMIT as i32
-                {
-                    let new_bounds = ObjectFit::ScaleDown.get_bounds(
-                        gpui::Bounds {
-                            origin: point(px(0.0), px(0.0)),
-                            size: size(px(ANTHROPIC_SIZE_LIMIT), px(ANTHROPIC_SIZE_LIMIT)),
-                        },
-                        image_size,
-                    );
-                    let resized_image = dynamic_image.resize(
-                        new_bounds.size.width.into(),
-                        new_bounds.size.height.into(),
-                        image::imageops::FilterType::Triangle,
-                    );
-
-                    encode_as_base64(data, resized_image)
-                } else {
-                    encode_as_base64(data, dynamic_image)
+            // First apply any provider-specific dimension constraints we know about (Anthropic).
+            let mut processed_image = if image_size.width.0 > ANTHROPIC_SIZE_LIMIT as i32
+                || image_size.height.0 > ANTHROPIC_SIZE_LIMIT as i32
+            {
+                let new_bounds = ObjectFit::ScaleDown.get_bounds(
+                    gpui::Bounds {
+                        origin: point(px(0.0), px(0.0)),
+                        size: size(px(ANTHROPIC_SIZE_LIMIT), px(ANTHROPIC_SIZE_LIMIT)),
+                    },
+                    image_size,
+                );
+                dynamic_image.resize(
+                    new_bounds.size.width.into(),
+                    new_bounds.size.height.into(),
+                    image::imageops::FilterType::Triangle,
+                )
+            } else {
+                dynamic_image
+            };
+
+            // Then enforce a default per-image size cap on the encoded PNG bytes.
+            //
+            // We always send PNG bytes (either original PNG bytes, or re-encoded PNG) base64'd.
+            // The upstream provider limit we want to respect is effectively on the binary image
+            // payload size, so we enforce against the encoded PNG bytes before base64 encoding.
+            let mut encoded_png = encode_png_bytes(&processed_image).log_err()?;
+            for _pass in 0..MAX_IMAGE_DOWNSCALE_PASSES {
+                if encoded_png.len() <= DEFAULT_IMAGE_MAX_BYTES {
+                    break;
+                }
+
+                // Scale down geometrically to converge quickly. We don't know the final PNG size
+                // as a function of pixels, so we iteratively shrink.
+                let (w, h) = processed_image.dimensions();
+                if w <= 1 || h <= 1 {
+                    break;
                 }
+
+                // Shrink by ~15% each pass (0.85). This is a compromise between speed and
+                // preserving image detail.
+                let new_w = ((w as f32) * 0.85).round().max(1.0) as u32;
+                let new_h = ((h as f32) * 0.85).round().max(1.0) as u32;
+
+                processed_image =
+                    processed_image.resize(new_w, new_h, image::imageops::FilterType::Triangle);
+                encoded_png = encode_png_bytes(&processed_image).log_err()?;
+            }
+
+            if encoded_png.len() > DEFAULT_IMAGE_MAX_BYTES {
+                // Still too large after multiple passes; treat as non-convertible for now.
+                // (Provider-specific handling can be introduced later.)
+                return None;
             }
-            .log_err()?;
+
+            // Now base64 encode the PNG bytes.
+            let base64_image = encode_bytes_as_base64(encoded_png.as_slice()).log_err()?;
 
             // SAFETY: The base64 encoder should not produce non-UTF8.
             let source = unsafe { String::from_utf8_unchecked(base64_image) };
 
             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,
@@ -160,21 +208,20 @@ impl LanguageModelImage {
     }
 }
 
-fn encode_as_base64(data: Arc<Image>, image: image::DynamicImage) -> Result<Vec<u8>> {
+fn encode_png_bytes(image: &image::DynamicImage) -> Result<Vec<u8>> {
+    let mut png = Vec::new();
+    image.write_with_encoder(PngEncoder::new(&mut png))?;
+    Ok(png)
+}
+
+fn encode_bytes_as_base64(bytes: &[u8]) -> Result<Vec<u8>> {
     let mut base64_image = Vec::new();
     {
         let mut base64_encoder = EncoderWriter::new(
             Cursor::new(&mut base64_image),
             &base64::engine::general_purpose::STANDARD,
         );
-        if data.format() == ImageFormat::Png {
-            base64_encoder.write_all(data.bytes())?;
-        } else {
-            let mut png = Vec::new();
-            image.write_with_encoder(PngEncoder::new(&mut png))?;
-
-            base64_encoder.write_all(png.as_slice())?;
-        }
+        base64_encoder.write_all(bytes)?;
     }
     Ok(base64_image)
 }
@@ -413,6 +460,71 @@ pub struct LanguageModelResponseMessage {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use base64::Engine as _;
+    use gpui::TestAppContext;
+    use image::ImageDecoder as _;
+
+    fn base64_to_png_bytes(base64_png: &str) -> Vec<u8> {
+        base64::engine::general_purpose::STANDARD
+            .decode(base64_png.as_bytes())
+            .expect("base64 should decode")
+    }
+
+    fn png_dimensions(png_bytes: &[u8]) -> (u32, u32) {
+        let decoder =
+            image::codecs::png::PngDecoder::new(Cursor::new(png_bytes)).expect("png should decode");
+        decoder.dimensions()
+    }
+
+    fn make_noisy_png_bytes(width: u32, height: u32) -> Vec<u8> {
+        // Create an RGBA image with per-pixel variance to avoid PNG compressing too well.
+        let mut img = image::RgbaImage::new(width, height);
+        for y in 0..height {
+            for x in 0..width {
+                let r = ((x ^ y) & 0xFF) as u8;
+                let g = ((x.wrapping_mul(31) ^ y.wrapping_mul(17)) & 0xFF) as u8;
+                let b = ((x.wrapping_mul(131) ^ y.wrapping_mul(7)) & 0xFF) as u8;
+                img.put_pixel(x, y, image::Rgba([r, g, b, 0xFF]));
+            }
+        }
+
+        let mut out = Vec::new();
+        image::DynamicImage::ImageRgba8(img)
+            .write_with_encoder(PngEncoder::new(&mut out))
+            .expect("png encoding should succeed");
+        out
+    }
+
+    #[gpui::test]
+    async fn test_from_image_downscales_to_default_5mb_limit(cx: &mut TestAppContext) {
+        // Pick a size that reliably produces a PNG > 5MB when filled with noise.
+        // If this fails (image is too small), bump dimensions.
+        let original_png = make_noisy_png_bytes(4096, 4096);
+        assert!(
+            original_png.len() > DEFAULT_IMAGE_MAX_BYTES,
+            "precondition failed: noisy PNG must exceed DEFAULT_IMAGE_MAX_BYTES"
+        );
+
+        let image = gpui::Image::from_bytes(ImageFormat::Png, original_png);
+        let lm_image = cx
+            .update(|cx| LanguageModelImage::from_image(Arc::new(image), cx))
+            .await
+            .expect("image conversion should succeed");
+
+        let encoded_png = base64_to_png_bytes(lm_image.source.as_ref());
+        assert!(
+            encoded_png.len() <= DEFAULT_IMAGE_MAX_BYTES,
+            "expected encoded PNG <= DEFAULT_IMAGE_MAX_BYTES, got {} bytes",
+            encoded_png.len()
+        );
+
+        // Ensure we actually downscaled in pixels (not just re-encoded).
+        let (w, h) = png_dimensions(&encoded_png);
+        assert!(
+            w < 4096 || h < 4096,
+            "expected image to be downscaled in at least one dimension; got {w}x{h}"
+        );
+    }
 
     #[test]
     fn test_language_model_tool_result_content_deserialization() {
@@ -463,8 +575,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 +596,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 +617,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 +656,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/Cargo.toml 🔗

@@ -28,6 +28,8 @@ convert_case.workspace = true
 copilot.workspace = true
 credentials_provider.workspace = true
 deepseek = { workspace = true, features = ["schemars"] }
+extension.workspace = true
+extension_host.workspace = true
 fs.workspace = true
 futures.workspace = true
 google_ai = { workspace = true, features = ["schemars"] }

crates/language_models/src/extension.rs 🔗

@@ -0,0 +1,67 @@
+use collections::HashMap;
+use extension::{
+    ExtensionHostProxy, ExtensionLanguageModelProviderProxy, LanguageModelProviderRegistration,
+};
+use gpui::{App, Entity};
+use language_model::{LanguageModelProviderId, LanguageModelRegistry};
+use std::sync::{Arc, LazyLock};
+
+/// Maps built-in provider IDs to their corresponding extension IDs.
+/// When an extension with this ID is installed, the built-in provider should be hidden.
+static BUILTIN_TO_EXTENSION_MAP: LazyLock<HashMap<&'static str, &'static str>> =
+    LazyLock::new(|| {
+        let mut map = HashMap::default();
+        map.insert("anthropic", "anthropic");
+        map.insert("openai", "openai");
+        map.insert("google", "google-ai");
+        map.insert("openrouter", "openrouter");
+        map.insert("copilot_chat", "copilot-chat");
+        map
+    });
+
+/// Returns the extension ID that should hide the given built-in provider.
+pub fn extension_for_builtin_provider(provider_id: &str) -> Option<&'static str> {
+    BUILTIN_TO_EXTENSION_MAP.get(provider_id).copied()
+}
+
+/// Proxy that registers extension language model providers with the LanguageModelRegistry.
+pub struct LanguageModelProviderRegistryProxy {
+    registry: Entity<LanguageModelRegistry>,
+}
+
+impl LanguageModelProviderRegistryProxy {
+    pub fn new(registry: Entity<LanguageModelRegistry>) -> Self {
+        Self { registry }
+    }
+}
+
+impl ExtensionLanguageModelProviderProxy for LanguageModelProviderRegistryProxy {
+    fn register_language_model_provider(
+        &self,
+        _provider_id: Arc<str>,
+        register_fn: LanguageModelProviderRegistration,
+        cx: &mut App,
+    ) {
+        register_fn(cx);
+    }
+
+    fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App) {
+        self.registry.update(cx, |registry, cx| {
+            registry.unregister_provider(LanguageModelProviderId::from(provider_id), cx);
+        });
+    }
+}
+
+/// Initialize the extension language model provider proxy.
+/// This must be called BEFORE extension_host::init to ensure the proxy is available
+/// when extensions try to register their language model providers.
+pub fn init_proxy(cx: &mut App) {
+    let proxy = ExtensionHostProxy::default_global(cx);
+    let registry = LanguageModelRegistry::global(cx);
+
+    registry.update(cx, |registry, _cx| {
+        registry.set_builtin_provider_hiding_fn(Box::new(extension_for_builtin_provider));
+    });
+
+    proxy.register_language_model_provider_proxy(LanguageModelProviderRegistryProxy::new(registry));
+}

crates/language_models/src/language_models.rs 🔗

@@ -7,9 +7,12 @@ use gpui::{App, Context, Entity};
 use language_model::{LanguageModelProviderId, LanguageModelRegistry};
 use provider::deepseek::DeepSeekLanguageModelProvider;
 
+pub mod extension;
 pub mod provider;
 mod settings;
 
+pub use crate::extension::init_proxy as init_extension_proxy;
+
 use crate::provider::anthropic::AnthropicLanguageModelProvider;
 use crate::provider::bedrock::BedrockLanguageModelProvider;
 use crate::provider::cloud::CloudLanguageModelProvider;
@@ -31,6 +34,56 @@ pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
         register_language_model_providers(registry, user_store, client.clone(), cx);
     });
 
+    // Subscribe to extension store events to track LLM extension installations
+    if let Some(extension_store) = extension_host::ExtensionStore::try_global(cx) {
+        cx.subscribe(&extension_store, {
+            let registry = registry.clone();
+            move |extension_store, event, cx| match event {
+                extension_host::Event::ExtensionInstalled(extension_id) => {
+                    if let Some(manifest) = extension_store
+                        .read(cx)
+                        .extension_manifest_for_id(extension_id)
+                    {
+                        if !manifest.language_model_providers.is_empty() {
+                            registry.update(cx, |registry, cx| {
+                                registry.extension_installed(extension_id.clone(), cx);
+                            });
+                        }
+                    }
+                }
+                extension_host::Event::ExtensionUninstalled(extension_id) => {
+                    registry.update(cx, |registry, cx| {
+                        registry.extension_uninstalled(extension_id, cx);
+                    });
+                }
+                extension_host::Event::ExtensionsUpdated => {
+                    let mut new_ids = HashSet::default();
+                    for (extension_id, entry) in extension_store.read(cx).installed_extensions() {
+                        if !entry.manifest.language_model_providers.is_empty() {
+                            new_ids.insert(extension_id.clone());
+                        }
+                    }
+                    registry.update(cx, |registry, cx| {
+                        registry.sync_installed_llm_extensions(new_ids, cx);
+                    });
+                }
+                _ => {}
+            }
+        })
+        .detach();
+
+        // Initialize with currently installed extensions
+        registry.update(cx, |registry, cx| {
+            let mut initial_ids = HashSet::default();
+            for (extension_id, entry) in extension_store.read(cx).installed_extensions() {
+                if !entry.manifest.language_model_providers.is_empty() {
+                    initial_ids.insert(extension_id.clone());
+                }
+            }
+            registry.sync_installed_llm_extensions(initial_ids, cx);
+        });
+    }
+
     let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx)
         .openai_compatible
         .keys()

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

@@ -1,6 +1,6 @@
 use anthropic::{
-    ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode, ContentDelta, Event, ResponseContent,
-    ToolResultContent, ToolResultPart, Usage,
+    ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode, ContentDelta, CountTokensRequest, Event,
+    ResponseContent, ToolResultContent, ToolResultPart, Usage,
 };
 use anyhow::{Result, anyhow};
 use collections::{BTreeMap, HashMap};
@@ -8,7 +8,7 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Task};
 use http_client::HttpClient;
 use language_model::{
-    ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModel,
+    ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, IconOrSvg, LanguageModel,
     LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
@@ -125,8 +125,8 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiAnthropic
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiAnthropic)
     }
 
     fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
@@ -219,68 +219,215 @@ pub struct AnthropicModel {
     request_limiter: RateLimiter,
 }
 
-pub fn count_anthropic_tokens(
+/// Convert a LanguageModelRequest to an Anthropic CountTokensRequest.
+pub fn into_anthropic_count_tokens_request(
     request: LanguageModelRequest,
-    cx: &App,
-) -> BoxFuture<'static, Result<u64>> {
-    cx.background_spawn(async move {
-        let messages = request.messages;
-        let mut tokens_from_images = 0;
-        let mut string_messages = Vec::with_capacity(messages.len());
-
-        for message in messages {
-            use language_model::MessageContent;
-
-            let mut string_contents = String::new();
-
-            for content in message.content {
-                match content {
-                    MessageContent::Text(text) => {
-                        string_contents.push_str(&text);
-                    }
-                    MessageContent::Thinking { .. } => {
-                        // Thinking blocks are not included in the input token count.
-                    }
-                    MessageContent::RedactedThinking(_) => {
-                        // Thinking blocks are not included in the input token count.
-                    }
-                    MessageContent::Image(image) => {
-                        tokens_from_images += image.estimate_tokens();
-                    }
-                    MessageContent::ToolUse(_tool_use) => {
-                        // TODO: Estimate token usage from tool uses.
-                    }
-                    MessageContent::ToolResult(tool_result) => match &tool_result.content {
-                        LanguageModelToolResultContent::Text(text) => {
-                            string_contents.push_str(text);
+    model: String,
+    mode: AnthropicModelMode,
+) -> CountTokensRequest {
+    let mut new_messages: Vec<anthropic::Message> = Vec::new();
+    let mut system_message = String::new();
+
+    for message in request.messages {
+        if message.contents_empty() {
+            continue;
+        }
+
+        match message.role {
+            Role::User | Role::Assistant => {
+                let anthropic_message_content: Vec<anthropic::RequestContent> = message
+                    .content
+                    .into_iter()
+                    .filter_map(|content| match content {
+                        MessageContent::Text(text) => {
+                            let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
+                                text.trim_end().to_string()
+                            } else {
+                                text
+                            };
+                            if !text.is_empty() {
+                                Some(anthropic::RequestContent::Text {
+                                    text,
+                                    cache_control: None,
+                                })
+                            } else {
+                                None
+                            }
+                        }
+                        MessageContent::Thinking {
+                            text: thinking,
+                            signature,
+                        } => {
+                            if !thinking.is_empty() {
+                                Some(anthropic::RequestContent::Thinking {
+                                    thinking,
+                                    signature: signature.unwrap_or_default(),
+                                    cache_control: None,
+                                })
+                            } else {
+                                None
+                            }
+                        }
+                        MessageContent::RedactedThinking(data) => {
+                            if !data.is_empty() {
+                                Some(anthropic::RequestContent::RedactedThinking { data })
+                            } else {
+                                None
+                            }
                         }
-                        LanguageModelToolResultContent::Image(image) => {
-                            tokens_from_images += image.estimate_tokens();
+                        MessageContent::Image(image) => Some(anthropic::RequestContent::Image {
+                            source: anthropic::ImageSource {
+                                source_type: "base64".to_string(),
+                                media_type: "image/png".to_string(),
+                                data: image.source.to_string(),
+                            },
+                            cache_control: None,
+                        }),
+                        MessageContent::ToolUse(tool_use) => {
+                            Some(anthropic::RequestContent::ToolUse {
+                                id: tool_use.id.to_string(),
+                                name: tool_use.name.to_string(),
+                                input: tool_use.input,
+                                cache_control: None,
+                            })
+                        }
+                        MessageContent::ToolResult(tool_result) => {
+                            Some(anthropic::RequestContent::ToolResult {
+                                tool_use_id: tool_result.tool_use_id.to_string(),
+                                is_error: tool_result.is_error,
+                                content: match tool_result.content {
+                                    LanguageModelToolResultContent::Text(text) => {
+                                        ToolResultContent::Plain(text.to_string())
+                                    }
+                                    LanguageModelToolResultContent::Image(image) => {
+                                        ToolResultContent::Multipart(vec![ToolResultPart::Image {
+                                            source: anthropic::ImageSource {
+                                                source_type: "base64".to_string(),
+                                                media_type: "image/png".to_string(),
+                                                data: image.source.to_string(),
+                                            },
+                                        }])
+                                    }
+                                },
+                                cache_control: None,
+                            })
                         }
-                    },
+                    })
+                    .collect();
+                let anthropic_role = match message.role {
+                    Role::User => anthropic::Role::User,
+                    Role::Assistant => anthropic::Role::Assistant,
+                    Role::System => unreachable!("System role should never occur here"),
+                };
+                if let Some(last_message) = new_messages.last_mut()
+                    && last_message.role == anthropic_role
+                {
+                    last_message.content.extend(anthropic_message_content);
+                    continue;
                 }
-            }
 
-            if !string_contents.is_empty() {
-                string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
-                    role: match message.role {
-                        Role::User => "user".into(),
-                        Role::Assistant => "assistant".into(),
-                        Role::System => "system".into(),
-                    },
-                    content: Some(string_contents),
-                    name: None,
-                    function_call: None,
+                new_messages.push(anthropic::Message {
+                    role: anthropic_role,
+                    content: anthropic_message_content,
                 });
             }
+            Role::System => {
+                if !system_message.is_empty() {
+                    system_message.push_str("\n\n");
+                }
+                system_message.push_str(&message.string_contents());
+            }
+        }
+    }
+
+    CountTokensRequest {
+        model,
+        messages: new_messages,
+        system: if system_message.is_empty() {
+            None
+        } else {
+            Some(anthropic::StringOrContents::String(system_message))
+        },
+        thinking: if request.thinking_allowed
+            && let AnthropicModelMode::Thinking { budget_tokens } = mode
+        {
+            Some(anthropic::Thinking::Enabled { budget_tokens })
+        } else {
+            None
+        },
+        tools: request
+            .tools
+            .into_iter()
+            .map(|tool| anthropic::Tool {
+                name: tool.name,
+                description: tool.description,
+                input_schema: tool.input_schema,
+            })
+            .collect(),
+        tool_choice: request.tool_choice.map(|choice| match choice {
+            LanguageModelToolChoice::Auto => anthropic::ToolChoice::Auto,
+            LanguageModelToolChoice::Any => anthropic::ToolChoice::Any,
+            LanguageModelToolChoice::None => anthropic::ToolChoice::None,
+        }),
+    }
+}
+
+/// Estimate tokens using tiktoken. Used as a fallback when the API is unavailable,
+/// or by providers (like Zed Cloud) that don't have direct Anthropic API access.
+pub fn count_anthropic_tokens_with_tiktoken(request: LanguageModelRequest) -> Result<u64> {
+    let messages = request.messages;
+    let mut tokens_from_images = 0;
+    let mut string_messages = Vec::with_capacity(messages.len());
+
+    for message in messages {
+        let mut string_contents = String::new();
+
+        for content in message.content {
+            match content {
+                MessageContent::Text(text) => {
+                    string_contents.push_str(&text);
+                }
+                MessageContent::Thinking { .. } => {
+                    // Thinking blocks are not included in the input token count.
+                }
+                MessageContent::RedactedThinking(_) => {
+                    // Thinking blocks are not included in the input token count.
+                }
+                MessageContent::Image(image) => {
+                    tokens_from_images += image.estimate_tokens();
+                }
+                MessageContent::ToolUse(_tool_use) => {
+                    // TODO: Estimate token usage from tool uses.
+                }
+                MessageContent::ToolResult(tool_result) => match &tool_result.content {
+                    LanguageModelToolResultContent::Text(text) => {
+                        string_contents.push_str(text);
+                    }
+                    LanguageModelToolResultContent::Image(image) => {
+                        tokens_from_images += image.estimate_tokens();
+                    }
+                },
+            }
+        }
+
+        if !string_contents.is_empty() {
+            string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
+                role: match message.role {
+                    Role::User => "user".into(),
+                    Role::Assistant => "assistant".into(),
+                    Role::System => "system".into(),
+                },
+                content: Some(string_contents),
+                name: None,
+                function_call: None,
+            });
         }
+    }
 
-        // Tiktoken doesn't yet support these models, so we manually use the
-        // same tokenizer as GPT-4.
-        tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
-            .map(|tokens| (tokens + tokens_from_images) as u64)
-    })
-    .boxed()
+    // Tiktoken doesn't yet support these models, so we manually use the
+    // same tokenizer as GPT-4.
+    tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
+        .map(|tokens| (tokens + tokens_from_images) as u64)
 }
 
 impl AnthropicModel {
@@ -386,7 +533,40 @@ impl LanguageModel for AnthropicModel {
         request: LanguageModelRequest,
         cx: &App,
     ) -> BoxFuture<'static, Result<u64>> {
-        count_anthropic_tokens(request, cx)
+        let http_client = self.http_client.clone();
+        let model_id = self.model.request_id().to_string();
+        let mode = self.model.mode();
+
+        let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
+            let api_url = AnthropicLanguageModelProvider::api_url(cx);
+            (
+                state.api_key_state.key(&api_url).map(|k| k.to_string()),
+                api_url.to_string(),
+            )
+        });
+
+        async move {
+            // If no API key, fall back to tiktoken estimation
+            let Some(api_key) = api_key else {
+                return count_anthropic_tokens_with_tiktoken(request);
+            };
+
+            let count_request =
+                into_anthropic_count_tokens_request(request.clone(), model_id, mode);
+
+            match anthropic::count_tokens(http_client.as_ref(), &api_url, &api_key, count_request)
+                .await
+            {
+                Ok(response) => Ok(response.input_tokens),
+                Err(err) => {
+                    log::error!(
+                        "Anthropic count_tokens API failed, falling back to tiktoken: {err:?}"
+                    );
+                    count_anthropic_tokens_with_tiktoken(request)
+                }
+            }
+        }
+        .boxed()
     }
 
     fn stream_completion(

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

@@ -5,7 +5,7 @@ use std::sync::Arc;
 use anyhow::{Context as _, Result, anyhow};
 use aws_config::stalled_stream_protection::StalledStreamProtectionConfig;
 use aws_config::{BehaviorVersion, Region};
-use aws_credential_types::Credentials;
+use aws_credential_types::{Credentials, Token};
 use aws_http_client::AwsHttpClient;
 use bedrock::bedrock_client::Client as BedrockClient;
 use bedrock::bedrock_client::config::timeout::TimeoutConfig;
@@ -30,18 +30,19 @@ use gpui::{
 use gpui_tokio::Tokio;
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCacheConfiguration,
+    AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration,
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
     LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
     LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role,
-    TokenUsage,
+    TokenUsage, env_var,
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use serde_json::Value;
 use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore};
 use smol::lock::OnceCell;
+use std::sync::LazyLock;
 use strum::{EnumIter, IntoEnumIterator, IntoStaticStr};
 use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
@@ -54,12 +55,52 @@ actions!(bedrock, [Tab, TabPrev]);
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("amazon-bedrock");
 const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Amazon Bedrock");
 
+/// Credentials stored in the keychain for static authentication.
+/// Region is handled separately since it's orthogonal to auth method.
 #[derive(Default, Clone, Deserialize, Serialize, PartialEq, Debug)]
 pub struct BedrockCredentials {
     pub access_key_id: String,
     pub secret_access_key: String,
     pub session_token: Option<String>,
-    pub region: String,
+    pub bearer_token: Option<String>,
+}
+
+/// Resolved authentication configuration for Bedrock.
+/// Settings take priority over UX-provided credentials.
+#[derive(Clone, Debug, PartialEq)]
+pub enum BedrockAuth {
+    /// Use default AWS credential provider chain (IMDSv2, PodIdentity, env vars, etc.)
+    Automatic,
+    /// Use AWS named profile from ~/.aws/credentials or ~/.aws/config
+    NamedProfile { profile_name: String },
+    /// Use AWS SSO profile
+    SingleSignOn { profile_name: String },
+    /// Use IAM credentials (access key + secret + optional session token)
+    IamCredentials {
+        access_key_id: String,
+        secret_access_key: String,
+        session_token: Option<String>,
+    },
+    /// Use Bedrock API Key (bearer token authentication)
+    ApiKey { api_key: String },
+}
+
+impl BedrockCredentials {
+    /// Convert stored credentials to the appropriate auth variant.
+    /// Prefers API key if present, otherwise uses IAM credentials.
+    fn into_auth(self) -> Option<BedrockAuth> {
+        if let Some(api_key) = self.bearer_token.filter(|t| !t.is_empty()) {
+            Some(BedrockAuth::ApiKey { api_key })
+        } else if !self.access_key_id.is_empty() && !self.secret_access_key.is_empty() {
+            Some(BedrockAuth::IamCredentials {
+                access_key_id: self.access_key_id,
+                secret_access_key: self.secret_access_key,
+                session_token: self.session_token.filter(|t| !t.is_empty()),
+            })
+        } else {
+            None
+        }
+    }
 }
 
 #[derive(Default, Clone, Debug, PartialEq)]
@@ -79,6 +120,8 @@ pub enum BedrockAuthMethod {
     NamedProfile,
     #[serde(rename = "sso")]
     SingleSignOn,
+    #[serde(rename = "api_key")]
+    ApiKey,
     /// IMDSv2, PodIdentity, env vars, etc.
     #[serde(rename = "default")]
     Automatic,
@@ -90,6 +133,7 @@ impl From<settings::BedrockAuthMethodContent> for BedrockAuthMethod {
             settings::BedrockAuthMethodContent::SingleSignOn => BedrockAuthMethod::SingleSignOn,
             settings::BedrockAuthMethodContent::Automatic => BedrockAuthMethod::Automatic,
             settings::BedrockAuthMethodContent::NamedProfile => BedrockAuthMethod::NamedProfile,
+            settings::BedrockAuthMethodContent::ApiKey => BedrockAuthMethod::ApiKey,
         }
     }
 }
@@ -130,23 +174,26 @@ impl From<BedrockModelMode> for ModelMode {
 const AMAZON_AWS_URL: &str = "https://amazonaws.com";
 
 // These environment variables all use a `ZED_` prefix because we don't want to overwrite the user's AWS credentials.
-const ZED_BEDROCK_ACCESS_KEY_ID_VAR: &str = "ZED_ACCESS_KEY_ID";
-const ZED_BEDROCK_SECRET_ACCESS_KEY_VAR: &str = "ZED_SECRET_ACCESS_KEY";
-const ZED_BEDROCK_SESSION_TOKEN_VAR: &str = "ZED_SESSION_TOKEN";
-const ZED_AWS_PROFILE_VAR: &str = "ZED_AWS_PROFILE";
-const ZED_BEDROCK_REGION_VAR: &str = "ZED_AWS_REGION";
-const ZED_AWS_CREDENTIALS_VAR: &str = "ZED_AWS_CREDENTIALS";
-const ZED_AWS_ENDPOINT_VAR: &str = "ZED_AWS_ENDPOINT";
+static ZED_BEDROCK_ACCESS_KEY_ID_VAR: LazyLock<EnvVar> = env_var!("ZED_ACCESS_KEY_ID");
+static ZED_BEDROCK_SECRET_ACCESS_KEY_VAR: LazyLock<EnvVar> = env_var!("ZED_SECRET_ACCESS_KEY");
+static ZED_BEDROCK_SESSION_TOKEN_VAR: LazyLock<EnvVar> = env_var!("ZED_SESSION_TOKEN");
+static ZED_AWS_PROFILE_VAR: LazyLock<EnvVar> = env_var!("ZED_AWS_PROFILE");
+static ZED_BEDROCK_REGION_VAR: LazyLock<EnvVar> = env_var!("ZED_AWS_REGION");
+static ZED_AWS_ENDPOINT_VAR: LazyLock<EnvVar> = env_var!("ZED_AWS_ENDPOINT");
+static ZED_BEDROCK_BEARER_TOKEN_VAR: LazyLock<EnvVar> = env_var!("ZED_BEDROCK_BEARER_TOKEN");
 
 pub struct State {
-    credentials: Option<BedrockCredentials>,
+    /// The resolved authentication method. Settings take priority over UX credentials.
+    auth: Option<BedrockAuth>,
+    /// Raw settings from settings.json
     settings: Option<AmazonBedrockSettings>,
+    /// Whether credentials came from environment variables (only relevant for static credentials)
     credentials_from_env: bool,
     _subscription: Subscription,
 }
 
 impl State {
-    fn reset_credentials(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
+    fn reset_auth(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
         let credentials_provider = <dyn CredentialsProvider>::global(cx);
         cx.spawn(async move |this, cx| {
             credentials_provider
@@ -154,19 +201,19 @@ impl State {
                 .await
                 .log_err();
             this.update(cx, |this, cx| {
-                this.credentials = None;
+                this.auth = None;
                 this.credentials_from_env = false;
-                this.settings = None;
                 cx.notify();
             })
         })
     }
 
-    fn set_credentials(
+    fn set_static_credentials(
         &mut self,
         credentials: BedrockCredentials,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
+        let auth = credentials.clone().into_auth();
         let credentials_provider = <dyn CredentialsProvider>::global(cx);
         cx.spawn(async move |this, cx| {
             credentials_provider
@@ -178,50 +225,131 @@ impl State {
                 )
                 .await?;
             this.update(cx, |this, cx| {
-                this.credentials = Some(credentials);
+                this.auth = auth;
+                this.credentials_from_env = false;
                 cx.notify();
             })
         })
     }
 
     fn is_authenticated(&self) -> bool {
-        let derived = self
-            .settings
-            .as_ref()
-            .and_then(|s| s.authentication_method.as_ref());
-        let creds = self.credentials.as_ref();
-
-        derived.is_some() || creds.is_some()
+        self.auth.is_some()
     }
 
+    /// Resolve authentication. Settings take priority over UX-provided credentials.
     fn authenticate(&self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         if self.is_authenticated() {
             return Task::ready(Ok(()));
         }
 
+        // Step 1: Check if settings specify an auth method (enterprise control)
+        if let Some(settings) = &self.settings {
+            if let Some(method) = &settings.authentication_method {
+                let profile_name = settings
+                    .profile_name
+                    .clone()
+                    .unwrap_or_else(|| "default".to_string());
+
+                let auth = match method {
+                    BedrockAuthMethod::Automatic => BedrockAuth::Automatic,
+                    BedrockAuthMethod::NamedProfile => BedrockAuth::NamedProfile { profile_name },
+                    BedrockAuthMethod::SingleSignOn => BedrockAuth::SingleSignOn { profile_name },
+                    BedrockAuthMethod::ApiKey => {
+                        // ApiKey method means "use static credentials from keychain/env"
+                        // Fall through to load them below
+                        return self.load_static_credentials(cx);
+                    }
+                };
+
+                return cx.spawn(async move |this, cx| {
+                    this.update(cx, |this, cx| {
+                        this.auth = Some(auth);
+                        this.credentials_from_env = false;
+                        cx.notify();
+                    })?;
+                    Ok(())
+                });
+            }
+        }
+
+        // Step 2: No settings auth method - try to load static credentials
+        self.load_static_credentials(cx)
+    }
+
+    /// Load static credentials from environment variables or keychain.
+    fn load_static_credentials(
+        &self,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<(), AuthenticateError>> {
         let credentials_provider = <dyn CredentialsProvider>::global(cx);
         cx.spawn(async move |this, cx| {
-            let (credentials, from_env) =
-                if let Ok(credentials) = std::env::var(ZED_AWS_CREDENTIALS_VAR) {
-                    (credentials, true)
-                } else {
-                    let (_, credentials) = credentials_provider
-                        .read_credentials(AMAZON_AWS_URL, cx)
-                        .await?
-                        .ok_or_else(|| AuthenticateError::CredentialsNotFound)?;
+            // Try environment variables first
+            let (auth, from_env) = if let Some(bearer_token) = &ZED_BEDROCK_BEARER_TOKEN_VAR.value {
+                if !bearer_token.is_empty() {
                     (
-                        String::from_utf8(credentials)
-                            .context("invalid {PROVIDER_NAME} credentials")?,
-                        false,
+                        Some(BedrockAuth::ApiKey {
+                            api_key: bearer_token.to_string(),
+                        }),
+                        true,
                     )
-                };
+                } else {
+                    (None, false)
+                }
+            } else if let Some(access_key_id) = &ZED_BEDROCK_ACCESS_KEY_ID_VAR.value {
+                if let Some(secret_access_key) = &ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.value {
+                    if !access_key_id.is_empty() && !secret_access_key.is_empty() {
+                        let session_token = ZED_BEDROCK_SESSION_TOKEN_VAR
+                            .value
+                            .as_deref()
+                            .filter(|s| !s.is_empty())
+                            .map(|s| s.to_string());
+                        (
+                            Some(BedrockAuth::IamCredentials {
+                                access_key_id: access_key_id.to_string(),
+                                secret_access_key: secret_access_key.to_string(),
+                                session_token,
+                            }),
+                            true,
+                        )
+                    } else {
+                        (None, false)
+                    }
+                } else {
+                    (None, false)
+                }
+            } else {
+                (None, false)
+            };
+
+            // If we got auth from env vars, use it
+            if let Some(auth) = auth {
+                this.update(cx, |this, cx| {
+                    this.auth = Some(auth);
+                    this.credentials_from_env = from_env;
+                    cx.notify();
+                })?;
+                return Ok(());
+            }
+
+            // Try keychain
+            let (_, credentials_bytes) = credentials_provider
+                .read_credentials(AMAZON_AWS_URL, cx)
+                .await?
+                .ok_or(AuthenticateError::CredentialsNotFound)?;
+
+            let credentials_str = String::from_utf8(credentials_bytes)
+                .context("invalid {PROVIDER_NAME} credentials")?;
 
             let credentials: BedrockCredentials =
-                serde_json::from_str(&credentials).context("failed to parse credentials")?;
+                serde_json::from_str(&credentials_str).context("failed to parse credentials")?;
+
+            let auth = credentials
+                .into_auth()
+                .ok_or(AuthenticateError::CredentialsNotFound)?;
 
             this.update(cx, |this, cx| {
-                this.credentials = Some(credentials);
-                this.credentials_from_env = from_env;
+                this.auth = Some(auth);
+                this.credentials_from_env = false;
                 cx.notify();
             })?;
 
@@ -229,15 +357,19 @@ impl State {
         })
     }
 
+    /// Get the resolved region. Checks env var, then settings, then defaults to us-east-1.
     fn get_region(&self) -> String {
-        // Get region - from credentials or directly from settings
-        let credentials_region = self.credentials.as_ref().map(|s| s.region.clone());
-        let settings_region = self.settings.as_ref().and_then(|s| s.region.clone());
-
-        // Use credentials region if available, otherwise use settings region, finally fall back to default
-        credentials_region
-            .or(settings_region)
-            .unwrap_or(String::from("us-east-1"))
+        // Priority: env var > settings > default
+        if let Some(region) = ZED_BEDROCK_REGION_VAR.value.as_deref() {
+            if !region.is_empty() {
+                return region.to_string();
+            }
+        }
+
+        self.settings
+            .as_ref()
+            .and_then(|s| s.region.clone())
+            .unwrap_or_else(|| "us-east-1".to_string())
     }
 
     fn get_allow_global(&self) -> bool {
@@ -257,7 +389,7 @@ pub struct BedrockLanguageModelProvider {
 impl BedrockLanguageModelProvider {
     pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
         let state = cx.new(|cx| State {
-            credentials: None,
+            auth: None,
             settings: Some(AllLanguageModelSettings::get_global(cx).bedrock.clone()),
             credentials_from_env: false,
             _subscription: cx.observe_global::<SettingsStore>(|_, cx| {
@@ -266,7 +398,7 @@ impl BedrockLanguageModelProvider {
         });
 
         Self {
-            http_client: AwsHttpClient::new(http_client.clone()),
+            http_client: AwsHttpClient::new(http_client),
             handle: Tokio::handle(cx),
             state,
         }
@@ -294,8 +426,8 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiBedrock
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiBedrock)
     }
 
     fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
@@ -312,7 +444,6 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
 
         for model in bedrock::Model::iter() {
             if !matches!(model, bedrock::Model::Custom { .. }) {
-                // TODO: Sonnet 3.7 vs. 3.7 Thinking bug is here.
                 models.insert(model.id().to_string(), model);
             }
         }
@@ -366,8 +497,7 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
     }
 
     fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
-        self.state
-            .update(cx, |state, cx| state.reset_credentials(cx))
+        self.state.update(cx, |state, cx| state.reset_auth(cx))
     }
 }
 
@@ -393,25 +523,11 @@ impl BedrockModel {
     fn get_or_init_client(&self, cx: &AsyncApp) -> anyhow::Result<&BedrockClient> {
         self.client
             .get_or_try_init_blocking(|| {
-                let (auth_method, credentials, endpoint, region, settings) =
-                    cx.read_entity(&self.state, |state, _cx| {
-                        let auth_method = state
-                            .settings
-                            .as_ref()
-                            .and_then(|s| s.authentication_method.clone());
-
-                        let endpoint = state.settings.as_ref().and_then(|s| s.endpoint.clone());
-
-                        let region = state.get_region();
-
-                        (
-                            auth_method,
-                            state.credentials.clone(),
-                            endpoint,
-                            region,
-                            state.settings.clone(),
-                        )
-                    })?;
+                let (auth, endpoint, region) = cx.read_entity(&self.state, |state, _cx| {
+                    let endpoint = state.settings.as_ref().and_then(|s| s.endpoint.clone());
+                    let region = state.get_region();
+                    (state.auth.clone(), endpoint, region)
+                })?;
 
                 let mut config_builder = aws_config::defaults(BehaviorVersion::latest())
                     .stalled_stream_protection(StalledStreamProtectionConfig::disabled())
@@ -425,37 +541,39 @@ impl BedrockModel {
                     config_builder = config_builder.endpoint_url(endpoint_url);
                 }
 
-                match auth_method {
-                    None => {
-                        if let Some(creds) = credentials {
-                            let aws_creds = Credentials::new(
-                                creds.access_key_id,
-                                creds.secret_access_key,
-                                creds.session_token,
-                                None,
-                                "zed-bedrock-provider",
-                            );
-                            config_builder = config_builder.credentials_provider(aws_creds);
-                        }
+                match auth {
+                    Some(BedrockAuth::Automatic) | None => {
+                        // Use default AWS credential provider chain
                     }
-                    Some(BedrockAuthMethod::NamedProfile)
-                    | Some(BedrockAuthMethod::SingleSignOn) => {
-                        // Currently NamedProfile and SSO behave the same way but only the instructions change
-                        // Until we support BearerAuth through SSO, this will not change.
-                        let profile_name = settings
-                            .and_then(|s| s.profile_name)
-                            .unwrap_or_else(|| "default".to_string());
-
+                    Some(BedrockAuth::NamedProfile { profile_name })
+                    | Some(BedrockAuth::SingleSignOn { profile_name }) => {
                         if !profile_name.is_empty() {
                             config_builder = config_builder.profile_name(profile_name);
                         }
                     }
-                    Some(BedrockAuthMethod::Automatic) => {
-                        // Use default credential provider chain
+                    Some(BedrockAuth::IamCredentials {
+                        access_key_id,
+                        secret_access_key,
+                        session_token,
+                    }) => {
+                        let aws_creds = Credentials::new(
+                            access_key_id,
+                            secret_access_key,
+                            session_token,
+                            None,
+                            "zed-bedrock-provider",
+                        );
+                        config_builder = config_builder.credentials_provider(aws_creds);
+                    }
+                    Some(BedrockAuth::ApiKey { api_key }) => {
+                        config_builder = config_builder
+                            .auth_scheme_preference(["httpBearerAuth".into()]) // https://github.com/smithy-lang/smithy-rs/pull/4241
+                            .token_provider(Token::new(api_key, None));
                     }
                 }
 
                 let config = self.handle.block_on(config_builder.load());
+
                 anyhow::Ok(BedrockClient::new(&config))
             })
             .context("initializing Bedrock client")?;
@@ -1024,7 +1142,7 @@ struct ConfigurationView {
     access_key_id_editor: Entity<InputField>,
     secret_access_key_editor: Entity<InputField>,
     session_token_editor: Entity<InputField>,
-    region_editor: Entity<InputField>,
+    bearer_token_editor: Entity<InputField>,
     state: Entity<State>,
     load_credentials_task: Option<Task<()>>,
     focus_handle: FocusHandle,
@@ -1035,7 +1153,7 @@ impl ConfigurationView {
     const PLACEHOLDER_SECRET_ACCESS_KEY_TEXT: &'static str =
         "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
     const PLACEHOLDER_SESSION_TOKEN_TEXT: &'static str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
-    const PLACEHOLDER_REGION: &'static str = "us-east-1";
+    const PLACEHOLDER_BEARER_TOKEN_TEXT: &'static str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
 
     fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
         let focus_handle = cx.focus_handle();
@@ -1066,9 +1184,9 @@ impl ConfigurationView {
                 .tab_stop(true)
         });
 
-        let region_editor = cx.new(|cx| {
-            InputField::new(window, cx, Self::PLACEHOLDER_REGION)
-                .label("Region")
+        let bearer_token_editor = cx.new(|cx| {
+            InputField::new(window, cx, Self::PLACEHOLDER_BEARER_TOKEN_TEXT)
+                .label("Bedrock API Key")
                 .tab_index(3)
                 .tab_stop(true)
         });
@@ -1095,7 +1213,7 @@ impl ConfigurationView {
             access_key_id_editor,
             secret_access_key_editor,
             session_token_editor,
-            region_editor,
+            bearer_token_editor,
             state,
             load_credentials_task,
             focus_handle,
@@ -1131,25 +1249,30 @@ impl ConfigurationView {
         } else {
             Some(session_token)
         };
-        let region = self.region_editor.read(cx).text(cx).trim().to_string();
-        let region = if region.is_empty() {
-            "us-east-1".to_string()
+        let bearer_token = self
+            .bearer_token_editor
+            .read(cx)
+            .text(cx)
+            .trim()
+            .to_string();
+        let bearer_token = if bearer_token.is_empty() {
+            None
         } else {
-            region
+            Some(bearer_token)
         };
 
         let state = self.state.clone();
         cx.spawn(async move |_, cx| {
             state
                 .update(cx, |state, cx| {
-                    let credentials: BedrockCredentials = BedrockCredentials {
-                        region: region.clone(),
-                        access_key_id: access_key_id.clone(),
-                        secret_access_key: secret_access_key.clone(),
-                        session_token: session_token.clone(),
+                    let credentials = BedrockCredentials {
+                        access_key_id,
+                        secret_access_key,
+                        session_token,
+                        bearer_token,
                     };
 
-                    state.set_credentials(credentials, cx)
+                    state.set_static_credentials(credentials, cx)
                 })?
                 .await
         })
@@ -1163,41 +1286,39 @@ impl ConfigurationView {
             .update(cx, |editor, cx| editor.set_text("", window, cx));
         self.session_token_editor
             .update(cx, |editor, cx| editor.set_text("", window, cx));
-        self.region_editor
+        self.bearer_token_editor
             .update(cx, |editor, cx| editor.set_text("", window, cx));
 
         let state = self.state.clone();
-        cx.spawn(async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.reset_credentials(cx))?
-                .await
-        })
-        .detach_and_log_err(cx);
+        cx.spawn(async move |_, cx| state.update(cx, |state, cx| state.reset_auth(cx))?.await)
+            .detach_and_log_err(cx);
     }
 
     fn should_render_editor(&self, cx: &Context<Self>) -> bool {
         self.state.read(cx).is_authenticated()
     }
 
-    fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context<Self>) {
-        window.focus_next();
+    fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_next(cx);
     }
 
     fn on_tab_prev(
         &mut self,
         _: &menu::SelectPrevious,
         window: &mut Window,
-        _: &mut Context<Self>,
+        cx: &mut Context<Self>,
     ) {
-        window.focus_prev();
+        window.focus_prev(cx);
     }
 }
 
 impl Render for ConfigurationView {
     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let env_var_set = self.state.read(cx).credentials_from_env;
-        let bedrock_settings = self.state.read(cx).settings.as_ref();
-        let bedrock_method = bedrock_settings
+        let state = self.state.read(cx);
+        let env_var_set = state.credentials_from_env;
+        let auth = state.auth.clone();
+        let settings_auth_method = state
+            .settings
             .as_ref()
             .and_then(|s| s.authentication_method.clone());
 
@@ -1205,34 +1326,62 @@ impl Render for ConfigurationView {
             return div().child(Label::new("Loading credentials...")).into_any();
         }
 
-        let configured_label = if env_var_set {
-            format!(
-                "Access Key ID is set in {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, Secret Key is set in {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, Region is set in {ZED_BEDROCK_REGION_VAR} environment variables."
-            )
-        } else {
-            match bedrock_method {
-                Some(BedrockAuthMethod::Automatic) => "You are using automatic credentials.".into(),
-                Some(BedrockAuthMethod::NamedProfile) => "You are using named profile.".into(),
-                Some(BedrockAuthMethod::SingleSignOn) => {
-                    "You are using a single sign on profile.".into()
-                }
-                None => "You are using static credentials.".into(),
+        let configured_label = match &auth {
+            Some(BedrockAuth::Automatic) => {
+                "Using automatic credentials (AWS default chain)".into()
+            }
+            Some(BedrockAuth::NamedProfile { profile_name }) => {
+                format!("Using AWS profile: {profile_name}")
+            }
+            Some(BedrockAuth::SingleSignOn { profile_name }) => {
+                format!("Using AWS SSO profile: {profile_name}")
+            }
+            Some(BedrockAuth::IamCredentials { .. }) if env_var_set => {
+                format!(
+                    "Using IAM credentials from {} and {} environment variables",
+                    ZED_BEDROCK_ACCESS_KEY_ID_VAR.name, ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.name
+                )
+            }
+            Some(BedrockAuth::IamCredentials { .. }) => "Using IAM credentials".into(),
+            Some(BedrockAuth::ApiKey { .. }) if env_var_set => {
+                format!(
+                    "Using Bedrock API Key from {} environment variable",
+                    ZED_BEDROCK_BEARER_TOKEN_VAR.name
+                )
             }
+            Some(BedrockAuth::ApiKey { .. }) => "Using Bedrock API Key".into(),
+            None => "Not authenticated".into(),
         };
 
+        // Determine if credentials can be reset
+        // Settings-derived auth (non-ApiKey) cannot be reset from UI
+        let is_settings_derived = matches!(
+            settings_auth_method,
+            Some(BedrockAuthMethod::Automatic)
+                | Some(BedrockAuthMethod::NamedProfile)
+                | Some(BedrockAuthMethod::SingleSignOn)
+        );
+
         let tooltip_label = if env_var_set {
             Some(format!(
-                "To reset your credentials, unset the {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR}, and {ZED_BEDROCK_REGION_VAR} environment variables."
+                "To reset your credentials, unset the {}, {}, and {} or {} environment variables.",
+                ZED_BEDROCK_ACCESS_KEY_ID_VAR.name,
+                ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.name,
+                ZED_BEDROCK_SESSION_TOKEN_VAR.name,
+                ZED_BEDROCK_BEARER_TOKEN_VAR.name
             ))
-        } else if bedrock_method.is_some() {
-            Some("You cannot reset credentials as they're being derived, check Zed settings to understand how.".to_string())
+        } else if is_settings_derived {
+            Some(
+                "Authentication method is configured in settings. Edit settings.json to change."
+                    .to_string(),
+            )
         } else {
             None
         };
 
         if self.should_render_editor(cx) {
             return ConfiguredApiCard::new(configured_label)
-                .disabled(env_var_set || bedrock_method.is_some())
+                .disabled(env_var_set || is_settings_derived)
                 .on_click(cx.listener(|this, _, window, cx| this.reset_credentials(window, cx)))
                 .when_some(tooltip_label, |this, label| this.tooltip_label(label))
                 .into_any_element();
@@ -1262,7 +1411,7 @@ impl Render for ConfigurationView {
             .child(self.render_static_credentials_ui())
             .child(
                 Label::new(
-                    format!("You can also assign the {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR} AND {ZED_BEDROCK_REGION_VAR} environment variables and restart Zed."),
+                    format!("You can also assign the {}, {} AND {} environment variables (or {} for Bedrock API Key authentication) and restart Zed.", ZED_BEDROCK_ACCESS_KEY_ID_VAR.name, ZED_BEDROCK_SECRET_ACCESS_KEY_VAR.name, ZED_BEDROCK_REGION_VAR.name, ZED_BEDROCK_BEARER_TOKEN_VAR.name),
                 )
                     .size(LabelSize::Small)
                     .color(Color::Muted)
@@ -1270,7 +1419,7 @@ impl Render for ConfigurationView {
             )
             .child(
                 Label::new(
-                    format!("Optionally, if your environment uses AWS CLI profiles, you can set {ZED_AWS_PROFILE_VAR}; if it requires a custom endpoint, you can set {ZED_AWS_ENDPOINT_VAR}; and if it requires a Session Token, you can set {ZED_BEDROCK_SESSION_TOKEN_VAR}."),
+                    format!("Optionally, if your environment uses AWS CLI profiles, you can set {}; if it requires a custom endpoint, you can set {}; and if it requires a Session Token, you can set {}.", ZED_AWS_PROFILE_VAR.name, ZED_AWS_ENDPOINT_VAR.name, ZED_BEDROCK_SESSION_TOKEN_VAR.name),
                 )
                     .size(LabelSize::Small)
                     .color(Color::Muted),
@@ -1292,31 +1441,47 @@ impl ConfigurationView {
             )
             .child(
                 Label::new(
-                    "This method uses your AWS access key ID and secret access key directly.",
+                    "This method uses your AWS access key ID and secret access key, or a Bedrock API Key.",
                 )
             )
             .child(
                 List::new()
                     .child(
                         ListBulletItem::new("")
-                            .child(Label::new("Create an IAM user in the AWS console with programmatic access"))
+                            .child(Label::new("For access keys: Create an IAM user in the AWS console with programmatic access"))
                             .child(ButtonLink::new("IAM Console", "https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users"))
                     )
                     .child(
                         ListBulletItem::new("")
-                            .child(Label::new("Attach the necessary Bedrock permissions to this"))
-                            .child(ButtonLink::new("user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"))
+                            .child(Label::new("For Bedrock API Keys: Generate an API key from the"))
+                            .child(ButtonLink::new("Bedrock Console", "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html"))
                     )
                     .child(
-                        ListBulletItem::new("Copy the access key ID and secret access key when provided")
+                        ListBulletItem::new("")
+                            .child(Label::new("Attach the necessary Bedrock permissions to this"))
+                            .child(ButtonLink::new("user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"))
                     )
                     .child(
-                        ListBulletItem::new("Enter these credentials below")
-                    )
+                        ListBulletItem::new("Enter either access keys OR a Bedrock API Key below (not both)")
+                    ),
             )
             .child(self.access_key_id_editor.clone())
             .child(self.secret_access_key_editor.clone())
             .child(self.session_token_editor.clone())
-            .child(self.region_editor.clone())
+            .child(
+                Label::new("OR")
+                    .size(LabelSize::Default)
+                    .weight(FontWeight::BOLD)
+                    .my_1(),
+            )
+            .child(self.bearer_token_editor.clone())
+            .child(
+                Label::new(
+                    format!("Region is configured via {} environment variable or settings.json (defaults to us-east-1).", ZED_BEDROCK_REGION_VAR.name),
+                )
+                    .size(LabelSize::Small)
+                    .color(Color::Muted)
+                    .mt_2(),
+            )
     }
 }

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

@@ -19,7 +19,7 @@ use gpui::{AnyElement, AnyView, App, AsyncApp, Context, Entity, Subscription, Ta
 use http_client::http::{HeaderMap, HeaderValue};
 use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Response, StatusCode};
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCacheConfiguration,
+    AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration,
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
     LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
@@ -42,7 +42,9 @@ use thiserror::Error;
 use ui::{TintColor, prelude::*};
 use util::{ResultExt as _, maybe};
 
-use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, into_anthropic};
+use crate::provider::anthropic::{
+    AnthropicEventMapper, count_anthropic_tokens_with_tiktoken, into_anthropic,
+};
 use crate::provider::google::{GoogleEventMapper, into_google};
 use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai};
 use crate::provider::x_ai::count_xai_tokens;
@@ -302,8 +304,8 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiZed
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiZed)
     }
 
     fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
@@ -667,9 +669,9 @@ impl LanguageModel for CloudLanguageModel {
         cx: &App,
     ) -> BoxFuture<'static, Result<u64>> {
         match self.model.provider {
-            cloud_llm_client::LanguageModelProvider::Anthropic => {
-                count_anthropic_tokens(request, cx)
-            }
+            cloud_llm_client::LanguageModelProvider::Anthropic => cx
+                .background_spawn(async move { count_anthropic_tokens_with_tiktoken(request) })
+                .boxed(),
             cloud_llm_client::LanguageModelProvider::OpenAi => {
                 let model = match open_ai::Model::from_id(&self.model.id.0) {
                     Ok(model) => model,

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

@@ -18,12 +18,12 @@ use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task};
 use http_client::StatusCode;
 use language::language_settings::all_language_settings;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolResultContent,
-    LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role,
-    StopReason, TokenUsage,
+    AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice,
+    LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse,
+    MessageContent, RateLimiter, Role, StopReason, TokenUsage,
 };
 use settings::SettingsStore;
 use ui::prelude::*;
@@ -104,8 +104,8 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::Copilot
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::Copilot)
     }
 
     fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {

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

@@ -7,7 +7,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture, stream::BoxStream
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
@@ -127,8 +127,8 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiDeepSeek
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiDeepSeek)
     }
 
     fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {

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

@@ -14,7 +14,7 @@ use language_model::{
     LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason,
 };
 use language_model::{
-    LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    IconOrSvg, LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, RateLimiter, Role,
 };
@@ -164,8 +164,8 @@ impl LanguageModelProvider for GoogleLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiGoogle
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiGoogle)
     }
 
     fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {

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

@@ -10,7 +10,7 @@ use language_model::{
     StopReason, TokenUsage,
 };
 use language_model::{
-    LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    IconOrSvg, LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, RateLimiter, Role,
 };
@@ -20,7 +20,7 @@ use settings::{Settings, SettingsStore};
 use std::pin::Pin;
 use std::str::FromStr;
 use std::{collections::BTreeMap, sync::Arc};
-use ui::{ButtonLike, Indicator, InlineCode, List, ListBulletItem, prelude::*};
+use ui::{ButtonLike, Indicator, List, ListBulletItem, prelude::*};
 use util::ResultExt;
 
 use crate::AllLanguageModelSettings;
@@ -175,8 +175,8 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiLmStudio
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiLmStudio)
     }
 
     fn default_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
@@ -691,7 +691,7 @@ impl Render for ConfigurationView {
                             .child(
                                 ListBulletItem::new("")
                                     .child(Label::new("To get your first model, try running"))
-                                    .child(InlineCode::new("lms get qwen2.5-coder-7b")),
+                                    .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx)),
                             ),
                     ),
                 )

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

@@ -5,7 +5,7 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
@@ -176,8 +176,8 @@ impl LanguageModelProvider for MistralLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiMistral
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiMistral)
     }
 
     fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
@@ -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 🔗

@@ -5,7 +5,7 @@ use futures::{Stream, TryFutureExt, stream};
 use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task};
 use http_client::HttpClient;
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse,
@@ -23,8 +23,8 @@ use std::sync::LazyLock;
 use std::sync::atomic::{AtomicU64, Ordering};
 use std::{collections::HashMap, sync::Arc};
 use ui::{
-    ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, InlineCode, List, ListBulletItem,
-    Tooltip, prelude::*,
+    ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip,
+    prelude::*,
 };
 use ui_input::InputField;
 
@@ -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>,
 }
 
@@ -220,8 +221,8 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiOllama
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiOllama)
     }
 
     fn default_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
@@ -238,40 +239,17 @@ 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
-        for setting_model in &OllamaLanguageModelProvider::settings(cx).available_models {
-            let setting_base = setting_model.name.split(':').next().unwrap();
-            if let Some(model) = models
-                .values_mut()
-                .find(|m| m.name.split(':').next().unwrap() == setting_base)
-            {
-                model.max_tokens = setting_model.max_tokens;
-                model.display_name = setting_model.display_name.clone();
-                model.keep_alive = setting_model.keep_alive.clone();
-                model.supports_tools = setting_model.supports_tools;
-                model.supports_vision = setting_model.supports_images;
-                model.supports_thinking = setting_model.supports_thinking;
-            } else {
-                models.insert(
-                    setting_model.name.clone(),
-                    ollama::Model {
-                        name: setting_model.name.clone(),
-                        display_name: setting_model.display_name.clone(),
-                        max_tokens: setting_model.max_tokens,
-                        keep_alive: setting_model.keep_alive.clone(),
-                        supports_tools: setting_model.supports_tools,
-                        supports_vision: setting_model.supports_images,
-                        supports_thinking: setting_model.supports_thinking,
-                    },
-                );
-            }
-        }
+        merge_settings_into_models(&mut models, &settings.available_models);
 
         let mut models = models
             .into_values()
@@ -720,7 +698,7 @@ impl ConfigurationView {
         cx.notify();
     }
 
-    fn render_instructions() -> Div {
+    fn render_instructions(cx: &mut Context<Self>) -> Div {
         v_flex()
             .gap_2()
             .child(Label::new(
@@ -738,7 +716,7 @@ impl ConfigurationView {
                     .child(
                         ListBulletItem::new("")
                             .child(Label::new("Start Ollama and download a model:"))
-                            .child(InlineCode::new("ollama run gpt-oss:20b")),
+                            .child(Label::new("ollama run gpt-oss:20b").inline_code(cx)),
                     )
                     .child(ListBulletItem::new(
                         "Click 'Connect' below to start using Ollama in Zed",
@@ -829,7 +807,7 @@ impl Render for ConfigurationView {
 
         v_flex()
             .gap_2()
-            .child(Self::render_instructions())
+            .child(Self::render_instructions(cx))
             .child(self.render_api_url_editor(cx))
             .child(self.render_api_key_editor(cx))
             .child(
@@ -917,6 +895,35 @@ impl Render for ConfigurationView {
     }
 }
 
+fn merge_settings_into_models(
+    models: &mut HashMap<String, ollama::Model>,
+    available_models: &[AvailableModel],
+) {
+    for setting_model in available_models {
+        if let Some(model) = models.get_mut(&setting_model.name) {
+            model.max_tokens = setting_model.max_tokens;
+            model.display_name = setting_model.display_name.clone();
+            model.keep_alive = setting_model.keep_alive.clone();
+            model.supports_tools = setting_model.supports_tools;
+            model.supports_vision = setting_model.supports_images;
+            model.supports_thinking = setting_model.supports_thinking;
+        } else {
+            models.insert(
+                setting_model.name.clone(),
+                ollama::Model {
+                    name: setting_model.name.clone(),
+                    display_name: setting_model.display_name.clone(),
+                    max_tokens: setting_model.max_tokens,
+                    keep_alive: setting_model.keep_alive.clone(),
+                    supports_tools: setting_model.supports_tools,
+                    supports_vision: setting_model.supports_images,
+                    supports_thinking: setting_model.supports_thinking,
+                },
+            );
+        }
+    }
+}
+
 fn tool_into_ollama(tool: LanguageModelRequestTool) -> ollama::OllamaTool {
     ollama::OllamaTool::Function {
         function: OllamaFunctionTool {
@@ -926,3 +933,83 @@ fn tool_into_ollama(tool: LanguageModelRequestTool) -> ollama::OllamaTool {
         },
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_merge_settings_preserves_display_names_for_similar_models() {
+        // Regression test for https://github.com/zed-industries/zed/issues/43646
+        // When multiple models share the same base name (e.g., qwen2.5-coder:1.5b and qwen2.5-coder:3b),
+        // each model should get its own display_name from settings, not a random one.
+
+        let mut models: HashMap<String, ollama::Model> = HashMap::new();
+        models.insert(
+            "qwen2.5-coder:1.5b".to_string(),
+            ollama::Model {
+                name: "qwen2.5-coder:1.5b".to_string(),
+                display_name: None,
+                max_tokens: 4096,
+                keep_alive: None,
+                supports_tools: None,
+                supports_vision: None,
+                supports_thinking: None,
+            },
+        );
+        models.insert(
+            "qwen2.5-coder:3b".to_string(),
+            ollama::Model {
+                name: "qwen2.5-coder:3b".to_string(),
+                display_name: None,
+                max_tokens: 4096,
+                keep_alive: None,
+                supports_tools: None,
+                supports_vision: None,
+                supports_thinking: None,
+            },
+        );
+
+        let available_models = vec![
+            AvailableModel {
+                name: "qwen2.5-coder:1.5b".to_string(),
+                display_name: Some("QWEN2.5 Coder 1.5B".to_string()),
+                max_tokens: 5000,
+                keep_alive: None,
+                supports_tools: Some(true),
+                supports_images: None,
+                supports_thinking: None,
+            },
+            AvailableModel {
+                name: "qwen2.5-coder:3b".to_string(),
+                display_name: Some("QWEN2.5 Coder 3B".to_string()),
+                max_tokens: 6000,
+                keep_alive: None,
+                supports_tools: Some(true),
+                supports_images: None,
+                supports_thinking: None,
+            },
+        ];
+
+        merge_settings_into_models(&mut models, &available_models);
+
+        let model_1_5b = models
+            .get("qwen2.5-coder:1.5b")
+            .expect("1.5b model missing");
+        let model_3b = models.get("qwen2.5-coder:3b").expect("3b model missing");
+
+        assert_eq!(
+            model_1_5b.display_name,
+            Some("QWEN2.5 Coder 1.5B".to_string()),
+            "1.5b model should have its own display_name"
+        );
+        assert_eq!(model_1_5b.max_tokens, 5000);
+
+        assert_eq!(
+            model_3b.display_name,
+            Some("QWEN2.5 Coder 3B".to_string()),
+            "3b model should have its own display_name"
+        );
+        assert_eq!(model_3b.max_tokens, 6000);
+    }
+}

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

@@ -5,7 +5,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
@@ -122,8 +122,8 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiOpenAi
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiOpenAi)
     }
 
     fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {

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

@@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
@@ -133,8 +133,8 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider {
         self.name.clone()
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiOpenAiCompat
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiOpenAiCompat)
     }
 
     fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {

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

@@ -4,7 +4,7 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::HttpClient;
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
@@ -180,8 +180,8 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiOpenRouter
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiOpenRouter)
     }
 
     fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
@@ -370,8 +370,8 @@ impl LanguageModel for OpenRouterLanguageModel {
             LanguageModelCompletionError,
         >,
     > {
-        let request = into_open_router(request, &self.model, self.max_output_tokens());
-        let request = self.stream_completion(request, cx);
+        let openrouter_request = into_open_router(request, &self.model, self.max_output_tokens());
+        let request = self.stream_completion(openrouter_request, cx);
         let future = self.request_limiter.stream(async move {
             let response = request.await?;
             Ok(OpenRouterEventMapper::new().map_stream(response))
@@ -385,15 +385,31 @@ pub fn into_open_router(
     model: &Model,
     max_output_tokens: Option<u64>,
 ) -> open_router::Request {
+    // Anthropic models via OpenRouter don't accept reasoning_details being echoed back
+    // in requests - it's an output-only field for them. However, Gemini models require
+    // the thought signatures to be echoed back for proper reasoning chain continuity.
+    // Note: OpenRouter's model API provides an `architecture.tokenizer` field (e.g. "Claude",
+    // "Gemini") which could replace this ID prefix check, but since this is the only place
+    // we need this distinction, we're just using this less invasive check instead.
+    // If we ever have a more formal distionction between the models in the future,
+    // we should revise this to use that instead.
+    let is_anthropic_model = model.id().starts_with("anthropic/");
+
     let mut messages = Vec::new();
     for message in request.messages {
-        let reasoning_details = message.reasoning_details.clone();
+        let reasoning_details_for_message = if is_anthropic_model {
+            None
+        } else {
+            message.reasoning_details.clone()
+        };
+
         for content in message.content {
             match content {
                 MessageContent::Text(text) => add_message_content_part(
                     open_router::MessagePart::Text { text },
                     message.role,
                     &mut messages,
+                    reasoning_details_for_message.clone(),
                 ),
                 MessageContent::Thinking { .. } => {}
                 MessageContent::RedactedThinking(_) => {}
@@ -404,6 +420,7 @@ pub fn into_open_router(
                         },
                         message.role,
                         &mut messages,
+                        reasoning_details_for_message.clone(),
                     );
                 }
                 MessageContent::ToolUse(tool_use) => {
@@ -419,21 +436,15 @@ pub fn into_open_router(
                         },
                     };
 
-                    if let Some(open_router::RequestMessage::Assistant {
-                        tool_calls,
-                        reasoning_details: existing_reasoning,
-                        ..
-                    }) = messages.last_mut()
+                    if let Some(open_router::RequestMessage::Assistant { tool_calls, .. }) =
+                        messages.last_mut()
                     {
                         tool_calls.push(tool_call);
-                        if existing_reasoning.is_none() && reasoning_details.is_some() {
-                            *existing_reasoning = reasoning_details.clone();
-                        }
                     } else {
                         messages.push(open_router::RequestMessage::Assistant {
                             content: None,
                             tool_calls: vec![tool_call],
-                            reasoning_details: reasoning_details.clone(),
+                            reasoning_details: reasoning_details_for_message.clone(),
                         });
                     }
                 }
@@ -509,6 +520,7 @@ fn add_message_content_part(
     new_part: open_router::MessagePart,
     role: Role,
     messages: &mut Vec<open_router::RequestMessage>,
+    reasoning_details: Option<serde_json::Value>,
 ) {
     match (role, messages.last_mut()) {
         (Role::User, Some(open_router::RequestMessage::User { content }))
@@ -532,7 +544,7 @@ fn add_message_content_part(
                 Role::Assistant => open_router::RequestMessage::Assistant {
                     content: Some(open_router::MessageContent::from(vec![new_part])),
                     tool_calls: Vec::new(),
-                    reasoning_details: None,
+                    reasoning_details,
                 },
                 Role::System => open_router::RequestMessage::System {
                     content: open_router::MessageContent::from(vec![new_part]),

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

@@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var,
@@ -117,8 +117,8 @@ impl LanguageModelProvider for VercelLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiVZero
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiVZero)
     }
 
     fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {

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

@@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
@@ -118,8 +118,8 @@ impl LanguageModelProvider for XAiLanguageModelProvider {
         PROVIDER_NAME
     }
 
-    fn icon(&self) -> IconName {
-        IconName::AiXAi
+    fn icon(&self) -> IconOrSvg {
+        IconOrSvg::Icon(IconName::AiXAi)
     }
 
     fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {

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/language_tools/src/lsp_button.rs 🔗

@@ -127,6 +127,16 @@ impl LanguageServerState {
             return menu;
         };
 
+        let server_versions = self
+            .lsp_store
+            .update(cx, |lsp_store, _| {
+                lsp_store
+                    .language_server_statuses()
+                    .map(|(server_id, status)| (server_id, status.server_version.clone()))
+                    .collect::<HashMap<_, _>>()
+            })
+            .unwrap_or_default();
+
         let mut first_button_encountered = false;
         for item in &self.items {
             if let LspMenuItem::ToggleServersButton { restart } = item {
@@ -254,6 +264,22 @@ impl LanguageServerState {
             };
 
             let server_name = server_info.name.clone();
+            let server_version = server_versions
+                .get(&server_info.id)
+                .and_then(|version| version.clone());
+
+            let tooltip_text = match (&server_version, &message) {
+                (None, None) => None,
+                (Some(version), None) => {
+                    Some(SharedString::from(format!("Version: {}", version.as_ref())))
+                }
+                (None, Some(message)) => Some(message.clone()),
+                (Some(version), Some(message)) => Some(SharedString::from(format!(
+                    "Version: {}\n\n{}",
+                    version.as_ref(),
+                    message.as_ref()
+                ))),
+            };
             menu = menu.item(ContextMenuItem::custom_entry(
                 move |_, _| {
                     h_flex()
@@ -355,11 +381,11 @@ impl LanguageServerState {
                         }
                     }
                 },
-                message.map(|server_message| {
+                tooltip_text.map(|tooltip_text| {
                     DocumentationAside::new(
                         DocumentationSide::Right,
-                        DocumentationEdge::Bottom,
-                        Rc::new(move |_| Label::new(server_message.clone()).into_any_element()),
+                        DocumentationEdge::Top,
+                        Rc::new(move |_| Label::new(tooltip_text.clone()).into_any_element()),
                     )
                 }),
             ));

crates/language_tools/src/lsp_log_view.rs 🔗

@@ -125,7 +125,7 @@ pub fn init(on_headless_host: bool, cx: &mut App) {
                     let server_id = server.server_id();
                     let weak_lsp_store = cx.weak_entity();
                     log_store.copilot_log_subscription =
-                        Some(server.on_notification::<copilot::request::LogMessage, _>(
+                        Some(server.on_notification::<lsp::notification::LogMessage, _>(
                             move |params, cx| {
                                 weak_lsp_store
                                     .update(cx, |lsp_store, cx| {
@@ -269,7 +269,7 @@ impl LspLogView {
 
         let focus_handle = cx.focus_handle();
         let focus_subscription = cx.on_focus(&focus_handle, window, |log_view, window, cx| {
-            window.focus(&log_view.editor.focus_handle(cx));
+            window.focus(&log_view.editor.focus_handle(cx), cx);
         });
 
         cx.on_release(|log_view, cx| {
@@ -330,6 +330,8 @@ impl LspLogView {
         let server_info = format!(
             "* Server: {NAME} (id {ID})
 
+* Version: {VERSION}
+
 * Binary: {BINARY}
 
 * Registered workspace folders:
@@ -340,6 +342,12 @@ impl LspLogView {
 * Configuration: {CONFIGURATION}",
             NAME = info.status.name,
             ID = info.id,
+            VERSION = info
+                .status
+                .server_version
+                .as_ref()
+                .map(|version| version.as_ref())
+                .unwrap_or("Unknown"),
             BINARY = info
                 .status
                 .binary
@@ -462,7 +470,7 @@ impl LspLogView {
             self.editor_subscriptions = editor_subscriptions;
             cx.notify();
         }
-        self.editor.read(cx).focus_handle(cx).focus(window);
+        self.editor.read(cx).focus_handle(cx).focus(window, cx);
         self.log_store.update(cx, |log_store, cx| {
             let state = log_store.get_language_server_state(server_id)?;
             state.toggled_log_kind = Some(LogKind::Logs);
@@ -494,7 +502,7 @@ impl LspLogView {
             cx.notify();
         }
 
-        self.editor.read(cx).focus_handle(cx).focus(window);
+        self.editor.read(cx).focus_handle(cx).focus(window, cx);
     }
 
     fn show_trace_for_server(
@@ -528,7 +536,7 @@ impl LspLogView {
             });
             cx.notify();
         }
-        self.editor.read(cx).focus_handle(cx).focus(window);
+        self.editor.read(cx).focus_handle(cx).focus(window, cx);
     }
 
     fn show_rpc_trace_for_server(
@@ -572,7 +580,7 @@ impl LspLogView {
             cx.notify();
         }
 
-        self.editor.read(cx).focus_handle(cx).focus(window);
+        self.editor.read(cx).focus_handle(cx).focus(window, cx);
     }
 
     fn toggle_rpc_trace_for_server(
@@ -660,7 +668,7 @@ impl LspLogView {
         self.editor = editor;
         self.editor_subscriptions = editor_subscriptions;
         cx.notify();
-        self.editor.read(cx).focus_handle(cx).focus(window);
+        self.editor.read(cx).focus_handle(cx).focus(window, cx);
         self.log_store.update(cx, |log_store, cx| {
             let state = log_store.get_language_server_state(server_id)?;
             if let Some(log_kind) = state.toggled_log_kind.take() {
@@ -1314,7 +1322,7 @@ impl LspLogToolbarItemView {
                     log_view.show_rpc_trace_for_server(id, window, cx);
                     cx.notify();
                 }
-                window.focus(&log_view.focus_handle);
+                window.focus(&log_view.focus_handle, cx);
             });
         }
         cx.notify();
@@ -1334,6 +1342,7 @@ impl ServerInfo {
             capabilities: server.capabilities(),
             status: LanguageServerStatus {
                 name: server.name(),
+                server_version: server.version(),
                 pending_work: Default::default(),
                 has_pending_diagnostic_updates: false,
                 progress_tokens: Default::default(),

crates/language_tools/src/syntax_tree_view.rs 🔗

@@ -659,7 +659,7 @@ impl SyntaxTreeToolbarItemView {
             buffer_state.active_layer = Some(layer.to_owned());
             view.selected_descendant_ix = None;
             cx.notify();
-            view.focus_handle.focus(window);
+            view.focus_handle.focus(window, cx);
             Some(())
         })
     }

crates/languages/Cargo.toml 🔗

@@ -68,6 +68,7 @@ serde_json.workspace = true
 serde_json_lenient.workspace = true
 settings.workspace = true
 smallvec.workspace = true
+semver.workspace = true
 smol.workspace = true
 snippet.workspace = true
 task.workspace = true

crates/languages/src/cpp/textobjects.scm 🔗

@@ -24,6 +24,12 @@
         [(_) ","?]* @class.inside
         "}")) @class.around
 
+(union_specifier
+    body: (_
+        "{"
+        (_)* @class.inside
+        "}")) @class.around
+
 (class_specifier
   body: (_
       "{"

crates/languages/src/css.rs 🔗

@@ -5,6 +5,7 @@ use language::{LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
 use lsp::{LanguageServerBinary, LanguageServerName, Uri};
 use node_runtime::{NodeRuntime, VersionStrategy};
 use project::lsp_store::language_server_settings;
+use semver::Version;
 use serde_json::json;
 use std::{
     ffi::OsString,
@@ -32,14 +33,14 @@ impl CssLspAdapter {
 }
 
 impl LspInstaller for CssLspAdapter {
-    type BinaryVersion = String;
+    type BinaryVersion = Version;
 
     async fn fetch_latest_server_version(
         &self,
         _: &dyn LspAdapterDelegate,
         _: bool,
         _: &mut AsyncApp,
-    ) -> Result<String> {
+    ) -> Result<Self::BinaryVersion> {
         self.node
             .npm_package_latest_version("vscode-langservers-extracted")
             .await
@@ -65,11 +66,12 @@ impl LspInstaller for CssLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        latest_version: String,
+        latest_version: Self::BinaryVersion,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
         let server_path = container_dir.join(SERVER_PATH);
+        let latest_version = latest_version.to_string();
 
         self.node
             .npm_install_packages(
@@ -87,7 +89,7 @@ impl LspInstaller for CssLspAdapter {
 
     async fn check_if_version_installed(
         &self,
-        version: &String,
+        version: &Self::BinaryVersion,
         container_dir: &PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Option<LanguageServerBinary> {

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/javascript/textobjects.scm 🔗

@@ -18,13 +18,47 @@
         (_)* @function.inside
         "}")) @function.around
 
-(arrow_function
+((arrow_function
     body: (statement_block
         "{"
         (_)* @function.inside
         "}")) @function.around
+ (#not-has-parent? @function.around variable_declarator))
 
-(arrow_function) @function.around
+; Arrow function in variable declaration - capture the full declaration
+([
+    (lexical_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (statement_block
+                    "{"
+                    (_)* @function.inside
+                    "}"))))
+    (variable_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (statement_block
+                    "{"
+                    (_)* @function.inside
+                    "}"))))
+]) @function.around
+
+; Arrow function in variable declaration (captures body for expression-bodied arrows)
+([
+    (lexical_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (_) @function.inside)))
+    (variable_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (_) @function.inside)))
+]) @function.around
+
+; Catch-all for arrow functions in other contexts (callbacks, etc.)
+((arrow_function
+    body: (_) @function.inside) @function.around
+ (#not-has-parent? @function.around variable_declarator))
 
 (generator_function
     body: (_

crates/languages/src/json.rs 🔗

@@ -13,6 +13,7 @@ use language::{
 use lsp::{LanguageServerBinary, LanguageServerName, Uri};
 use node_runtime::{NodeRuntime, VersionStrategy};
 use project::lsp_store::language_server_settings;
+use semver::Version;
 use serde_json::{Value, json};
 use smol::{
     fs::{self},
@@ -142,14 +143,14 @@ impl JsonLspAdapter {
 }
 
 impl LspInstaller for JsonLspAdapter {
-    type BinaryVersion = String;
+    type BinaryVersion = Version;
 
     async fn fetch_latest_server_version(
         &self,
         _: &dyn LspAdapterDelegate,
         _: bool,
         _: &mut AsyncApp,
-    ) -> Result<String> {
+    ) -> Result<Self::BinaryVersion> {
         self.node
             .npm_package_latest_version(Self::PACKAGE_NAME)
             .await
@@ -175,7 +176,7 @@ impl LspInstaller for JsonLspAdapter {
 
     async fn check_if_version_installed(
         &self,
-        version: &String,
+        version: &Self::BinaryVersion,
         container_dir: &PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Option<LanguageServerBinary> {
@@ -204,11 +205,12 @@ impl LspInstaller for JsonLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        latest_version: String,
+        latest_version: Self::BinaryVersion,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
         let server_path = container_dir.join(SERVER_PATH);
+        let latest_version = latest_version.to_string();
 
         self.node
             .npm_install_packages(

crates/languages/src/markdown/config.toml 🔗

@@ -20,6 +20,9 @@ rewrap_prefixes = [
     ">\\s*",
     "[-*+]\\s+\\[[\\sx]\\]\\s+"
 ]
+unordered_list = ["- ", "* ", "+ "]
+ordered_list = [{ pattern = "(\\d+)\\. ", format = "{1}. " }]
+task_list = { prefixes = ["- [ ] ", "- [x] "], continuation = "- [ ] " }
 
 auto_indent_on_paste = false
 auto_indent_using_last_non_empty_line = false

crates/languages/src/python.rs 🔗

@@ -19,6 +19,7 @@ use pet_core::python_environment::{PythonEnvironment, PythonEnvironmentKind};
 use pet_virtualenv::is_virtualenv_dir;
 use project::Fs;
 use project::lsp_store::language_server_settings;
+use semver::Version;
 use serde::{Deserialize, Serialize};
 use serde_json::{Value, json};
 use settings::Settings;
@@ -280,7 +281,7 @@ impl LspInstaller for TyLspAdapter {
         _: &mut AsyncApp,
     ) -> Result<Self::BinaryVersion> {
         let release =
-            latest_github_release("astral-sh/ty", true, true, delegate.http_client()).await?;
+            latest_github_release("astral-sh/ty", true, false, delegate.http_client()).await?;
         let (_, asset_name) = Self::build_asset_name()?;
         let asset = release
             .assets
@@ -294,6 +295,23 @@ impl LspInstaller for TyLspAdapter {
         })
     }
 
+    async fn check_if_user_installed(
+        &self,
+        delegate: &dyn LspAdapterDelegate,
+        _: Option<Toolchain>,
+        _: &AsyncApp,
+    ) -> Option<LanguageServerBinary> {
+        let Some(ty_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await else {
+            return None;
+        };
+        let env = delegate.shell_env().await;
+        Some(LanguageServerBinary {
+            path: ty_bin,
+            env: Some(env),
+            arguments: vec!["server".into()],
+        })
+    }
+
     async fn fetch_server_binary(
         &self,
         latest_version: Self::BinaryVersion,
@@ -621,14 +639,14 @@ impl LspAdapter for PyrightLspAdapter {
 }
 
 impl LspInstaller for PyrightLspAdapter {
-    type BinaryVersion = String;
+    type BinaryVersion = Version;
 
     async fn fetch_latest_server_version(
         &self,
         _: &dyn LspAdapterDelegate,
         _: bool,
         _: &mut AsyncApp,
-    ) -> Result<String> {
+    ) -> Result<Self::BinaryVersion> {
         self.node
             .npm_package_latest_version(Self::SERVER_NAME.as_ref())
             .await
@@ -672,6 +690,7 @@ impl LspInstaller for PyrightLspAdapter {
         delegate: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
         let server_path = container_dir.join(Self::SERVER_PATH);
+        let latest_version = latest_version.to_string();
 
         self.node
             .npm_install_packages(
@@ -1131,6 +1150,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 +1328,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 {
@@ -2024,14 +2059,14 @@ impl LspAdapter for BasedPyrightLspAdapter {
 }
 
 impl LspInstaller for BasedPyrightLspAdapter {
-    type BinaryVersion = String;
+    type BinaryVersion = Version;
 
     async fn fetch_latest_server_version(
         &self,
         _: &dyn LspAdapterDelegate,
         _: bool,
         _: &mut AsyncApp,
-    ) -> Result<String> {
+    ) -> Result<Self::BinaryVersion> {
         self.node
             .npm_package_latest_version(Self::SERVER_NAME.as_ref())
             .await
@@ -2076,6 +2111,7 @@ impl LspInstaller for BasedPyrightLspAdapter {
         delegate: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
         let server_path = container_dir.join(Self::SERVER_PATH);
+        let latest_version = latest_version.to_string();
 
         self.node
             .npm_install_packages(

crates/languages/src/rust.rs 🔗

@@ -355,7 +355,7 @@ impl LspAdapter for RustLspAdapter {
                         | lsp::CompletionTextEdit::Edit(lsp::TextEdit { new_text, .. }),
                     ) = completion.text_edit.as_ref()
                     && let Ok(mut snippet) = snippet::Snippet::parse(new_text)
-                    && !snippet.tabstops.is_empty()
+                    && snippet.tabstops.len() > 1
                 {
                     label = String::new();
 
@@ -375,16 +375,20 @@ impl LspAdapter for RustLspAdapter {
                         let start_pos = range.start as usize;
                         let end_pos = range.end as usize;
 
-                        label.push_str(&snippet.text[text_pos..end_pos]);
-                        text_pos = end_pos;
+                        label.push_str(&snippet.text[text_pos..start_pos]);
 
                         if start_pos == end_pos {
                             let caret_start = label.len();
                             label.push('…');
                             runs.push((caret_start..label.len(), HighlightId::TABSTOP_INSERT_ID));
                         } else {
-                            runs.push((start_pos..end_pos, HighlightId::TABSTOP_REPLACE_ID));
+                            let label_start = label.len();
+                            label.push_str(&snippet.text[start_pos..end_pos]);
+                            let label_end = label.len();
+                            runs.push((label_start..label_end, HighlightId::TABSTOP_REPLACE_ID));
                         }
+
+                        text_pos = end_pos;
                     }
 
                     label.push_str(&snippet.text[text_pos..]);
@@ -417,7 +421,9 @@ impl LspAdapter for RustLspAdapter {
                             0..label.rfind('(').unwrap_or(completion.label.len()),
                             highlight_id,
                         ));
-                    } else if detail_left.is_none() {
+                    } else if detail_left.is_none()
+                        && kind != Some(lsp::CompletionItemKind::SNIPPET)
+                    {
                         return None;
                     }
                 }
@@ -1592,6 +1598,78 @@ mod tests {
                 ],
             ))
         );
+
+        // Postfix completion without actual tabstops (only implicit final $0)
+        // The label should use completion.label so it can be filtered by "ref"
+        let ref_completion = adapter
+            .label_for_completion(
+                &lsp::CompletionItem {
+                    kind: Some(lsp::CompletionItemKind::SNIPPET),
+                    label: "ref".to_string(),
+                    filter_text: Some("ref".to_string()),
+                    label_details: Some(CompletionItemLabelDetails {
+                        detail: None,
+                        description: Some("&expr".to_string()),
+                    }),
+                    detail: Some("&expr".to_string()),
+                    insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
+                    text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                        range: lsp::Range::default(),
+                        new_text: "&String::new()".to_string(),
+                    })),
+                    ..Default::default()
+                },
+                &language,
+            )
+            .await;
+        assert!(
+            ref_completion.is_some(),
+            "ref postfix completion should have a label"
+        );
+        let ref_label = ref_completion.unwrap();
+        let filter_text = &ref_label.text[ref_label.filter_range.clone()];
+        assert!(
+            filter_text.contains("ref"),
+            "filter range text '{filter_text}' should contain 'ref' for filtering to work",
+        );
+
+        // Test for correct range calculation with mixed empty and non-empty tabstops.(See https://github.com/zed-industries/zed/issues/44825)
+        let res = adapter
+            .label_for_completion(
+                &lsp::CompletionItem {
+                    kind: Some(lsp::CompletionItemKind::STRUCT),
+                    label: "Particles".to_string(),
+                    insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
+                    text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                        range: lsp::Range::default(),
+                        new_text: "Particles { pos_x: $1, pos_y: $2, vel_x: $3, vel_y: $4, acc_x: ${5:()}, acc_y: ${6:()}, mass: $7 }$0".to_string(),
+                    })),
+                    ..Default::default()
+                },
+                &language,
+            )
+            .await
+            .unwrap();
+
+        assert_eq!(
+            res,
+            CodeLabel::new(
+                "Particles { pos_x: …, pos_y: …, vel_x: …, vel_y: …, acc_x: (), acc_y: (), mass: … }".to_string(),
+                0..9,
+                vec![
+                    (19..22, HighlightId::TABSTOP_INSERT_ID),
+                    (31..34, HighlightId::TABSTOP_INSERT_ID),
+                    (43..46, HighlightId::TABSTOP_INSERT_ID),
+                    (55..58, HighlightId::TABSTOP_INSERT_ID),
+                    (67..69, HighlightId::TABSTOP_REPLACE_ID),
+                    (78..80, HighlightId::TABSTOP_REPLACE_ID),
+                    (88..91, HighlightId::TABSTOP_INSERT_ID),
+                    (0..9, highlight_type),
+                    (60..65, highlight_field),
+                    (71..76, highlight_field),
+                ],
+            )
+        );
     }
 
     #[gpui::test]

crates/languages/src/tailwind.rs 🔗

@@ -6,6 +6,7 @@ use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolc
 use lsp::{LanguageServerBinary, LanguageServerName, Uri};
 use node_runtime::{NodeRuntime, VersionStrategy};
 use project::lsp_store::language_server_settings;
+use semver::Version;
 use serde_json::{Value, json};
 use std::{
     ffi::OsString,
@@ -39,14 +40,14 @@ impl TailwindLspAdapter {
 }
 
 impl LspInstaller for TailwindLspAdapter {
-    type BinaryVersion = String;
+    type BinaryVersion = Version;
 
     async fn fetch_latest_server_version(
         &self,
         _: &dyn LspAdapterDelegate,
         _: bool,
         _: &mut AsyncApp,
-    ) -> Result<String> {
+    ) -> Result<Self::BinaryVersion> {
         self.node
             .npm_package_latest_version(Self::PACKAGE_NAME)
             .await
@@ -70,11 +71,12 @@ impl LspInstaller for TailwindLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        latest_version: String,
+        latest_version: Self::BinaryVersion,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
         let server_path = container_dir.join(SERVER_PATH);
+        let latest_version = latest_version.to_string();
 
         self.node
             .npm_install_packages(
@@ -92,7 +94,7 @@ impl LspInstaller for TailwindLspAdapter {
 
     async fn check_if_version_installed(
         &self,
-        version: &String,
+        version: &Self::BinaryVersion,
         container_dir: &PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Option<LanguageServerBinary> {

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/tsx/textobjects.scm 🔗

@@ -18,13 +18,47 @@
         (_)* @function.inside
         "}")) @function.around
 
-(arrow_function
+((arrow_function
     body: (statement_block
         "{"
         (_)* @function.inside
         "}")) @function.around
+ (#not-has-parent? @function.around variable_declarator))
 
-(arrow_function) @function.around
+; Arrow function in variable declaration - capture the full declaration
+([
+    (lexical_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (statement_block
+                    "{"
+                    (_)* @function.inside
+                    "}"))))
+    (variable_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (statement_block
+                    "{"
+                    (_)* @function.inside
+                    "}"))))
+]) @function.around
+
+; Arrow function in variable declaration (expression body fallback)
+([
+    (lexical_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (_) @function.inside)))
+    (variable_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (_) @function.inside)))
+]) @function.around
+
+; Catch-all for arrow functions in other contexts (callbacks, etc.)
+((arrow_function
+    body: (_) @function.inside) @function.around
+ (#not-has-parent? @function.around variable_declarator))
 (function_signature) @function.around
 
 (generator_function

crates/languages/src/typescript.rs 🔗

@@ -12,6 +12,7 @@ use language::{
 use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
 use node_runtime::{NodeRuntime, VersionStrategy};
 use project::{Fs, lsp_store::language_server_settings};
+use semver::Version;
 use serde_json::{Value, json};
 use smol::lock::RwLock;
 use std::{
@@ -635,8 +636,8 @@ impl TypeScriptLspAdapter {
 }
 
 pub struct TypeScriptVersions {
-    typescript_version: String,
-    server_version: String,
+    typescript_version: Version,
+    server_version: Version,
 }
 
 impl LspInstaller for TypeScriptLspAdapter {
@@ -647,7 +648,7 @@ impl LspInstaller for TypeScriptLspAdapter {
         _: &dyn LspAdapterDelegate,
         _: bool,
         _: &mut AsyncApp,
-    ) -> Result<TypeScriptVersions> {
+    ) -> Result<Self::BinaryVersion> {
         Ok(TypeScriptVersions {
             typescript_version: self
                 .node
@@ -662,7 +663,7 @@ impl LspInstaller for TypeScriptLspAdapter {
 
     async fn check_if_version_installed(
         &self,
-        version: &TypeScriptVersions,
+        version: &Self::BinaryVersion,
         container_dir: &PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Option<LanguageServerBinary> {
@@ -674,7 +675,7 @@ impl LspInstaller for TypeScriptLspAdapter {
                 Self::PACKAGE_NAME,
                 &server_path,
                 container_dir,
-                VersionStrategy::Latest(version.typescript_version.as_str()),
+                VersionStrategy::Latest(&version.typescript_version),
             )
             .await
         {
@@ -687,7 +688,7 @@ impl LspInstaller for TypeScriptLspAdapter {
                 Self::SERVER_PACKAGE_NAME,
                 &server_path,
                 container_dir,
-                VersionStrategy::Latest(version.server_version.as_str()),
+                VersionStrategy::Latest(&version.server_version),
             )
             .await
         {
@@ -703,7 +704,7 @@ impl LspInstaller for TypeScriptLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        latest_version: TypeScriptVersions,
+        latest_version: Self::BinaryVersion,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
@@ -715,11 +716,11 @@ impl LspInstaller for TypeScriptLspAdapter {
                 &[
                     (
                         Self::PACKAGE_NAME,
-                        latest_version.typescript_version.as_str(),
+                        &latest_version.typescript_version.to_string(),
                     ),
                     (
                         Self::SERVER_PACKAGE_NAME,
-                        latest_version.server_version.as_str(),
+                        &latest_version.server_version.to_string(),
                     ),
                 ],
             )

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/languages/src/typescript/textobjects.scm 🔗

@@ -18,13 +18,48 @@
         (_)* @function.inside
         "}")) @function.around
 
-(arrow_function
+((arrow_function
     body: (statement_block
         "{"
         (_)* @function.inside
         "}")) @function.around
+ (#not-has-parent? @function.around variable_declarator))
 
-(arrow_function) @function.around
+; Arrow function in variable declaration - capture the full declaration
+([
+    (lexical_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (statement_block
+                    "{"
+                    (_)* @function.inside
+                    "}"))))
+    (variable_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (statement_block
+                    "{"
+                    (_)* @function.inside
+                    "}"))))
+]) @function.around
+
+; Arrow function in variable declaration - capture body as @function.inside
+; (for statement blocks, the more specific pattern above captures just the contents)
+([
+    (lexical_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (_) @function.inside)))
+    (variable_declaration
+        (variable_declarator
+            value: (arrow_function
+                body: (_) @function.inside)))
+]) @function.around
+
+; Catch-all for arrow functions in other contexts (callbacks, etc.)
+((arrow_function
+    body: (_) @function.inside) @function.around
+ (#not-has-parent? @function.around variable_declarator))
 (function_signature) @function.around
 
 (generator_function

crates/languages/src/vtsls.rs 🔗

@@ -2,12 +2,17 @@ use anyhow::Result;
 use async_trait::async_trait;
 use collections::HashMap;
 use gpui::AsyncApp;
-use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
+use language::{
+    LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, PromptResponseContext, Toolchain,
+};
 use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
 use node_runtime::{NodeRuntime, VersionStrategy};
 use project::{Fs, lsp_store::language_server_settings};
 use regex::Regex;
+use semver::Version;
 use serde_json::Value;
+use serde_json::json;
+use settings::update_settings_file;
 use std::{
     ffi::OsString,
     path::{Path, PathBuf},
@@ -15,6 +20,11 @@ use std::{
 };
 use util::{ResultExt, maybe, merge_json_value_into};
 
+const ACTION_ALWAYS: &str = "Always";
+const ACTION_NEVER: &str = "Never";
+const UPDATE_IMPORTS_MESSAGE_PATTERN: &str = "Update imports for";
+const VTSLS_SERVER_NAME: &str = "vtsls";
+
 fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
     vec![server_path.into(), "--stdio".into()]
 }
@@ -74,8 +84,8 @@ impl VtslsLspAdapter {
 }
 
 pub struct TypeScriptVersions {
-    typescript_version: String,
-    server_version: String,
+    typescript_version: Version,
+    server_version: Version,
 }
 
 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("vtsls");
@@ -88,7 +98,7 @@ impl LspInstaller for VtslsLspAdapter {
         _: &dyn LspAdapterDelegate,
         _: bool,
         _: &mut AsyncApp,
-    ) -> Result<TypeScriptVersions> {
+    ) -> Result<Self::BinaryVersion> {
         Ok(TypeScriptVersions {
             typescript_version: self.node.npm_package_latest_version("typescript").await?,
             server_version: self
@@ -115,12 +125,15 @@ impl LspInstaller for VtslsLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        latest_version: TypeScriptVersions,
+        latest_version: Self::BinaryVersion,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
         let server_path = container_dir.join(Self::SERVER_PATH);
 
+        let typescript_version = latest_version.typescript_version.to_string();
+        let server_version = latest_version.server_version.to_string();
+
         let mut packages_to_install = Vec::new();
 
         if self
@@ -133,7 +146,7 @@ impl LspInstaller for VtslsLspAdapter {
             )
             .await
         {
-            packages_to_install.push((Self::PACKAGE_NAME, latest_version.server_version.as_str()));
+            packages_to_install.push((Self::PACKAGE_NAME, server_version.as_str()));
         }
 
         if self
@@ -146,10 +159,7 @@ impl LspInstaller for VtslsLspAdapter {
             )
             .await
         {
-            packages_to_install.push((
-                Self::TYPESCRIPT_PACKAGE_NAME,
-                latest_version.typescript_version.as_str(),
-            ));
+            packages_to_install.push((Self::TYPESCRIPT_PACKAGE_NAME, typescript_version.as_str()));
         }
 
         self.node
@@ -301,6 +311,52 @@ impl LspAdapter for VtslsLspAdapter {
             (LanguageName::new_static("TSX"), "typescriptreact".into()),
         ])
     }
+
+    fn process_prompt_response(&self, context: &PromptResponseContext, cx: &mut AsyncApp) {
+        let selected_title = context.selected_action.title.as_str();
+        let is_preference_response =
+            selected_title == ACTION_ALWAYS || selected_title == ACTION_NEVER;
+        if !is_preference_response {
+            return;
+        }
+
+        if context.message.contains(UPDATE_IMPORTS_MESSAGE_PATTERN) {
+            let setting_value = match selected_title {
+                ACTION_ALWAYS => "always",
+                ACTION_NEVER => "never",
+                _ => return,
+            };
+
+            let settings = json!({
+                "typescript": {
+                    "updateImportsOnFileMove": {
+                        "enabled": setting_value
+                    }
+                },
+                "javascript": {
+                    "updateImportsOnFileMove": {
+                        "enabled": setting_value
+                    }
+                }
+            });
+
+            let _ = cx.update(|cx| {
+                update_settings_file(self.fs.clone(), cx, move |content, _| {
+                    let lsp_settings = content
+                        .project
+                        .lsp
+                        .entry(VTSLS_SERVER_NAME.into())
+                        .or_default();
+
+                    if let Some(existing) = &mut lsp_settings.settings {
+                        merge_json_value_into(settings, existing);
+                    } else {
+                        lsp_settings.settings = Some(settings);
+                    }
+                });
+            });
+        }
+    }
 }
 
 async fn get_cached_ts_server_binary(

crates/languages/src/yaml.rs 🔗

@@ -7,6 +7,7 @@ use language::{
 use lsp::{LanguageServerBinary, LanguageServerName, Uri};
 use node_runtime::{NodeRuntime, VersionStrategy};
 use project::lsp_store::language_server_settings;
+use semver::Version;
 use serde_json::Value;
 use settings::{Settings, SettingsLocation};
 use std::{
@@ -35,14 +36,14 @@ impl YamlLspAdapter {
 }
 
 impl LspInstaller for YamlLspAdapter {
-    type BinaryVersion = String;
+    type BinaryVersion = Version;
 
     async fn fetch_latest_server_version(
         &self,
         _: &dyn LspAdapterDelegate,
         _: bool,
         _: &mut AsyncApp,
-    ) -> Result<String> {
+    ) -> Result<Self::BinaryVersion> {
         self.node
             .npm_package_latest_version("yaml-language-server")
             .await
@@ -66,7 +67,7 @@ impl LspInstaller for YamlLspAdapter {
 
     async fn fetch_server_binary(
         &self,
-        latest_version: String,
+        latest_version: Self::BinaryVersion,
         container_dir: PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
@@ -75,7 +76,7 @@ impl LspInstaller for YamlLspAdapter {
         self.node
             .npm_install_packages(
                 &container_dir,
-                &[(Self::PACKAGE_NAME, latest_version.as_str())],
+                &[(Self::PACKAGE_NAME, &latest_version.to_string())],
             )
             .await?;
 
@@ -88,7 +89,7 @@ impl LspInstaller for YamlLspAdapter {
 
     async fn check_if_version_installed(
         &self,
-        version: &String,
+        version: &Self::BinaryVersion,
         container_dir: &PathBuf,
         _: &dyn LspAdapterDelegate,
     ) -> Option<LanguageServerBinary> {

crates/languages/src/yaml/config.toml 🔗

@@ -1,6 +1,6 @@
 name = "YAML"
 grammar = "yaml"
-path_suffixes = ["yml", "yaml", "pixi.lock", "clang-format", "clangd"]
+path_suffixes = ["yml", "yaml", "pixi.lock", "clang-format", "clangd", "bst"]
 line_comments = ["# "]
 autoclose_before = ",]}"
 brackets = [

crates/lsp/src/lsp.rs 🔗

@@ -89,6 +89,7 @@ pub struct LanguageServer {
     outbound_tx: channel::Sender<String>,
     notification_tx: channel::Sender<NotificationSerializer>,
     name: LanguageServerName,
+    version: Option<SharedString>,
     process_name: Arc<str>,
     binary: LanguageServerBinary,
     capabilities: RwLock<ServerCapabilities>,
@@ -501,6 +502,7 @@ impl LanguageServer {
             response_handlers,
             io_handlers,
             name: server_name,
+            version: None,
             process_name: binary
                 .path
                 .file_name()
@@ -882,7 +884,9 @@ impl LanguageServer {
                 window: Some(WindowClientCapabilities {
                     work_done_progress: Some(true),
                     show_message: Some(ShowMessageRequestClientCapabilities {
-                        message_action_item: None,
+                        message_action_item: Some(MessageActionItemCapabilities {
+                            additional_properties_support: Some(true),
+                        }),
                     }),
                     ..WindowClientCapabilities::default()
                 }),
@@ -923,6 +927,7 @@ impl LanguageServer {
                     )
                 })?;
             if let Some(info) = response.server_info {
+                self.version = info.version.map(SharedString::from);
                 self.process_name = info.name.into();
             }
             self.capabilities = RwLock::new(response.capabilities);
@@ -1153,6 +1158,11 @@ impl LanguageServer {
         self.name.clone()
     }
 
+    /// Get the version of the running language server.
+    pub fn version(&self) -> Option<SharedString> {
+        self.version.clone()
+    }
+
     pub fn process_name(&self) -> &str {
         &self.process_name
     }

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,
         }
     }
 }
@@ -705,7 +707,7 @@ impl MarkdownElement {
                                 pending: true,
                                 mode,
                             };
-                            window.focus(&markdown.focus_handle);
+                            window.focus(&markdown.focus_handle, cx);
                         }
 
                         window.prevent_default();
@@ -1071,15 +1073,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(),
@@ -1088,38 +1098,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,
                             );
@@ -1233,17 +1238,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),
                 },
@@ -1506,6 +1512,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>,
@@ -1517,7 +1567,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>,
 }
 
@@ -1553,7 +1603,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,
         }
     }
@@ -1887,7 +1937,7 @@ impl RenderedText {
     }
 
     fn text_for_range(&self, range: Range<usize>) -> String {
-        let mut ret = vec![];
+        let mut accumulator = String::new();
 
         for line in self.lines.iter() {
             if range.start > line.source_end {
@@ -1912,9 +1962,12 @@ impl RenderedText {
             }
             .min(text.len());
 
-            ret.push(text[start..end].to_string());
+            accumulator.push_str(&text[start..end]);
+            accumulator.push('\n');
         }
-        ret.join("\n")
+        // Remove trailing newline
+        accumulator.pop();
+        accumulator
     }
 
     fn link_for_position(&self, position: Point<Pixels>) -> Option<&RenderedLink> {

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -96,7 +96,7 @@ impl MarkdownPreviewView {
                         pane.add_item(Box::new(view.clone()), false, false, None, window, cx)
                     }
                 });
-                editor.focus_handle(cx).focus(window);
+                editor.focus_handle(cx).focus(window, cx);
                 cx.notify();
             }
         });
@@ -370,7 +370,7 @@ impl MarkdownPreviewView {
                     cx,
                     |selections| selections.select_ranges(vec![selection]),
                 );
-                window.focus(&editor.focus_handle(cx));
+                window.focus(&editor.focus_handle(cx), cx);
             });
         }
     }

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 🔗

@@ -165,3 +165,9 @@ pub(crate) mod m_2025_12_08 {
 
     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_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 🔗

@@ -232,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)
 }
@@ -366,6 +370,10 @@ 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/mistral/src/mistral.rs 🔗

@@ -155,15 +155,15 @@ impl Model {
     pub fn max_token_count(&self) -> u64 {
         match self {
             Self::CodestralLatest => 256000,
-            Self::MistralLargeLatest => 131000,
+            Self::MistralLargeLatest => 256000,
             Self::MistralMediumLatest => 128000,
             Self::MistralSmallLatest => 32000,
-            Self::MagistralMediumLatest => 40000,
-            Self::MagistralSmallLatest => 40000,
+            Self::MagistralMediumLatest => 128000,
+            Self::MagistralSmallLatest => 128000,
             Self::OpenMistralNemo => 131000,
             Self::OpenCodestralMamba => 256000,
-            Self::DevstralMediumLatest => 128000,
-            Self::DevstralSmallLatest => 262144,
+            Self::DevstralMediumLatest => 256000,
+            Self::DevstralSmallLatest => 256000,
             Self::Pixtral12BLatest => 128000,
             Self::PixtralLargeLatest => 128000,
             Self::Custom { max_tokens, .. } => *max_tokens,

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -19,9 +19,9 @@ use gpui::{App, Context, Entity, EntityId, EventEmitter};
 use itertools::Itertools;
 use language::{
     AutoindentMode, BracketMatch, Buffer, BufferChunks, BufferRow, BufferSnapshot, Capability,
-    CharClassifier, CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, DiskState,
-    File, IndentGuideSettings, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16,
-    Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _,
+    CharClassifier, CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, File,
+    IndentGuideSettings, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16, Outline,
+    OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _,
     ToPoint as _, TransactionId, TreeSitterOptions, Unclipped,
     language_settings::{LanguageSettings, language_settings},
 };
@@ -2610,9 +2610,8 @@ impl MultiBuffer {
         for range in ranges {
             let range = range.to_point(&snapshot);
             let start = snapshot.point_to_offset(Point::new(range.start.row, 0));
-            let end = snapshot.point_to_offset(Point::new(range.end.row + 1, 0));
-            let start = start.saturating_sub_usize(1);
-            let end = snapshot.len().min(end + 1usize);
+            let end = (snapshot.point_to_offset(Point::new(range.end.row + 1, 0)) + 1usize)
+                .min(snapshot.len());
             cursor.seek(&start, Bias::Right);
             while let Some(item) = cursor.item() {
                 if *cursor.start() >= end {
@@ -2981,7 +2980,7 @@ impl MultiBuffer {
             *is_dirty |= buffer.is_dirty();
             *has_deleted_file |= buffer
                 .file()
-                .is_some_and(|file| file.disk_state() == DiskState::Deleted);
+                .is_some_and(|file| file.disk_state().is_deleted());
             *has_conflict |= buffer.has_conflict();
         }
         if edited {

crates/multi_buffer/src/multi_buffer_tests.rs 🔗

@@ -4480,6 +4480,19 @@ async fn test_word_diff_simple_replacement(cx: &mut TestAppContext) {
     assert_eq!(word_diffs, vec!["world", "bar", "WORLD", "BAR"]);
 }
 
+#[gpui::test]
+async fn test_word_diff_white_space(cx: &mut TestAppContext) {
+    let settings_store = cx.update(|cx| SettingsStore::test(cx));
+    cx.set_global(settings_store);
+
+    let base_text = "hello world foo bar\n";
+    let modified_text = "    hello world foo bar\n";
+
+    let word_diffs = collect_word_diffs(base_text, modified_text, cx);
+
+    assert_eq!(word_diffs, vec!["    "]);
+}
+
 #[gpui::test]
 async fn test_word_diff_consecutive_modified_lines(cx: &mut TestAppContext) {
     let settings_store = cx.update(|cx| SettingsStore::test(cx));

crates/node_runtime/src/node_runtime.rs 🔗

@@ -32,9 +32,9 @@ pub struct NodeBinaryOptions {
 
 pub enum VersionStrategy<'a> {
     /// Install if current version doesn't match pinned version
-    Pin(&'a str),
+    Pin(&'a Version),
     /// Install if current version is older than latest version
-    Latest(&'a str),
+    Latest(&'a Version),
 }
 
 #[derive(Clone)]
@@ -221,14 +221,14 @@ impl NodeRuntime {
         &self,
         local_package_directory: &Path,
         name: &str,
-    ) -> Result<Option<String>> {
+    ) -> Result<Option<Version>> {
         self.instance()
             .await
             .npm_package_installed_version(local_package_directory, name)
             .await
     }
 
-    pub async fn npm_package_latest_version(&self, name: &str) -> Result<String> {
+    pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {
         let http = self.0.lock().await.http.clone();
         let output = self
             .instance()
@@ -271,16 +271,19 @@ impl NodeRuntime {
             .map(|(name, version)| format!("{name}@{version}"))
             .collect();
 
-        let mut arguments: Vec<_> = packages.iter().map(|p| p.as_str()).collect();
-        arguments.extend_from_slice(&[
-            "--save-exact",
-            "--fetch-retry-mintimeout",
-            "2000",
-            "--fetch-retry-maxtimeout",
-            "5000",
-            "--fetch-timeout",
-            "5000",
-        ]);
+        let arguments: Vec<_> = packages
+            .iter()
+            .map(|p| p.as_str())
+            .chain([
+                "--save-exact",
+                "--fetch-retry-mintimeout",
+                "2000",
+                "--fetch-retry-maxtimeout",
+                "5000",
+                "--fetch-timeout",
+                "5000",
+            ])
+            .collect();
 
         // This is also wrong because the directory is wrong.
         self.run_npm_subcommand(Some(directory), "install", &arguments)
@@ -311,23 +314,9 @@ impl NodeRuntime {
             return true;
         };
 
-        let Some(installed_version) = Version::parse(&installed_version).log_err() else {
-            return true;
-        };
-
         match version_strategy {
-            VersionStrategy::Pin(pinned_version) => {
-                let Some(pinned_version) = Version::parse(pinned_version).log_err() else {
-                    return true;
-                };
-                installed_version != pinned_version
-            }
-            VersionStrategy::Latest(latest_version) => {
-                let Some(latest_version) = Version::parse(latest_version).log_err() else {
-                    return true;
-                };
-                installed_version < latest_version
-            }
+            VersionStrategy::Pin(pinned_version) => &installed_version != pinned_version,
+            VersionStrategy::Latest(latest_version) => &installed_version < latest_version,
         }
     }
 }
@@ -342,12 +331,12 @@ enum ArchiveType {
 pub struct NpmInfo {
     #[serde(default)]
     dist_tags: NpmInfoDistTags,
-    versions: Vec<String>,
+    versions: Vec<Version>,
 }
 
 #[derive(Debug, Deserialize, Default)]
 pub struct NpmInfoDistTags {
-    latest: Option<String>,
+    latest: Option<Version>,
 }
 
 #[async_trait::async_trait]
@@ -367,7 +356,7 @@ trait NodeRuntimeTrait: Send + Sync {
         &self,
         local_package_directory: &Path,
         name: &str,
-    ) -> Result<Option<String>>;
+    ) -> Result<Option<Version>>;
 }
 
 #[derive(Clone)]
@@ -601,7 +590,7 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
         &self,
         local_package_directory: &Path,
         name: &str,
-    ) -> Result<Option<String>> {
+    ) -> Result<Option<Version>> {
         read_package_installed_version(local_package_directory.join("node_modules"), name).await
     }
 }
@@ -726,7 +715,7 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
         &self,
         local_package_directory: &Path,
         name: &str,
-    ) -> Result<Option<String>> {
+    ) -> Result<Option<Version>> {
         read_package_installed_version(local_package_directory.join("node_modules"), name).await
         // todo: allow returning a globally installed version (requires callers not to hard-code the path)
     }
@@ -735,7 +724,7 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
 pub async fn read_package_installed_version(
     node_module_directory: PathBuf,
     name: &str,
-) -> Result<Option<String>> {
+) -> Result<Option<Version>> {
     let package_json_path = node_module_directory.join(name).join("package.json");
 
     let mut file = match fs::File::open(package_json_path).await {
@@ -751,7 +740,7 @@ pub async fn read_package_installed_version(
 
     #[derive(Deserialize)]
     struct PackageJson {
-        version: String,
+        version: Version,
     }
 
     let mut contents = String::new();
@@ -788,7 +777,7 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime {
         &self,
         _local_package_directory: &Path,
         _: &str,
-    ) -> Result<Option<String>> {
+    ) -> Result<Option<Version>> {
         bail!("{}", self.error_message)
     }
 }

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/basics_page.rs 🔗

@@ -3,6 +3,7 @@ use std::sync::Arc;
 use client::TelemetrySettings;
 use fs::Fs;
 use gpui::{Action, App, IntoElement};
+use project::project_settings::ProjectSettings;
 use settings::{BaseKeymap, Settings, update_settings_file};
 use theme::{
     Appearance, SystemAppearance, ThemeAppearanceMode, ThemeName, ThemeRegistry, ThemeSelection,
@@ -10,8 +11,8 @@ use theme::{
 };
 use ui::{
     Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor,
-    ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, prelude::*,
-    rems_from_px,
+    ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip,
+    prelude::*, rems_from_px,
 };
 use vim_mode_setting::VimModeSetting;
 
@@ -409,6 +410,48 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
     })
 }
 
+fn render_worktree_auto_trust_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
+    let toggle_state = if ProjectSettings::get_global(cx).session.trust_all_worktrees {
+        ui::ToggleState::Selected
+    } else {
+        ui::ToggleState::Unselected
+    };
+
+    let tooltip_description = "Zed can only allow services like language servers, project settings, and MCP servers to run after you mark a new project as trusted.";
+
+    SwitchField::new(
+        "onboarding-auto-trust-worktrees",
+        Some("Trust All Projects By Default"),
+        Some("Automatically mark all new projects as trusted to unlock all Zed's features".into()),
+        toggle_state,
+        {
+            let fs = <dyn Fs>::global(cx);
+            move |&selection, _, cx| {
+                let trust = match selection {
+                    ToggleState::Selected => true,
+                    ToggleState::Unselected => false,
+                    ToggleState::Indeterminate => {
+                        return;
+                    }
+                };
+                update_settings_file(fs.clone(), cx, move |setting, _| {
+                    setting.session.get_or_insert_default().trust_all_worktrees = Some(trust);
+                });
+
+                telemetry::event!(
+                    "Welcome Page Worktree Auto Trust Toggled",
+                    options = if trust { "on" } else { "off" }
+                );
+            }
+        },
+    )
+    .tab_index({
+        *tab_index += 1;
+        *tab_index - 1
+    })
+    .tooltip(Tooltip::text(tooltip_description))
+}
+
 fn render_setting_import_button(
     tab_index: isize,
     label: SharedString,
@@ -481,6 +524,7 @@ pub(crate) fn render_basics_page(cx: &mut App) -> impl IntoElement {
         .child(render_base_keymap_section(&mut tab_index, cx))
         .child(render_import_settings_section(&mut tab_index, cx))
         .child(render_vim_mode_switch(&mut tab_index, cx))
+        .child(render_worktree_auto_trust_switch(&mut tab_index, cx))
         .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
         .child(render_telemetry_section(&mut tab_index, cx))
 }

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,
@@ -196,7 +190,7 @@ pub fn show_onboarding_view(app_state: Arc<AppState>, cx: &mut App) -> Task<anyh
                 let onboarding_page = Onboarding::new(workspace, cx);
                 workspace.add_item_to_center(Box::new(onboarding_page.clone()), window, cx);
 
-                window.focus(&onboarding_page.focus_handle(cx));
+                window.focus(&onboarding_page.focus_handle(cx), cx);
 
                 cx.notify();
             };
@@ -283,11 +277,11 @@ impl Render for Onboarding {
             .on_action(Self::handle_sign_in)
             .on_action(Self::handle_open_account)
             .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
-                window.focus_next();
+                window.focus_next(cx);
                 cx.notify();
             }))
             .on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| {
-                window.focus_prev();
+                window.focus_prev(cx);
                 cx.notify();
             }))
             .child(
@@ -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/outline/src/outline.rs 🔗

@@ -311,7 +311,7 @@ impl PickerDelegate for OutlineViewDelegate {
                     |s| s.select_ranges([rows.start..rows.start]),
                 );
                 active_editor.clear_row_highlights::<OutlineRowHighlights>();
-                window.focus(&active_editor.focus_handle(cx));
+                window.focus(&active_editor.focus_handle(cx), cx);
             }
         });
 

crates/outline_panel/src/outline_panel.rs 🔗

@@ -998,9 +998,9 @@ impl OutlinePanel {
 
     fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
         if self.filter_editor.focus_handle(cx).is_focused(window) {
-            self.focus_handle.focus(window);
+            self.focus_handle.focus(window, cx);
         } else {
-            self.filter_editor.focus_handle(cx).focus(window);
+            self.filter_editor.focus_handle(cx).focus(window, cx);
         }
 
         if self.context_menu.is_some() {
@@ -1153,9 +1153,9 @@ impl OutlinePanel {
                 }
 
                 if change_focus {
-                    active_editor.focus_handle(cx).focus(window);
+                    active_editor.focus_handle(cx).focus(window, cx);
                 } else {
-                    self.focus_handle.focus(window);
+                    self.focus_handle.focus(window, cx);
                 }
             }
         }
@@ -1458,7 +1458,7 @@ impl OutlinePanel {
                     Box::new(zed_actions::workspace::CopyRelativePath),
                 )
         });
-        window.focus(&context_menu.focus_handle(cx));
+        window.focus(&context_menu.focus_handle(cx), cx);
         let subscription = cx.subscribe(&context_menu, |outline_panel, _, _: &DismissEvent, cx| {
             outline_panel.context_menu.take();
             cx.notify();
@@ -4539,7 +4539,7 @@ impl OutlinePanel {
         cx: &mut Context<Self>,
     ) {
         if focus {
-            self.focus_handle.focus(window);
+            self.focus_handle.focus(window, cx);
         }
         let ix = self
             .cached_entries

crates/outline_panel/src/outline_panel_settings.rs 🔗

@@ -50,7 +50,13 @@ impl Settings for OutlinePanelSettings {
             dock: panel.dock.unwrap(),
             file_icons: panel.file_icons.unwrap(),
             folder_icons: panel.folder_icons.unwrap(),
-            git_status: panel.git_status.unwrap(),
+            git_status: panel.git_status.unwrap()
+                && content
+                    .git
+                    .unwrap()
+                    .enabled
+                    .unwrap()
+                    .is_git_status_enabled(),
             indent_size: panel.indent_size.unwrap(),
             indent_guides: IndentGuidesSettings {
                 show: panel.indent_guides.unwrap().show.unwrap(),

crates/picker/src/picker.rs 🔗

@@ -384,7 +384,7 @@ impl<D: PickerDelegate> Picker<D> {
     }
 
     pub fn focus(&self, window: &mut Window, cx: &mut App) {
-        self.focus_handle(cx).focus(window);
+        self.focus_handle(cx).focus(window, cx);
     }
 
     /// Handles the selecting an index, and passing the change to the delegate.

crates/project/Cargo.toml 🔗

@@ -40,6 +40,7 @@ clock.workspace = true
 collections.workspace = true
 context_server.workspace = true
 dap.workspace = true
+encoding_rs.workspace = true
 extension.workspace = true
 fancy-regex.workspace = true
 fs.workspace = true
@@ -96,6 +97,7 @@ tracing.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }
+db = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
 context_server = { workspace = true, features = ["test-support"] }
 buffer_diff = { workspace = true, features = ["test-support"] }

crates/project/src/agent_server_store.rs 🔗

@@ -22,6 +22,7 @@ use rpc::{
     proto::{self, ExternalExtensionAgent},
 };
 use schemars::JsonSchema;
+use semver::Version;
 use serde::{Deserialize, Serialize};
 use settings::{RegisterSetting, SettingsStore};
 use task::{Shell, SpawnInTerminal};
@@ -459,7 +460,7 @@ impl AgentServerStore {
                     .gemini
                     .as_ref()
                     .and_then(|settings| settings.ignore_system_version)
-                    .unwrap_or(false),
+                    .unwrap_or(true),
             }),
         );
         self.external_agents.insert(
@@ -974,11 +975,10 @@ fn get_or_npm_install_builtin_agent(
         }
 
         versions.sort();
-        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
+        let newest_version = if let Some((version, _)) = versions.last().cloned()
             && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
         {
-            versions.pop();
-            Some(file_name)
+            versions.pop()
         } else {
             None
         };
@@ -1004,9 +1004,8 @@ fn get_or_npm_install_builtin_agent(
         })
         .detach();
 
-        let version = if let Some(file_name) = newest_version {
+        let version = if let Some((version, file_name)) = newest_version {
             cx.background_spawn({
-                let file_name = file_name.clone();
                 let dir = dir.clone();
                 let fs = fs.clone();
                 async move {
@@ -1015,7 +1014,7 @@ fn get_or_npm_install_builtin_agent(
                         .await
                         .ok();
                     if let Some(latest_version) = latest_version
-                        && &latest_version != &file_name.to_string_lossy()
+                        && latest_version != version
                     {
                         let download_result = download_latest_version(
                             fs,
@@ -1028,7 +1027,9 @@ fn get_or_npm_install_builtin_agent(
                         if let Some(mut new_version_available) = new_version_available
                             && download_result.is_some()
                         {
-                            new_version_available.send(Some(latest_version)).ok();
+                            new_version_available
+                                .send(Some(latest_version.to_string()))
+                                .ok();
                         }
                     }
                 }
@@ -1047,6 +1048,7 @@ fn get_or_npm_install_builtin_agent(
                 package_name.clone(),
             ))
             .await?
+            .to_string()
             .into()
         };
 
@@ -1093,7 +1095,7 @@ async fn download_latest_version(
     dir: PathBuf,
     node_runtime: NodeRuntime,
     package_name: SharedString,
-) -> Result<String> {
+) -> Result<Version> {
     log::debug!("downloading latest version of {package_name}");
 
     let tmp_dir = tempfile::tempdir_in(&dir)?;
@@ -1109,7 +1111,7 @@ async fn download_latest_version(
 
     fs.rename(
         &tmp_dir.keep(),
-        &dir.join(&version),
+        &dir.join(version.to_string()),
         RenameOptions {
             ignore_if_exists: true,
             overwrite: true,

crates/project/src/buffer_store.rs 🔗

@@ -376,6 +376,8 @@ impl LocalBufferStore {
 
         let text = buffer.as_rope().clone();
         let line_ending = buffer.line_ending();
+        let encoding = buffer.encoding();
+        let has_bom = buffer.has_bom();
         let version = buffer.version();
         let buffer_id = buffer.remote_id();
         let file = buffer.file().cloned();
@@ -387,7 +389,7 @@ impl LocalBufferStore {
         }
 
         let save = worktree.update(cx, |worktree, cx| {
-            worktree.write_file(path, text, line_ending, cx)
+            worktree.write_file(path, text, line_ending, encoding, has_bom, cx)
         });
 
         cx.spawn(async move |this, cx| {
@@ -630,7 +632,11 @@ impl LocalBufferStore {
                         })
                         .await;
                     cx.insert_entity(reservation, |_| {
-                        Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite)
+                        let mut buffer =
+                            Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite);
+                        buffer.set_encoding(loaded.encoding);
+                        buffer.set_has_bom(loaded.has_bom);
+                        buffer
                     })?
                 }
                 Err(error) if is_not_found_error(&error) => cx.new(|cx| {

crates/project/src/debugger/breakpoint_store.rs 🔗

@@ -23,7 +23,7 @@ use super::session::ThreadId;
 
 mod breakpoints_in_file {
     use collections::HashMap;
-    use language::{BufferEvent, DiskState};
+    use language::BufferEvent;
 
     use super::*;
 
@@ -82,7 +82,7 @@ mod breakpoints_in_file {
                     BufferEvent::FileHandleChanged => {
                         let entity_id = buffer.entity_id();
 
-                        if buffer.read(cx).file().is_none_or(|f| f.disk_state() == DiskState::Deleted) {
+                        if buffer.read(cx).file().is_none_or(|f| f.disk_state().is_deleted()) {
                             breakpoint_store.breakpoints.retain(|_, breakpoints_in_file| {
                                 breakpoints_in_file.buffer.entity_id() != entity_id
                             });

crates/project/src/debugger/session.rs 🔗

@@ -3118,10 +3118,11 @@ async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Resul
             .await
             .context("getting installed companion version")?
             .context("companion was not installed")?;
-        smol::fs::rename(temp_dir.path(), dir.join(&version))
+        let version_folder = dir.join(version.to_string());
+        smol::fs::rename(temp_dir.path(), &version_folder)
             .await
             .context("moving companion package into place")?;
-        Ok(dir.join(version))
+        Ok(version_folder)
     }
 
     let dir = paths::debug_adapters_dir().join("js-debug-companion");
@@ -3134,19 +3135,23 @@ async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Resul
                     .await
                     .context("creating companion installation directory")?;
 
-                let mut children = smol::fs::read_dir(&dir)
+                let children = smol::fs::read_dir(&dir)
                     .await
                     .context("reading companion installation directory")?
                     .try_collect::<Vec<_>>()
                     .await
                     .context("reading companion installation directory entries")?;
-                children
-                    .sort_by_key(|child| semver::Version::parse(child.file_name().to_str()?).ok());
 
-                let latest_installed_version = children.last().and_then(|child| {
-                    let version = child.file_name().into_string().ok()?;
-                    Some((child.path(), version))
-                });
+                let latest_installed_version = children
+                    .iter()
+                    .filter_map(|child| {
+                        Some((
+                            child.path(),
+                            semver::Version::parse(child.file_name().to_str()?).ok()?,
+                        ))
+                    })
+                    .max_by_key(|(_, version)| version.clone());
+
                 let latest_version = node
                     .npm_package_latest_version(PACKAGE_NAME)
                     .await

crates/project/src/git_store.rs 🔗

@@ -4205,74 +4205,29 @@ impl Repository {
         entries: Vec<RepoPath>,
         cx: &mut Context<Self>,
     ) -> Task<anyhow::Result<()>> {
-        if entries.is_empty() {
-            return Task::ready(Ok(()));
-        }
-        let id = self.id;
-        let save_tasks = self.save_buffers(&entries, cx);
-        let paths = entries
-            .iter()
-            .map(|p| p.as_unix_str())
-            .collect::<Vec<_>>()
-            .join(" ");
-        let status = format!("git add {paths}");
-        let job_key = GitJobKey::WriteIndex(entries.clone());
-
-        self.spawn_job_with_tracking(
-            entries.clone(),
-            pending_op::GitStatus::Staged,
-            cx,
-            async move |this, cx| {
-                for save_task in save_tasks {
-                    save_task.await?;
-                }
-
-                this.update(cx, |this, _| {
-                    this.send_keyed_job(
-                        Some(job_key),
-                        Some(status.into()),
-                        move |git_repo, _cx| async move {
-                            match git_repo {
-                                RepositoryState::Local(LocalRepositoryState {
-                                    backend,
-                                    environment,
-                                    ..
-                                }) => backend.stage_paths(entries, environment.clone()).await,
-                                RepositoryState::Remote(RemoteRepositoryState {
-                                    project_id,
-                                    client,
-                                }) => {
-                                    client
-                                        .request(proto::Stage {
-                                            project_id: project_id.0,
-                                            repository_id: id.to_proto(),
-                                            paths: entries
-                                                .into_iter()
-                                                .map(|repo_path| repo_path.to_proto())
-                                                .collect(),
-                                        })
-                                        .await
-                                        .context("sending stage request")?;
-
-                                    Ok(())
-                                }
-                            }
-                        },
-                    )
-                })?
-                .await?
-            },
-        )
+        self.stage_or_unstage_entries(true, entries, cx)
     }
 
     pub fn unstage_entries(
         &mut self,
         entries: Vec<RepoPath>,
         cx: &mut Context<Self>,
+    ) -> Task<anyhow::Result<()>> {
+        self.stage_or_unstage_entries(false, entries, cx)
+    }
+
+    fn stage_or_unstage_entries(
+        &mut self,
+        stage: bool,
+        entries: Vec<RepoPath>,
+        cx: &mut Context<Self>,
     ) -> Task<anyhow::Result<()>> {
         if entries.is_empty() {
             return Task::ready(Ok(()));
         }
+        let Some(git_store) = self.git_store.upgrade() else {
+            return Task::ready(Ok(()));
+        };
         let id = self.id;
         let save_tasks = self.save_buffers(&entries, cx);
         let paths = entries
@@ -4280,48 +4235,164 @@ impl Repository {
             .map(|p| p.as_unix_str())
             .collect::<Vec<_>>()
             .join(" ");
-        let status = format!("git reset {paths}");
+        let status = if stage {
+            format!("git add {paths}")
+        } else {
+            format!("git reset {paths}")
+        };
         let job_key = GitJobKey::WriteIndex(entries.clone());
 
         self.spawn_job_with_tracking(
             entries.clone(),
-            pending_op::GitStatus::Unstaged,
+            if stage {
+                pending_op::GitStatus::Staged
+            } else {
+                pending_op::GitStatus::Unstaged
+            },
             cx,
             async move |this, cx| {
                 for save_task in save_tasks {
                     save_task.await?;
                 }
 
-                this.update(cx, |this, _| {
+                this.update(cx, |this, cx| {
+                    let weak_this = cx.weak_entity();
                     this.send_keyed_job(
                         Some(job_key),
                         Some(status.into()),
-                        move |git_repo, _cx| async move {
-                            match git_repo {
+                        move |git_repo, mut cx| async move {
+                            let hunk_staging_operation_counts = weak_this
+                                .update(&mut cx, |this, cx| {
+                                    let mut hunk_staging_operation_counts = HashMap::default();
+                                    for path in &entries {
+                                        let Some(project_path) =
+                                            this.repo_path_to_project_path(path, cx)
+                                        else {
+                                            continue;
+                                        };
+                                        let Some(buffer) = git_store
+                                            .read(cx)
+                                            .buffer_store
+                                            .read(cx)
+                                            .get_by_path(&project_path)
+                                        else {
+                                            continue;
+                                        };
+                                        let Some(diff_state) = git_store
+                                            .read(cx)
+                                            .diffs
+                                            .get(&buffer.read(cx).remote_id())
+                                            .cloned()
+                                        else {
+                                            continue;
+                                        };
+                                        let Some(uncommitted_diff) =
+                                            diff_state.read(cx).uncommitted_diff.as_ref().and_then(
+                                                |uncommitted_diff| uncommitted_diff.upgrade(),
+                                            )
+                                        else {
+                                            continue;
+                                        };
+                                        let buffer_snapshot = buffer.read(cx).text_snapshot();
+                                        let file_exists = buffer
+                                            .read(cx)
+                                            .file()
+                                            .is_some_and(|file| file.disk_state().exists());
+                                        let hunk_staging_operation_count =
+                                            diff_state.update(cx, |diff_state, cx| {
+                                                uncommitted_diff.update(
+                                                    cx,
+                                                    |uncommitted_diff, cx| {
+                                                        uncommitted_diff
+                                                            .stage_or_unstage_all_hunks(
+                                                                stage,
+                                                                &buffer_snapshot,
+                                                                file_exists,
+                                                                cx,
+                                                            );
+                                                    },
+                                                );
+
+                                                diff_state.hunk_staging_operation_count += 1;
+                                                diff_state.hunk_staging_operation_count
+                                            });
+                                        hunk_staging_operation_counts.insert(
+                                            diff_state.downgrade(),
+                                            hunk_staging_operation_count,
+                                        );
+                                    }
+                                    hunk_staging_operation_counts
+                                })
+                                .unwrap_or_default();
+
+                            let result = match git_repo {
                                 RepositoryState::Local(LocalRepositoryState {
                                     backend,
                                     environment,
                                     ..
-                                }) => backend.unstage_paths(entries, environment).await,
+                                }) => {
+                                    if stage {
+                                        backend.stage_paths(entries, environment.clone()).await
+                                    } else {
+                                        backend.unstage_paths(entries, environment.clone()).await
+                                    }
+                                }
                                 RepositoryState::Remote(RemoteRepositoryState {
                                     project_id,
                                     client,
                                 }) => {
-                                    client
-                                        .request(proto::Unstage {
-                                            project_id: project_id.0,
-                                            repository_id: id.to_proto(),
-                                            paths: entries
-                                                .into_iter()
-                                                .map(|repo_path| repo_path.to_proto())
-                                                .collect(),
-                                        })
-                                        .await
-                                        .context("sending unstage request")?;
-
-                                    Ok(())
+                                    if stage {
+                                        client
+                                            .request(proto::Stage {
+                                                project_id: project_id.0,
+                                                repository_id: id.to_proto(),
+                                                paths: entries
+                                                    .into_iter()
+                                                    .map(|repo_path| repo_path.to_proto())
+                                                    .collect(),
+                                            })
+                                            .await
+                                            .context("sending stage request")
+                                            .map(|_| ())
+                                    } else {
+                                        client
+                                            .request(proto::Unstage {
+                                                project_id: project_id.0,
+                                                repository_id: id.to_proto(),
+                                                paths: entries
+                                                    .into_iter()
+                                                    .map(|repo_path| repo_path.to_proto())
+                                                    .collect(),
+                                            })
+                                            .await
+                                            .context("sending unstage request")
+                                            .map(|_| ())
+                                    }
                                 }
+                            };
+
+                            for (diff_state, hunk_staging_operation_count) in
+                                hunk_staging_operation_counts
+                            {
+                                diff_state
+                                    .update(&mut cx, |diff_state, cx| {
+                                        if result.is_ok() {
+                                            diff_state.hunk_staging_operation_count_as_of_write =
+                                                hunk_staging_operation_count;
+                                        } else if let Some(uncommitted_diff) =
+                                            &diff_state.uncommitted_diff
+                                        {
+                                            uncommitted_diff
+                                                .update(cx, |uncommitted_diff, cx| {
+                                                    uncommitted_diff.clear_pending_hunks(cx);
+                                                })
+                                                .ok();
+                                        }
+                                    })
+                                    .ok();
                             }
+
+                            result
                         },
                     )
                 })?
@@ -4347,7 +4418,7 @@ impl Repository {
                 }
             })
             .collect();
-        self.stage_entries(to_stage, cx)
+        self.stage_or_unstage_entries(true, to_stage, cx)
     }
 
     pub fn unstage_all(&mut self, cx: &mut Context<Self>) -> Task<anyhow::Result<()>> {
@@ -4367,7 +4438,7 @@ impl Repository {
                 }
             })
             .collect();
-        self.unstage_entries(to_unstage, cx)
+        self.stage_or_unstage_entries(false, to_unstage, cx)
     }
 
     pub fn stash_all(&mut self, cx: &mut Context<Self>) -> Task<anyhow::Result<()>> {
@@ -5867,6 +5938,11 @@ impl Repository {
         self.pending_ops.edit(edits, ());
         ids
     }
+    pub fn default_remote_url(&self) -> Option<String> {
+        self.remote_upstream_url
+            .clone()
+            .or(self.remote_origin_url.clone())
+    }
 }
 
 fn get_permalink_in_rust_registry_src(

crates/project/src/lsp_store.rs 🔗

@@ -38,6 +38,7 @@ use crate::{
     prettier_store::{self, PrettierStore, PrettierStoreEvent},
     project_settings::{LspSettings, ProjectSettings},
     toolchain_store::{LocalToolchainStore, ToolchainStoreEvent},
+    trusted_worktrees::{PathTrust, TrustedWorktrees, TrustedWorktreesEvent},
     worktree_store::{WorktreeStore, WorktreeStoreEvent},
     yarn::YarnPathStore,
 };
@@ -54,8 +55,8 @@ use futures::{
 };
 use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
 use gpui::{
-    App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString, Task,
-    WeakEntity,
+    App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString,
+    Subscription, Task, WeakEntity,
 };
 use http_client::HttpClient;
 use itertools::Itertools as _;
@@ -92,17 +93,19 @@ use rpc::{
     AnyProtoClient, ErrorCode, ErrorExt as _,
     proto::{LspRequestId, LspRequestMessage as _},
 };
+use semver::Version;
 use serde::Serialize;
 use serde_json::Value;
 use settings::{Settings, SettingsLocation, SettingsStore};
 use sha2::{Digest, Sha256};
-use smol::channel::Sender;
+use smol::channel::{Receiver, Sender};
 use snippet::Snippet;
 use std::{
     any::TypeId,
     borrow::Cow,
     cell::RefCell,
     cmp::{Ordering, Reverse},
+    collections::hash_map,
     convert::TryInto,
     ffi::OsStr,
     future::ready,
@@ -125,6 +128,7 @@ use util::{
     ConnectionResult, ResultExt as _, debug_panic, defer, maybe, merge_json_value_into,
     paths::{PathStyle, SanitizedPath},
     post_inc,
+    redact::redact_command,
     rel_path::RelPath,
 };
 
@@ -296,6 +300,7 @@ pub struct LocalLspStore {
         LanguageServerId,
         HashMap<Option<SharedString>, HashMap<PathBuf, Option<SharedString>>>,
     >,
+    restricted_worktrees_tasks: HashMap<WorktreeId, (Subscription, Receiver<()>)>,
 }
 
 impl LocalLspStore {
@@ -367,7 +372,8 @@ impl LocalLspStore {
     ) -> LanguageServerId {
         let worktree = worktree_handle.read(cx);
 
-        let root_path = worktree.abs_path();
+        let worktree_id = worktree.id();
+        let worktree_abs_path = worktree.abs_path();
         let toolchain = key.toolchain.clone();
         let override_options = settings.initialization_options.clone();
 
@@ -375,19 +381,49 @@ impl LocalLspStore {
 
         let server_id = self.languages.next_language_server_id();
         log::trace!(
-            "attempting to start language server {:?}, path: {root_path:?}, id: {server_id}",
+            "attempting to start language server {:?}, path: {worktree_abs_path:?}, id: {server_id}",
             adapter.name.0
         );
 
+        let untrusted_worktree_task =
+            TrustedWorktrees::try_get_global(cx).and_then(|trusted_worktrees| {
+                let can_trust = trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                    trusted_worktrees.can_trust(worktree_id, cx)
+                });
+                if can_trust {
+                    self.restricted_worktrees_tasks.remove(&worktree_id);
+                    None
+                } else {
+                    match self.restricted_worktrees_tasks.entry(worktree_id) {
+                        hash_map::Entry::Occupied(o) => Some(o.get().1.clone()),
+                        hash_map::Entry::Vacant(v) => {
+                            let (tx, rx) = smol::channel::bounded::<()>(1);
+                            let subscription = cx.subscribe(&trusted_worktrees, move |_, e, _| {
+                                if let TrustedWorktreesEvent::Trusted(_, trusted_paths) = e {
+                                    if trusted_paths.contains(&PathTrust::Worktree(worktree_id)) {
+                                        tx.send_blocking(()).ok();
+                                    }
+                                }
+                            });
+                            v.insert((subscription, rx.clone()));
+                            Some(rx)
+                        }
+                    }
+                }
+            });
+        let update_binary_status = untrusted_worktree_task.is_none();
+
         let binary = self.get_language_server_binary(
+            worktree_abs_path.clone(),
             adapter.clone(),
             settings,
             toolchain.clone(),
             delegate.clone(),
             true,
+            untrusted_worktree_task,
             cx,
         );
-        let pending_workspace_folders: Arc<Mutex<BTreeSet<Uri>>> = Default::default();
+        let pending_workspace_folders = Arc::<Mutex<BTreeSet<Uri>>>::default();
 
         let pending_server = cx.spawn({
             let adapter = adapter.clone();
@@ -420,7 +456,7 @@ impl LocalLspStore {
                     server_id,
                     server_name,
                     binary,
-                    &root_path,
+                    &worktree_abs_path,
                     code_action_kinds,
                     Some(pending_workspace_folders),
                     cx,
@@ -542,9 +578,12 @@ impl LocalLspStore {
                                 },
                             },
                         );
-                        log::error!("Failed to start language server {server_name:?}: {err:?}");
+                        log::error!(
+                            "Failed to start language server {server_name:?}: {}",
+                            redact_command(&format!("{err:?}"))
+                        );
                         if !log.is_empty() {
-                            log::error!("server stderr: {log}");
+                            log::error!("server stderr: {}", redact_command(&log));
                         }
                         None
                     }
@@ -556,8 +595,10 @@ impl LocalLspStore {
             pending_workspace_folders,
         };
 
-        self.languages
-            .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
+        if update_binary_status {
+            self.languages
+                .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
+        }
 
         self.language_servers.insert(server_id, state);
         self.language_server_ids
@@ -571,19 +612,34 @@ impl LocalLspStore {
 
     fn get_language_server_binary(
         &self,
+        worktree_abs_path: Arc<Path>,
         adapter: Arc<CachedLspAdapter>,
         settings: Arc<LspSettings>,
         toolchain: Option<Toolchain>,
         delegate: Arc<dyn LspAdapterDelegate>,
         allow_binary_download: bool,
+        untrusted_worktree_task: Option<Receiver<()>>,
         cx: &mut App,
     ) -> Task<Result<LanguageServerBinary>> {
         if let Some(settings) = &settings.binary
             && let Some(path) = settings.path.as_ref().map(PathBuf::from)
         {
             let settings = settings.clone();
-
+            let languages = self.languages.clone();
             return cx.background_spawn(async move {
+                if let Some(untrusted_worktree_task) = untrusted_worktree_task {
+                    log::info!(
+                        "Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {}",
+                        adapter.name(),
+                    );
+                    untrusted_worktree_task.recv().await.ok();
+                    log::info!(
+                        "Worktree {worktree_abs_path:?} is trusted, starting language server {}",
+                        adapter.name(),
+                    );
+                    languages
+                        .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
+                }
                 let mut env = delegate.shell_env().await;
                 env.extend(settings.env.unwrap_or_default());
 
@@ -614,6 +670,18 @@ impl LocalLspStore {
         };
 
         cx.spawn(async move |cx| {
+            if let Some(untrusted_worktree_task) = untrusted_worktree_task {
+                log::info!(
+                    "Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {}",
+                    adapter.name(),
+                );
+                untrusted_worktree_task.recv().await.ok();
+                log::info!(
+                    "Worktree {worktree_abs_path:?} is trusted, starting language server {}",
+                    adapter.name(),
+                );
+            }
+
             let (existing_binary, maybe_download_binary) = adapter
                 .clone()
                 .get_language_server_command(delegate.clone(), toolchain, lsp_binary_options, cx)
@@ -992,12 +1060,15 @@ impl LocalLspStore {
             .on_request::<lsp::request::ShowMessageRequest, _, _>({
                 let this = lsp_store.clone();
                 let name = name.to_string();
+                let adapter = adapter.clone();
                 move |params, cx| {
                     let this = this.clone();
                     let name = name.to_string();
+                    let adapter = adapter.clone();
                     let mut cx = cx.clone();
                     async move {
                         let actions = params.actions.unwrap_or_default();
+                        let message = params.message.clone();
                         let (tx, rx) = smol::channel::bounded(1);
                         let request = LanguageServerPromptRequest {
                             level: match params.typ {
@@ -1018,6 +1089,14 @@ impl LocalLspStore {
                             .is_ok();
                         if did_update {
                             let response = rx.recv().await.ok();
+                            if let Some(ref selected_action) = response {
+                                let context = language::PromptResponseContext {
+                                    message,
+                                    selected_action: selected_action.clone(),
+                                };
+                                adapter.process_prompt_response(&context, &mut cx)
+                            }
+
                             Ok(response)
                         } else {
                             Ok(None)
@@ -2222,12 +2301,10 @@ impl LocalLspStore {
                     && lsp_action.data.is_some()
                     && (lsp_action.command.is_none() || lsp_action.edit.is_none())
                 {
-                    *lsp_action = Box::new(
-                        lang_server
-                            .request::<lsp::request::CodeActionResolveRequest>(*lsp_action.clone())
-                            .await
-                            .into_response()?,
-                    );
+                    **lsp_action = lang_server
+                        .request::<lsp::request::CodeActionResolveRequest>(*lsp_action.clone())
+                        .await
+                        .into_response()?;
                 }
             }
             LspAction::CodeLens(lens) => {
@@ -3238,8 +3315,10 @@ impl LocalLspStore {
         )
         .await
         .log_err();
-        this.update(cx, |this, _| {
+        this.update(cx, |this, cx| {
             if let Some(transaction) = transaction {
+                cx.emit(LspStoreEvent::WorkspaceEditApplied(transaction.clone()));
+
                 this.as_local_mut()
                     .unwrap()
                     .last_workspace_edits_by_language_server
@@ -3258,6 +3337,7 @@ impl LocalLspStore {
         id_to_remove: WorktreeId,
         cx: &mut Context<LspStore>,
     ) -> Vec<LanguageServerId> {
+        self.restricted_worktrees_tasks.remove(&id_to_remove);
         self.diagnostics.remove(&id_to_remove);
         self.prettier_store.update(cx, |prettier_store, cx| {
             prettier_store.remove_worktree(id_to_remove, cx);
@@ -3778,11 +3858,13 @@ pub enum LspStoreEvent {
         edits: Vec<(lsp::Range, Snippet)>,
         most_recent_edit: clock::Lamport,
     },
+    WorkspaceEditApplied(ProjectTransaction),
 }
 
 #[derive(Clone, Debug, Serialize)]
 pub struct LanguageServerStatus {
     pub name: LanguageServerName,
+    pub server_version: Option<SharedString>,
     pub pending_work: BTreeMap<ProgressToken, LanguageServerProgress>,
     pub has_pending_diagnostic_updates: bool,
     pub progress_tokens: HashSet<ProgressToken>,
@@ -3974,6 +4056,7 @@ impl LspStore {
                 buffers_opened_in_servers: HashMap::default(),
                 buffer_pull_diagnostics_result_ids: HashMap::default(),
                 workspace_pull_diagnostics_result_ids: HashMap::default(),
+                restricted_worktrees_tasks: HashMap::default(),
                 watched_manifest_filenames: ManifestProvidersStore::global(cx)
                     .manifest_file_names(),
             }),
@@ -6415,7 +6498,7 @@ impl LspStore {
                 server_id == *completion_server_id,
                 "server_id mismatch, applying completion resolve for {server_id} but completion server id is {completion_server_id}"
             );
-            *lsp_completion = Box::new(resolved_completion);
+            **lsp_completion = resolved_completion;
             *resolved = true;
         }
         Ok(())
@@ -6574,7 +6657,7 @@ impl LspStore {
                 server_id == *completion_server_id,
                 "remote server_id mismatch, applying completion resolve for {server_id} but completion server id is {completion_server_id}"
             );
-            *lsp_completion = Box::new(resolved_lsp_completion);
+            **lsp_completion = resolved_lsp_completion;
             *resolved = true;
         }
 
@@ -6849,9 +6932,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 +6987,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 +7017,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));
@@ -8262,6 +8355,7 @@ impl LspStore {
                     server_id,
                     LanguageServerStatus {
                         name,
+                        server_version: None,
                         pending_work: Default::default(),
                         has_pending_diagnostic_updates: false,
                         progress_tokens: Default::default(),
@@ -9297,6 +9391,7 @@ impl LspStore {
                 server_id,
                 LanguageServerStatus {
                     name: server_name.clone(),
+                    server_version: None,
                     pending_work: Default::default(),
                     has_pending_diagnostic_updates: false,
                     progress_tokens: Default::default(),
@@ -11327,6 +11422,7 @@ impl LspStore {
             server_id,
             LanguageServerStatus {
                 name: language_server.name(),
+                server_version: language_server.version(),
                 pending_work: Default::default(),
                 has_pending_diagnostic_updates: false,
                 progress_tokens: Default::default(),
@@ -12726,10 +12822,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] => {
@@ -13679,7 +13776,7 @@ impl From<lsp::Documentation> for CompletionDocumentation {
         match docs {
             lsp::Documentation::String(text) => {
                 if text.lines().count() <= 1 {
-                    CompletionDocumentation::SingleLine(text.into())
+                    CompletionDocumentation::SingleLine(text.trim().to_string().into())
                 } else {
                     CompletionDocumentation::MultiLinePlainText(text.into())
                 }
@@ -13907,7 +14004,7 @@ impl LspAdapterDelegate for LocalLspAdapterDelegate {
     async fn npm_package_installed_version(
         &self,
         package_name: &str,
-    ) -> Result<Option<(PathBuf, String)>> {
+    ) -> Result<Option<(PathBuf, Version)>> {
         let local_package_directory = self.worktree_root_path();
         let node_modules_directory = local_package_directory.join("node_modules");
 
@@ -14271,4 +14368,22 @@ mod tests {
             )
         );
     }
+
+    #[test]
+    fn test_trailing_newline_in_completion_documentation() {
+        let doc = lsp::Documentation::String(
+            "Inappropriate argument value (of correct type).\n".to_string(),
+        );
+        let completion_doc: CompletionDocumentation = doc.into();
+        assert!(
+            matches!(completion_doc, CompletionDocumentation::SingleLine(s) if s == "Inappropriate argument value (of correct type).")
+        );
+
+        let doc = lsp::Documentation::String("  some value  \n".to_string());
+        let completion_doc: CompletionDocumentation = doc.into();
+        assert!(matches!(
+            completion_doc,
+            CompletionDocumentation::SingleLine(s) if s == "some value"
+        ));
+    }
 }

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/persistence.rs 🔗

@@ -0,0 +1,60 @@
+use collections::{HashMap, HashSet};
+use gpui::{App, Entity, SharedString};
+use std::path::PathBuf;
+
+use db::{
+    query,
+    sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+    sqlez_macros::sql,
+};
+
+use crate::{
+    trusted_worktrees::{PathTrust, RemoteHostLocation, find_worktree_in_store},
+    worktree_store::WorktreeStore,
+};
+
+// https://www.sqlite.org/limits.html
+// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
+// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
+#[allow(unused)]
+const MAX_QUERY_PLACEHOLDERS: usize = 32000;
+
+#[allow(unused)]
+pub struct ProjectDb(ThreadSafeConnection);
+
+impl Domain for ProjectDb {
+    const NAME: &str = stringify!(ProjectDb);
+
+    const MIGRATIONS: &[&str] = &[sql!(
+        CREATE TABLE IF NOT EXISTS trusted_worktrees (
+            trust_id INTEGER PRIMARY KEY AUTOINCREMENT,
+            absolute_path TEXT,
+            user_name TEXT,
+            host_name TEXT
+        ) STRICT;
+    )];
+}
+
+db::static_connection!(PROJECT_DB, ProjectDb, []);
+
+impl ProjectDb {}
+
+#[cfg(test)]
+mod tests {
+    use std::path::PathBuf;
+
+    use collections::{HashMap, HashSet};
+    use gpui::{SharedString, TestAppContext};
+    use serde_json::json;
+    use settings::SettingsStore;
+    use smol::lock::Mutex;
+    use util::path;
+
+    use crate::{
+        FakeFs, Project,
+        persistence::PROJECT_DB,
+        trusted_worktrees::{PathTrust, RemoteHostLocation},
+    };
+
+    static TEST_WORKTREE_TRUST_LOCK: Mutex<()> = Mutex::new(());
+}

crates/project/src/prettier_store.rs 🔗

@@ -905,7 +905,7 @@ async fn install_prettier_packages(
                     .with_context(|| {
                         format!("fetching latest npm version for package {returned_package_name}")
                     })?;
-                anyhow::Ok((returned_package_name, latest_version))
+                anyhow::Ok((returned_package_name, latest_version.to_string()))
             }),
     )
     .await

crates/project/src/project.rs 🔗

@@ -19,6 +19,7 @@ pub mod task_store;
 pub mod telemetry_snapshot;
 pub mod terminals;
 pub mod toolchain_store;
+pub mod trusted_worktrees;
 pub mod worktree_store;
 
 #[cfg(test)]
@@ -39,6 +40,7 @@ use crate::{
     git_store::GitStore,
     lsp_store::{SymbolLocation, log_store::LogKind},
     project_search::SearchResultsHandle,
+    trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
 };
 pub use agent_server_store::{AgentServerStore, AgentServersUpdated, ExternalAgentServerName};
 pub use git_store::{
@@ -63,6 +65,7 @@ use debugger::{
     dap_store::{DapStore, DapStoreEvent},
     session::Session,
 };
+use encoding_rs;
 pub use environment::ProjectEnvironment;
 #[cfg(test)]
 use futures::future::join_all;
@@ -80,7 +83,7 @@ use gpui::{
     Task, WeakEntity, Window,
 };
 use language::{
-    Buffer, BufferEvent, Capability, CodeLabel, CursorShape, Language, LanguageName,
+    Buffer, BufferEvent, Capability, CodeLabel, CursorShape, DiskState, Language, LanguageName,
     LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainMetadata,
     ToolchainScope, Transaction, Unclipped, language_settings::InlayHintKind,
     proto::split_operations,
@@ -348,6 +351,7 @@ pub enum Event {
     SnippetEdit(BufferId, Vec<(lsp::Range, Snippet)>),
     ExpandedAllForEntry(WorktreeId, ProjectEntryId),
     EntryRenamed(ProjectTransaction, ProjectPath, PathBuf),
+    WorkspaceEditApplied(ProjectTransaction),
     AgentLocationChanged,
 }
 
@@ -1069,6 +1073,7 @@ impl Project {
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
         env: Option<HashMap<String, String>>,
+        init_worktree_trust: bool,
         cx: &mut App,
     ) -> Entity<Self> {
         cx.new(|cx: &mut Context<Self>| {
@@ -1077,6 +1082,15 @@ impl Project {
                 .detach();
             let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
             let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
+            if init_worktree_trust {
+                trusted_worktrees::track_worktree_trust(
+                    worktree_store.clone(),
+                    None,
+                    None,
+                    None,
+                    cx,
+                );
+            }
             cx.subscribe(&worktree_store, Self::on_worktree_store_event)
                 .detach();
 
@@ -1250,6 +1264,7 @@ impl Project {
         user_store: Entity<UserStore>,
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
+        init_worktree_trust: bool,
         cx: &mut App,
     ) -> Entity<Self> {
         cx.new(|cx: &mut Context<Self>| {
@@ -1258,8 +1273,14 @@ impl Project {
                 .detach();
             let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
 
-            let (remote_proto, path_style) =
-                remote.read_with(cx, |remote, _| (remote.proto_client(), remote.path_style()));
+            let (remote_proto, path_style, connection_options) =
+                remote.read_with(cx, |remote, _| {
+                    (
+                        remote.proto_client(),
+                        remote.path_style(),
+                        remote.connection_options(),
+                    )
+                });
             let worktree_store = cx.new(|_| {
                 WorktreeStore::remote(
                     false,
@@ -1268,8 +1289,23 @@ impl Project {
                     path_style,
                 )
             });
+
             cx.subscribe(&worktree_store, Self::on_worktree_store_event)
                 .detach();
+            if init_worktree_trust {
+                match &connection_options {
+                    RemoteConnectionOptions::Wsl(..) | RemoteConnectionOptions::Ssh(..) => {
+                        trusted_worktrees::track_worktree_trust(
+                            worktree_store.clone(),
+                            Some(RemoteHostLocation::from(connection_options)),
+                            None,
+                            Some((remote_proto.clone(), REMOTE_SERVER_PROJECT_ID)),
+                            cx,
+                        );
+                    }
+                    RemoteConnectionOptions::Docker(..) => {}
+                }
+            }
 
             let weak_self = cx.weak_entity();
             let context_server_store =
@@ -1294,7 +1330,12 @@ impl Project {
             cx.subscribe(&buffer_store, Self::on_buffer_store_event)
                 .detach();
             let toolchain_store = cx.new(|cx| {
-                ToolchainStore::remote(REMOTE_SERVER_PROJECT_ID, remote.read(cx).proto_client(), cx)
+                ToolchainStore::remote(
+                    REMOTE_SERVER_PROJECT_ID,
+                    worktree_store.clone(),
+                    remote.read(cx).proto_client(),
+                    cx,
+                )
             });
             let task_store = cx.new(|cx| {
                 TaskStore::remote(
@@ -1450,6 +1491,9 @@ impl Project {
             remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request);
             remote_proto.add_entity_message_handler(Self::handle_hide_toast);
             remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server);
+            remote_proto.add_entity_request_handler(Self::handle_trust_worktrees);
+            remote_proto.add_entity_request_handler(Self::handle_restrict_worktrees);
+
             BufferStore::init(&remote_proto);
             LspStore::init(&remote_proto);
             SettingsObserver::init(&remote_proto);
@@ -1810,6 +1854,7 @@ impl Project {
                     Arc::new(languages),
                     fs,
                     None,
+                    false,
                     cx,
                 )
             })
@@ -1834,6 +1879,25 @@ impl Project {
         fs: Arc<dyn Fs>,
         root_paths: impl IntoIterator<Item = &Path>,
         cx: &mut gpui::TestAppContext,
+    ) -> Entity<Project> {
+        Self::test_project(fs, root_paths, false, cx).await
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub async fn test_with_worktree_trust(
+        fs: Arc<dyn Fs>,
+        root_paths: impl IntoIterator<Item = &Path>,
+        cx: &mut gpui::TestAppContext,
+    ) -> Entity<Project> {
+        Self::test_project(fs, root_paths, true, cx).await
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    async fn test_project(
+        fs: Arc<dyn Fs>,
+        root_paths: impl IntoIterator<Item = &Path>,
+        init_worktree_trust: bool,
+        cx: &mut gpui::TestAppContext,
     ) -> Entity<Project> {
         use clock::FakeSystemClock;
 
@@ -1850,6 +1914,7 @@ impl Project {
                 Arc::new(languages),
                 fs,
                 None,
+                init_worktree_trust,
                 cx,
             )
         });
@@ -2425,13 +2490,11 @@ impl Project {
         cx: &mut Context<Self>,
     ) -> Result<()> {
         cx.update_global::<SettingsStore, _>(|store, cx| {
-            self.worktree_store.update(cx, |worktree_store, cx| {
-                for worktree in worktree_store.worktrees() {
-                    store
-                        .clear_local_settings(worktree.read(cx).id(), cx)
-                        .log_err();
-                }
-            });
+            for worktree_metadata in &message.worktrees {
+                store
+                    .clear_local_settings(WorktreeId::from_proto(worktree_metadata.id), cx)
+                    .log_err();
+            }
         });
 
         self.join_project_response_message_id = message_id;
@@ -3193,6 +3256,9 @@ impl Project {
                     cx.emit(Event::SnippetEdit(*buffer_id, edits.clone()))
                 }
             }
+            LspStoreEvent::WorkspaceEditApplied(transaction) => {
+                cx.emit(Event::WorkspaceEditApplied(transaction.clone()))
+            }
         }
     }
 
@@ -4671,6 +4737,14 @@ impl Project {
         this.update(&mut cx, |this, cx| {
             // Don't handle messages that were sent before the response to us joining the project
             if envelope.message_id > this.join_project_response_message_id {
+                cx.update_global::<SettingsStore, _>(|store, cx| {
+                    for worktree_metadata in &envelope.payload.worktrees {
+                        store
+                            .clear_local_settings(WorktreeId::from_proto(worktree_metadata.id), cx)
+                            .log_err();
+                    }
+                });
+
                 this.set_worktrees_from_proto(envelope.payload.worktrees, cx)?;
             }
             Ok(())
@@ -4757,9 +4831,14 @@ impl Project {
         envelope: TypedEnvelope<proto::UpdateWorktree>,
         mut cx: AsyncApp,
     ) -> Result<()> {
-        this.update(&mut cx, |this, cx| {
+        this.update(&mut cx, |project, cx| {
             let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-            if let Some(worktree) = this.worktree_for_id(worktree_id, cx) {
+            if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+                trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                    trusted_worktrees.can_trust(worktree_id, cx)
+                });
+            }
+            if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
                 worktree.update(cx, |worktree, _| {
                     let worktree = worktree.as_remote_mut().unwrap();
                     worktree.update_from_remote(envelope.payload);
@@ -4786,6 +4865,58 @@ impl Project {
         BufferStore::handle_update_buffer(buffer_store, envelope, cx).await
     }
 
+    async fn handle_trust_worktrees(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::TrustWorktrees>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let trusted_worktrees = cx
+            .update(|cx| TrustedWorktrees::try_get_global(cx))?
+            .context("missing trusted worktrees")?;
+        trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
+            let remote_host = this
+                .read(cx)
+                .remote_connection_options(cx)
+                .map(RemoteHostLocation::from);
+            trusted_worktrees.trust(
+                envelope
+                    .payload
+                    .trusted_paths
+                    .into_iter()
+                    .filter_map(|proto_path| PathTrust::from_proto(proto_path))
+                    .collect(),
+                remote_host,
+                cx,
+            );
+        })?;
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_restrict_worktrees(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::RestrictWorktrees>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let trusted_worktrees = cx
+            .update(|cx| TrustedWorktrees::try_get_global(cx))?
+            .context("missing trusted worktrees")?;
+        trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
+            let restricted_paths = envelope
+                .payload
+                .worktree_ids
+                .into_iter()
+                .map(WorktreeId::from_proto)
+                .map(PathTrust::Worktree)
+                .collect::<HashSet<_>>();
+            let remote_host = this
+                .read(cx)
+                .remote_connection_options(cx)
+                .map(RemoteHostLocation::from);
+            trusted_worktrees.restrict(restricted_paths, remote_host, cx);
+        })?;
+        Ok(proto::Ack {})
+    }
+
     async fn handle_update_buffer(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::UpdateBuffer>,
@@ -5336,13 +5467,22 @@ impl Project {
                 .await
                 .context("Failed to load settings file")?;
 
+            let has_bom = file.has_bom;
+
             let new_text = cx.read_global::<SettingsStore, _>(|store, cx| {
                 store.new_text_for_update(file.text, move |settings| update(settings, cx))
             })?;
             worktree
                 .update(cx, |worktree, cx| {
                     let line_ending = text::LineEnding::detect(&new_text);
-                    worktree.write_file(rel_path.clone(), new_text.into(), line_ending, cx)
+                    worktree.write_file(
+                        rel_path.clone(),
+                        new_text.into(),
+                        line_ending,
+                        encoding_rs::UTF_8,
+                        has_bom,
+                        cx,
+                    )
                 })?
                 .await
                 .context("Failed to write settings file")?;
@@ -5531,7 +5671,9 @@ impl ProjectItem for Buffer {
     }
 
     fn project_path(&self, cx: &App) -> Option<ProjectPath> {
-        self.file().map(|file| ProjectPath {
+        let file = self.file()?;
+
+        (!matches!(file.disk_state(), DiskState::Historic { .. })).then(|| ProjectPath {
             worktree_id: file.worktree_id(cx),
             path: file.path().clone(),
         })

crates/project/src/project_settings.rs 🔗

@@ -23,13 +23,14 @@ use settings::{
     DapSettingsContent, InvalidSettingsError, LocalSettingsKind, RegisterSetting, Settings,
     SettingsLocation, SettingsStore, parse_json_with_comments, watch_config_file,
 };
-use std::{path::PathBuf, sync::Arc, time::Duration};
+use std::{cell::OnceCell, collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
 use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile};
 use util::{ResultExt, rel_path::RelPath, serde::default_true};
 use worktree::{PathChange, UpdatedEntriesSet, Worktree, WorktreeId};
 
 use crate::{
     task_store::{TaskSettingsLocation, TaskStore},
+    trusted_worktrees::{PathTrust, TrustedWorktrees, TrustedWorktreesEvent},
     worktree_store::{WorktreeStore, WorktreeStoreEvent},
 };
 
@@ -83,6 +84,12 @@ pub struct SessionSettings {
     ///
     /// Default: true
     pub restore_unsaved_buffers: bool,
+    /// Whether or not to skip worktree trust checks.
+    /// When trusted, project settings are synchronized automatically,
+    /// language and MCP servers are downloaded and started automatically.
+    ///
+    /// Default: false
+    pub trust_all_worktrees: bool,
 }
 
 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -325,6 +332,10 @@ impl GoToDiagnosticSeverityFilter {
 
 #[derive(Copy, Clone, Debug)]
 pub struct GitSettings {
+    /// Whether or not git integration is enabled.
+    ///
+    /// Default: true
+    pub enabled: GitEnabledSettings,
     /// Whether or not to show the git gutter.
     ///
     /// Default: tracked_files
@@ -354,6 +365,18 @@ pub struct GitSettings {
     pub path_style: GitPathStyle,
 }
 
+#[derive(Clone, Copy, Debug)]
+pub struct GitEnabledSettings {
+    /// Whether git integration is enabled for showing git status.
+    ///
+    /// Default: true
+    pub status: bool,
+    /// Whether git integration is enabled for showing diffs.
+    ///
+    /// Default: true
+    pub diff: bool,
+}
+
 #[derive(Clone, Copy, Debug, PartialEq, Default)]
 pub enum GitPathStyle {
     #[default]
@@ -495,7 +518,14 @@ impl Settings for ProjectSettings {
         let inline_diagnostics = diagnostics.inline.as_ref().unwrap();
 
         let git = content.git.as_ref().unwrap();
+        let git_enabled = {
+            GitEnabledSettings {
+                status: git.enabled.as_ref().unwrap().is_git_status_enabled(),
+                diff: git.enabled.as_ref().unwrap().is_git_diff_enabled(),
+            }
+        };
         let git_settings = GitSettings {
+            enabled: git_enabled,
             git_gutter: git.git_gutter.unwrap(),
             gutter_debounce: git.gutter_debounce.unwrap_or_default(),
             inline_blame: {
@@ -570,6 +600,7 @@ impl Settings for ProjectSettings {
             load_direnv: project.load_direnv.clone().unwrap(),
             session: SessionSettings {
                 restore_unsaved_buffers: content.session.unwrap().restore_unsaved_buffers.unwrap(),
+                trust_all_worktrees: content.session.unwrap().trust_all_worktrees.unwrap(),
             },
         }
     }
@@ -595,6 +626,9 @@ pub struct SettingsObserver {
     worktree_store: Entity<WorktreeStore>,
     project_id: u64,
     task_store: Entity<TaskStore>,
+    pending_local_settings:
+        HashMap<PathTrust, BTreeMap<(WorktreeId, Arc<RelPath>), Option<String>>>,
+    _trusted_worktrees_watcher: Option<Subscription>,
     _user_settings_watcher: Option<Subscription>,
     _global_task_config_watcher: Task<()>,
     _global_debug_config_watcher: Task<()>,
@@ -620,11 +654,61 @@ impl SettingsObserver {
         cx.subscribe(&worktree_store, Self::on_worktree_store_event)
             .detach();
 
+        let _trusted_worktrees_watcher =
+            TrustedWorktrees::try_get_global(cx).map(|trusted_worktrees| {
+                cx.subscribe(
+                    &trusted_worktrees,
+                    move |settings_observer, _, e, cx| match e {
+                        TrustedWorktreesEvent::Trusted(_, trusted_paths) => {
+                            for trusted_path in trusted_paths {
+                                if let Some(pending_local_settings) = settings_observer
+                                    .pending_local_settings
+                                    .remove(trusted_path)
+                                {
+                                    for ((worktree_id, directory_path), settings_contents) in
+                                        pending_local_settings
+                                    {
+                                        apply_local_settings(
+                                            worktree_id,
+                                            &directory_path,
+                                            LocalSettingsKind::Settings,
+                                            &settings_contents,
+                                            cx,
+                                        );
+                                        if let Some(downstream_client) =
+                                            &settings_observer.downstream_client
+                                        {
+                                            downstream_client
+                                                .send(proto::UpdateWorktreeSettings {
+                                                    project_id: settings_observer.project_id,
+                                                    worktree_id: worktree_id.to_proto(),
+                                                    path: directory_path.to_proto(),
+                                                    content: settings_contents,
+                                                    kind: Some(
+                                                        local_settings_kind_to_proto(
+                                                            LocalSettingsKind::Settings,
+                                                        )
+                                                        .into(),
+                                                    ),
+                                                })
+                                                .log_err();
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                        TrustedWorktreesEvent::Restricted(..) => {}
+                    },
+                )
+            });
+
         Self {
             worktree_store,
             task_store,
             mode: SettingsObserverMode::Local(fs.clone()),
             downstream_client: None,
+            _trusted_worktrees_watcher,
+            pending_local_settings: HashMap::default(),
             _user_settings_watcher: None,
             project_id: REMOTE_SERVER_PROJECT_ID,
             _global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
@@ -677,6 +761,8 @@ impl SettingsObserver {
             mode: SettingsObserverMode::Remote,
             downstream_client: None,
             project_id: REMOTE_SERVER_PROJECT_ID,
+            _trusted_worktrees_watcher: None,
+            pending_local_settings: HashMap::default(),
             _user_settings_watcher: user_settings_watcher,
             _global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
                 fs.clone(),
@@ -792,13 +878,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();
+                });
+            }
+            _ => {}
         }
     }
 
@@ -968,36 +1061,32 @@ impl SettingsObserver {
         let worktree_id = worktree.read(cx).id();
         let remote_worktree_id = worktree.read(cx).id();
         let task_store = self.task_store.clone();
-
+        let can_trust_worktree = OnceCell::new();
         for (directory, kind, file_content) in settings_contents {
+            let mut applied = true;
             match kind {
-                LocalSettingsKind::Settings | LocalSettingsKind::Editorconfig => cx
-                    .update_global::<SettingsStore, _>(|store, cx| {
-                        let result = store.set_local_settings(
-                            worktree_id,
-                            directory.clone(),
-                            kind,
-                            file_content.as_deref(),
-                            cx,
-                        );
-
-                        match result {
-                            Err(InvalidSettingsError::LocalSettings { path, message }) => {
-                                log::error!("Failed to set local settings in {path:?}: {message}");
-                                cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
-                                    InvalidSettingsError::LocalSettings { path, message },
-                                )));
-                            }
-                            Err(e) => {
-                                log::error!("Failed to set local settings: {e}");
-                            }
-                            Ok(()) => {
-                                cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
-                                    .as_std_path()
-                                    .join(local_settings_file_relative_path().as_std_path()))));
-                            }
+                LocalSettingsKind::Settings => {
+                    if *can_trust_worktree.get_or_init(|| {
+                        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+                            trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                                trusted_worktrees.can_trust(worktree_id, cx)
+                            })
+                        } else {
+                            true
                         }
-                    }),
+                    }) {
+                        apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
+                    } else {
+                        applied = false;
+                        self.pending_local_settings
+                            .entry(PathTrust::Worktree(worktree_id))
+                            .or_default()
+                            .insert((worktree_id, directory.clone()), file_content.clone());
+                    }
+                }
+                LocalSettingsKind::Editorconfig => {
+                    apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
+                }
                 LocalSettingsKind::Tasks => {
                     let result = task_store.update(cx, |task_store, cx| {
                         task_store.update_user_tasks(
@@ -1060,16 +1149,18 @@ impl SettingsObserver {
                 }
             };
 
-            if let Some(downstream_client) = &self.downstream_client {
-                downstream_client
-                    .send(proto::UpdateWorktreeSettings {
-                        project_id: self.project_id,
-                        worktree_id: remote_worktree_id.to_proto(),
-                        path: directory.to_proto(),
-                        content: file_content.clone(),
-                        kind: Some(local_settings_kind_to_proto(kind).into()),
-                    })
-                    .log_err();
+            if applied {
+                if let Some(downstream_client) = &self.downstream_client {
+                    downstream_client
+                        .send(proto::UpdateWorktreeSettings {
+                            project_id: self.project_id,
+                            worktree_id: remote_worktree_id.to_proto(),
+                            path: directory.to_proto(),
+                            content: file_content.clone(),
+                            kind: Some(local_settings_kind_to_proto(kind).into()),
+                        })
+                        .log_err();
+                }
             }
         }
     }
@@ -1186,6 +1277,37 @@ impl SettingsObserver {
     }
 }
 
+fn apply_local_settings(
+    worktree_id: WorktreeId,
+    directory: &Arc<RelPath>,
+    kind: LocalSettingsKind,
+    file_content: &Option<String>,
+    cx: &mut Context<'_, SettingsObserver>,
+) {
+    cx.update_global::<SettingsStore, _>(|store, cx| {
+        let result = store.set_local_settings(
+            worktree_id,
+            directory.clone(),
+            kind,
+            file_content.as_deref(),
+            cx,
+        );
+
+        match result {
+            Err(InvalidSettingsError::LocalSettings { path, message }) => {
+                log::error!("Failed to set local settings in {path:?}: {message}");
+                cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
+                    InvalidSettingsError::LocalSettings { path, message },
+                )));
+            }
+            Err(e) => log::error!("Failed to set local settings: {e}"),
+            Ok(()) => cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
+                .as_std_path()
+                .join(local_settings_file_relative_path().as_std_path())))),
+        }
+    })
+}
+
 pub fn local_settings_kind_from_proto(kind: proto::LocalSettingsKind) -> LocalSettingsKind {
     match kind {
         proto::LocalSettingsKind::Settings => LocalSettingsKind::Settings,

crates/project/src/project_tests.rs 🔗

@@ -10922,3 +10922,146 @@ async fn test_git_worktree_remove(cx: &mut gpui::TestAppContext) {
     });
     assert!(active_repo_path.is_none());
 }
+
+#[gpui::test]
+async fn test_optimistic_hunks_in_staged_files(cx: &mut gpui::TestAppContext) {
+    use DiffHunkSecondaryStatus::*;
+    init_test(cx);
+
+    let committed_contents = r#"
+        one
+        two
+        three
+    "#
+    .unindent();
+    let file_contents = r#"
+        one
+        TWO
+        three
+    "#
+    .unindent();
+
+    let fs = FakeFs::new(cx.background_executor.clone());
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            ".git": {},
+            "file.txt": file_contents.clone()
+        }),
+    )
+    .await;
+
+    fs.set_head_and_index_for_repo(
+        path!("/dir/.git").as_ref(),
+        &[("file.txt", committed_contents.clone())],
+    );
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer(path!("/dir/file.txt"), cx)
+        })
+        .await
+        .unwrap();
+    let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+    let uncommitted_diff = project
+        .update(cx, |project, cx| {
+            project.open_uncommitted_diff(buffer.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    // The hunk is initially unstaged.
+    uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &[(
+                1..2,
+                "two\n",
+                "TWO\n",
+                DiffHunkStatus::modified(HasSecondaryHunk),
+            )],
+        );
+    });
+
+    // Get the repository handle.
+    let repo = project.read_with(cx, |project, cx| {
+        project.repositories(cx).values().next().unwrap().clone()
+    });
+
+    // Stage the file.
+    let stage_task = repo.update(cx, |repo, cx| {
+        repo.stage_entries(vec![repo_path("file.txt")], cx)
+    });
+
+    // Run a few ticks to let the job start and mark hunks as pending,
+    // but don't run_until_parked which would complete the entire operation.
+    for _ in 0..10 {
+        cx.executor().tick();
+        let [hunk]: [_; 1] = uncommitted_diff
+            .read_with(cx, |diff, cx| diff.hunks(&snapshot, cx).collect::<Vec<_>>())
+            .try_into()
+            .unwrap();
+        match hunk.secondary_status {
+            HasSecondaryHunk => {}
+            SecondaryHunkRemovalPending => break,
+            NoSecondaryHunk => panic!("hunk was not optimistically staged"),
+            _ => panic!("unexpected hunk state"),
+        }
+    }
+    uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &[(
+                1..2,
+                "two\n",
+                "TWO\n",
+                DiffHunkStatus::modified(SecondaryHunkRemovalPending),
+            )],
+        );
+    });
+
+    // Let the staging complete.
+    stage_task.await.unwrap();
+    cx.run_until_parked();
+
+    // The hunk is now fully staged.
+    uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &[(
+                1..2,
+                "two\n",
+                "TWO\n",
+                DiffHunkStatus::modified(NoSecondaryHunk),
+            )],
+        );
+    });
+
+    // Simulate a commit by updating HEAD to match the current file contents.
+    // The FakeGitRepository's commit method is a no-op, so we need to manually
+    // update HEAD to simulate the commit completing.
+    fs.set_head_for_repo(
+        path!("/dir/.git").as_ref(),
+        &[("file.txt", file_contents.clone())],
+        "newhead",
+    );
+    cx.run_until_parked();
+
+    // After committing, there are no more hunks.
+    uncommitted_diff.read_with(cx, |diff, cx| {
+        assert_hunks(
+            diff.hunks(&snapshot, cx),
+            &snapshot,
+            &diff.base_text_string().unwrap(),
+            &[] as &[(Range<u32>, &str, &str, DiffHunkStatus)],
+        );
+    });
+}

crates/project/src/toolchain_store.rs 🔗

@@ -32,6 +32,7 @@ use crate::{
 pub struct ToolchainStore {
     mode: ToolchainStoreInner,
     user_toolchains: BTreeMap<ToolchainScope, IndexSet<Toolchain>>,
+    worktree_store: Entity<WorktreeStore>,
     _sub: Subscription,
 }
 
@@ -66,7 +67,7 @@ impl ToolchainStore {
     ) -> Self {
         let entity = cx.new(|_| LocalToolchainStore {
             languages,
-            worktree_store,
+            worktree_store: worktree_store.clone(),
             project_environment,
             active_toolchains: Default::default(),
             manifest_tree,
@@ -77,12 +78,18 @@ impl ToolchainStore {
         });
         Self {
             mode: ToolchainStoreInner::Local(entity),
+            worktree_store,
             user_toolchains: Default::default(),
             _sub,
         }
     }
 
-    pub(super) fn remote(project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) -> Self {
+    pub(super) fn remote(
+        project_id: u64,
+        worktree_store: Entity<WorktreeStore>,
+        client: AnyProtoClient,
+        cx: &mut Context<Self>,
+    ) -> Self {
         let entity = cx.new(|_| RemoteToolchainStore { client, project_id });
         let _sub = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| {
             cx.emit(e.clone())
@@ -90,6 +97,7 @@ impl ToolchainStore {
         Self {
             mode: ToolchainStoreInner::Remote(entity),
             user_toolchains: Default::default(),
+            worktree_store,
             _sub,
         }
     }
@@ -165,12 +173,22 @@ impl ToolchainStore {
         language_name: LanguageName,
         cx: &mut Context<Self>,
     ) -> Task<Option<Toolchains>> {
+        let Some(worktree) = self
+            .worktree_store
+            .read(cx)
+            .worktree_for_id(path.worktree_id, cx)
+        else {
+            return Task::ready(None);
+        };
+        let target_root_path = worktree.read_with(cx, |this, _| this.abs_path());
+
         let user_toolchains = self
             .user_toolchains
             .iter()
             .filter(|(scope, _)| {
-                if let ToolchainScope::Subproject(worktree_id, relative_path) = scope {
-                    path.worktree_id == *worktree_id && relative_path.starts_with(&path.path)
+                if let ToolchainScope::Subproject(subproject_root_path, relative_path) = scope {
+                    target_root_path == *subproject_root_path
+                        && relative_path.starts_with(&path.path)
                 } else {
                     true
                 }

crates/project/src/trusted_worktrees.rs 🔗

@@ -0,0 +1,1378 @@
+//! A module, responsible for managing the trust logic in Zed.
+//!
+//! It deals with multiple hosts, distinguished by [`RemoteHostLocation`].
+//! Each [`crate::Project`] and `HeadlessProject` should call [`init_global`], if wants to establish the trust mechanism.
+//! This will set up a [`gpui::Global`] with [`TrustedWorktrees`] entity that will persist, restore and allow querying for worktree trust.
+//! It's also possible to subscribe on [`TrustedWorktreesEvent`] events of this entity to track trust changes dynamically.
+//!
+//! The implementation can synchronize trust information with the remote hosts: currently, WSL and SSH.
+//! Docker and Collab remotes do not employ trust mechanism, as manage that themselves.
+//!
+//! Unless `trust_all_worktrees` auto trust is enabled, does not trust anything that was not persisted before.
+//! When dealing with "restricted" and other related concepts in the API, it means all explicitly restricted, after any of the [`TrustedWorktreesStore::can_trust`] and [`TrustedWorktreesStore::can_trust_global`] calls.
+//!
+//!
+//!
+//!
+//! Path rust hierarchy.
+//!
+//! Zed has multiple layers of trust, based on the requests and [`PathTrust`] enum variants.
+//! From the least to the most trusted level:
+//!
+//! * "single file worktree"
+//!
+//! After opening an empty Zed it's possible to open just a file, same as after opening a directory in Zed it's possible to open a file outside of this directory.
+//! Usual scenario for both cases is opening Zed's settings.json file via `zed: open settings file` command: that starts a language server for a new file open, which originates from a newly created, single file worktree.
+//!
+//! Spawning a language server is potentially dangerous, and Zed needs to restrict that by default.
+//! Each single file worktree requires a separate trust permission, unless a more global level is trusted.
+//!
+//! * "directory worktree"
+//!
+//! If a directory is open in Zed, it's a full worktree which may spawn multiple language servers associated with it.
+//! Each such worktree requires a separate trust permission, so each separate directory worktree has to be trusted separately, unless a more global level is trusted.
+//!
+//! When a directory worktree is trusted and language servers are allowed to be downloaded and started, hence, "single file worktree" level of trust also.
+//!
+//! * "path override"
+//!
+//! To ease trusting multiple directory worktrees at once, it's possible to trust a parent directory of a certain directory worktree opened in Zed.
+//! Trusting a directory means trusting all its subdirectories as well, including all current and potential directory worktrees.
+
+use collections::{HashMap, HashSet};
+use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, SharedString, WeakEntity};
+use remote::RemoteConnectionOptions;
+use rpc::{AnyProtoClient, proto};
+use settings::{Settings as _, WorktreeId};
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+use util::debug_panic;
+
+use crate::{project_settings::ProjectSettings, worktree_store::WorktreeStore};
+
+pub fn init(
+    db_trusted_paths: TrustedPaths,
+    downstream_client: Option<(AnyProtoClient, u64)>,
+    upstream_client: Option<(AnyProtoClient, u64)>,
+    cx: &mut App,
+) {
+    if TrustedWorktrees::try_get_global(cx).is_none() {
+        let trusted_worktrees = cx.new(|_| {
+            TrustedWorktreesStore::new(
+                db_trusted_paths,
+                None,
+                None,
+                downstream_client,
+                upstream_client,
+            )
+        });
+        cx.set_global(TrustedWorktrees(trusted_worktrees))
+    }
+}
+
+/// An initialization call to set up trust global for a particular project (remote or local).
+pub fn track_worktree_trust(
+    worktree_store: Entity<WorktreeStore>,
+    remote_host: Option<RemoteHostLocation>,
+    downstream_client: Option<(AnyProtoClient, u64)>,
+    upstream_client: Option<(AnyProtoClient, u64)>,
+    cx: &mut App,
+) {
+    match TrustedWorktrees::try_get_global(cx) {
+        Some(trusted_worktrees) => {
+            trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                let sync_upstream = trusted_worktrees.upstream_client.as_ref().map(|(_, id)| id)
+                    != upstream_client.as_ref().map(|(_, id)| id);
+                trusted_worktrees.downstream_client = downstream_client;
+                trusted_worktrees.upstream_client = upstream_client;
+                trusted_worktrees.add_worktree_store(worktree_store, remote_host, cx);
+
+                if sync_upstream {
+                    if let Some((upstream_client, upstream_project_id)) =
+                        &trusted_worktrees.upstream_client
+                    {
+                        let trusted_paths = trusted_worktrees
+                            .trusted_paths
+                            .iter()
+                            .flat_map(|(_, paths)| {
+                                paths.iter().map(|trusted_path| trusted_path.to_proto())
+                            })
+                            .collect::<Vec<_>>();
+                        if !trusted_paths.is_empty() {
+                            upstream_client
+                                .send(proto::TrustWorktrees {
+                                    project_id: *upstream_project_id,
+                                    trusted_paths,
+                                })
+                                .ok();
+                        }
+                    }
+                }
+            });
+        }
+        None => log::debug!("No TrustedWorktrees initialized, not tracking worktree trust"),
+    }
+}
+
+/// A collection of worktree trust metadata, can be accessed globally (if initialized) and subscribed to.
+pub struct TrustedWorktrees(Entity<TrustedWorktreesStore>);
+
+impl Global for TrustedWorktrees {}
+
+impl TrustedWorktrees {
+    pub fn try_get_global(cx: &App) -> Option<Entity<TrustedWorktreesStore>> {
+        cx.try_global::<Self>().map(|this| this.0.clone())
+    }
+}
+
+/// A collection of worktrees that are considered trusted and not trusted.
+/// This can be used when checking for this criteria before enabling certain features.
+///
+/// Emits an event each time the worktree was checked and found not trusted,
+/// or a certain worktree had been trusted.
+pub struct TrustedWorktreesStore {
+    downstream_client: Option<(AnyProtoClient, u64)>,
+    upstream_client: Option<(AnyProtoClient, u64)>,
+    worktree_stores: HashMap<WeakEntity<WorktreeStore>, Option<RemoteHostLocation>>,
+    trusted_paths: TrustedPaths,
+    restricted: HashSet<WorktreeId>,
+}
+
+/// An identifier of a host to split the trust questions by.
+/// Each trusted data change and event is done for a particular host.
+/// A host may contain more than one worktree or even project open concurrently.
+#[derive(Debug, PartialEq, Eq, Clone, Hash)]
+pub struct RemoteHostLocation {
+    pub user_name: Option<SharedString>,
+    pub host_identifier: SharedString,
+}
+
+impl From<RemoteConnectionOptions> for RemoteHostLocation {
+    fn from(options: RemoteConnectionOptions) -> Self {
+        let (user_name, host_name) = match options {
+            RemoteConnectionOptions::Ssh(ssh) => (
+                ssh.username.map(SharedString::new),
+                SharedString::new(ssh.host.to_string()),
+            ),
+            RemoteConnectionOptions::Wsl(wsl) => (
+                wsl.user.map(SharedString::new),
+                SharedString::new(wsl.distro_name),
+            ),
+            RemoteConnectionOptions::Docker(docker_connection_options) => (
+                Some(SharedString::new(docker_connection_options.name)),
+                SharedString::new(docker_connection_options.container_id),
+            ),
+        };
+        RemoteHostLocation {
+            user_name,
+            host_identifier: host_name,
+        }
+    }
+}
+
+/// A unit of trust consideration inside a particular host:
+/// either a familiar worktree, or a path that may influence other worktrees' trust.
+/// See module-level documentation on the trust model.
+#[derive(Debug, PartialEq, Eq, Clone, Hash)]
+pub enum PathTrust {
+    /// A worktree that is familiar to this workspace.
+    /// Either a single file or a directory worktree.
+    Worktree(WorktreeId),
+    /// A path that may be another worktree yet not loaded into any workspace (hence, without any `WorktreeId`),
+    /// or a parent path coming out of the security modal.
+    AbsPath(PathBuf),
+}
+
+impl PathTrust {
+    fn to_proto(&self) -> proto::PathTrust {
+        match self {
+            Self::Worktree(worktree_id) => proto::PathTrust {
+                content: Some(proto::path_trust::Content::WorktreeId(
+                    worktree_id.to_proto(),
+                )),
+            },
+            Self::AbsPath(path_buf) => proto::PathTrust {
+                content: Some(proto::path_trust::Content::AbsPath(
+                    path_buf.to_string_lossy().to_string(),
+                )),
+            },
+        }
+    }
+
+    pub fn from_proto(proto: proto::PathTrust) -> Option<Self> {
+        Some(match proto.content? {
+            proto::path_trust::Content::WorktreeId(id) => {
+                Self::Worktree(WorktreeId::from_proto(id))
+            }
+            proto::path_trust::Content::AbsPath(path) => Self::AbsPath(PathBuf::from(path)),
+        })
+    }
+}
+
+/// A change of trust on a certain host.
+#[derive(Debug)]
+pub enum TrustedWorktreesEvent {
+    Trusted(Option<RemoteHostLocation>, HashSet<PathTrust>),
+    Restricted(Option<RemoteHostLocation>, HashSet<PathTrust>),
+}
+
+impl EventEmitter<TrustedWorktreesEvent> for TrustedWorktreesStore {}
+
+pub type TrustedPaths = HashMap<Option<RemoteHostLocation>, HashSet<PathTrust>>;
+
+impl TrustedWorktreesStore {
+    fn new(
+        trusted_paths: TrustedPaths,
+        worktree_store: Option<Entity<WorktreeStore>>,
+        remote_host: Option<RemoteHostLocation>,
+        downstream_client: Option<(AnyProtoClient, u64)>,
+        upstream_client: Option<(AnyProtoClient, u64)>,
+    ) -> Self {
+        if let Some((upstream_client, upstream_project_id)) = &upstream_client {
+            let trusted_paths = trusted_paths
+                .iter()
+                .flat_map(|(_, paths)| paths.iter().map(|trusted_path| trusted_path.to_proto()))
+                .collect::<Vec<_>>();
+            if !trusted_paths.is_empty() {
+                upstream_client
+                    .send(proto::TrustWorktrees {
+                        project_id: *upstream_project_id,
+                        trusted_paths,
+                    })
+                    .ok();
+            }
+        }
+
+        let worktree_stores = match worktree_store {
+            Some(worktree_store) => HashMap::from_iter([(worktree_store.downgrade(), remote_host)]),
+            None => HashMap::default(),
+        };
+
+        Self {
+            trusted_paths,
+            downstream_client,
+            upstream_client,
+            restricted: HashSet::default(),
+            worktree_stores,
+        }
+    }
+
+    /// Whether a particular worktree store has associated worktrees that are restricted, or an associated host is restricted.
+    pub fn has_restricted_worktrees(
+        &self,
+        worktree_store: &Entity<WorktreeStore>,
+        cx: &App,
+    ) -> bool {
+        self.worktree_stores
+            .contains_key(&worktree_store.downgrade())
+            && self.restricted.iter().any(|restricted_worktree| {
+                worktree_store
+                    .read(cx)
+                    .worktree_for_id(*restricted_worktree, cx)
+                    .is_some()
+            })
+    }
+
+    /// Adds certain entities on this host to the trusted list.
+    /// This will emit [`TrustedWorktreesEvent::Trusted`] event for all passed entries
+    /// and the ones that got auto trusted based on trust hierarchy (see module-level docs).
+    pub fn trust(
+        &mut self,
+        mut trusted_paths: HashSet<PathTrust>,
+        remote_host: Option<RemoteHostLocation>,
+        cx: &mut Context<Self>,
+    ) {
+        let mut new_trusted_single_file_worktrees = HashSet::default();
+        let mut new_trusted_other_worktrees = HashSet::default();
+        let mut new_trusted_abs_paths = HashSet::default();
+        for trusted_path in trusted_paths.iter().chain(
+            self.trusted_paths
+                .remove(&remote_host)
+                .iter()
+                .flat_map(|current_trusted| current_trusted.iter()),
+        ) {
+            match trusted_path {
+                PathTrust::Worktree(worktree_id) => {
+                    self.restricted.remove(worktree_id);
+                    if let Some((abs_path, is_file, host)) =
+                        self.find_worktree_data(*worktree_id, cx)
+                    {
+                        if host == remote_host {
+                            if is_file {
+                                new_trusted_single_file_worktrees.insert(*worktree_id);
+                            } else {
+                                new_trusted_other_worktrees.insert((abs_path, *worktree_id));
+                            }
+                        }
+                    }
+                }
+                PathTrust::AbsPath(path) => {
+                    debug_assert!(
+                        path.is_absolute(),
+                        "Cannot trust non-absolute path {path:?}"
+                    );
+                    new_trusted_abs_paths.insert(path.clone());
+                }
+            }
+        }
+
+        new_trusted_other_worktrees.retain(|(worktree_abs_path, _)| {
+            new_trusted_abs_paths
+                .iter()
+                .all(|new_trusted_path| !worktree_abs_path.starts_with(new_trusted_path))
+        });
+        if !new_trusted_other_worktrees.is_empty() {
+            new_trusted_single_file_worktrees.clear();
+        }
+        self.restricted = std::mem::take(&mut self.restricted)
+            .into_iter()
+            .filter(|restricted_worktree| {
+                let Some((restricted_worktree_path, is_file, restricted_host)) =
+                    self.find_worktree_data(*restricted_worktree, cx)
+                else {
+                    return false;
+                };
+                if restricted_host != remote_host {
+                    return true;
+                }
+                let retain = (!is_file || new_trusted_other_worktrees.is_empty())
+                    && new_trusted_abs_paths.iter().all(|new_trusted_path| {
+                        !restricted_worktree_path.starts_with(new_trusted_path)
+                    });
+                if !retain {
+                    trusted_paths.insert(PathTrust::Worktree(*restricted_worktree));
+                }
+                retain
+            })
+            .collect();
+
+        {
+            let trusted_paths = self.trusted_paths.entry(remote_host.clone()).or_default();
+            trusted_paths.extend(new_trusted_abs_paths.into_iter().map(PathTrust::AbsPath));
+            trusted_paths.extend(
+                new_trusted_other_worktrees
+                    .into_iter()
+                    .map(|(_, worktree_id)| PathTrust::Worktree(worktree_id)),
+            );
+            trusted_paths.extend(
+                new_trusted_single_file_worktrees
+                    .into_iter()
+                    .map(PathTrust::Worktree),
+            );
+        }
+
+        cx.emit(TrustedWorktreesEvent::Trusted(
+            remote_host,
+            trusted_paths.clone(),
+        ));
+
+        if let Some((upstream_client, upstream_project_id)) = &self.upstream_client {
+            let trusted_paths = trusted_paths
+                .iter()
+                .map(|trusted_path| trusted_path.to_proto())
+                .collect::<Vec<_>>();
+            if !trusted_paths.is_empty() {
+                upstream_client
+                    .send(proto::TrustWorktrees {
+                        project_id: *upstream_project_id,
+                        trusted_paths,
+                    })
+                    .ok();
+            }
+        }
+    }
+
+    /// Restricts certain entities on this host.
+    /// This will emit [`TrustedWorktreesEvent::Restricted`] event for all passed entries.
+    pub fn restrict(
+        &mut self,
+        restricted_paths: HashSet<PathTrust>,
+        remote_host: Option<RemoteHostLocation>,
+        cx: &mut Context<Self>,
+    ) {
+        for restricted_path in restricted_paths {
+            match restricted_path {
+                PathTrust::Worktree(worktree_id) => {
+                    self.restricted.insert(worktree_id);
+                    cx.emit(TrustedWorktreesEvent::Restricted(
+                        remote_host.clone(),
+                        HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+                    ));
+                }
+                PathTrust::AbsPath(..) => debug_panic!("Unexpected: cannot restrict an abs path"),
+            }
+        }
+    }
+
+    /// Erases all trust information.
+    /// Requires Zed's restart to take proper effect.
+    pub fn clear_trusted_paths(&mut self) {
+        self.trusted_paths.clear();
+    }
+
+    /// Checks whether a certain worktree is trusted (or on a larger trust level).
+    /// If not, emits [`TrustedWorktreesEvent::Restricted`] event if for the first time and not trusted, or no corresponding worktree store was found.
+    ///
+    /// No events or data adjustment happens when `trust_all_worktrees` auto trust is enabled.
+    pub fn can_trust(&mut self, worktree_id: WorktreeId, cx: &mut Context<Self>) -> bool {
+        if ProjectSettings::get_global(cx).session.trust_all_worktrees {
+            return true;
+        }
+        if self.restricted.contains(&worktree_id) {
+            return false;
+        }
+
+        let Some((worktree_path, is_file, remote_host)) = self.find_worktree_data(worktree_id, cx)
+        else {
+            return false;
+        };
+
+        if self
+            .trusted_paths
+            .get(&remote_host)
+            .is_some_and(|trusted_paths| trusted_paths.contains(&PathTrust::Worktree(worktree_id)))
+        {
+            return true;
+        }
+
+        // See module documentation for details on trust level.
+        if is_file && self.trusted_paths.contains_key(&remote_host) {
+            return true;
+        }
+
+        let parent_path_trusted =
+            self.trusted_paths
+                .get(&remote_host)
+                .is_some_and(|trusted_paths| {
+                    trusted_paths.iter().any(|trusted_path| {
+                        let PathTrust::AbsPath(trusted_path) = trusted_path else {
+                            return false;
+                        };
+                        worktree_path.starts_with(trusted_path)
+                    })
+                });
+        if parent_path_trusted {
+            return true;
+        }
+
+        self.restricted.insert(worktree_id);
+        cx.emit(TrustedWorktreesEvent::Restricted(
+            remote_host,
+            HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+        ));
+        if let Some((downstream_client, downstream_project_id)) = &self.downstream_client {
+            downstream_client
+                .send(proto::RestrictWorktrees {
+                    project_id: *downstream_project_id,
+                    worktree_ids: vec![worktree_id.to_proto()],
+                })
+                .ok();
+        }
+        if let Some((upstream_client, upstream_project_id)) = &self.upstream_client {
+            upstream_client
+                .send(proto::RestrictWorktrees {
+                    project_id: *upstream_project_id,
+                    worktree_ids: vec![worktree_id.to_proto()],
+                })
+                .ok();
+        }
+        false
+    }
+
+    /// Lists all explicitly restricted worktrees (via [`TrustedWorktreesStore::can_trust`] method calls) for a particular worktree store on a particular host.
+    pub fn restricted_worktrees(
+        &self,
+        worktree_store: &WorktreeStore,
+        cx: &App,
+    ) -> HashSet<(WorktreeId, Arc<Path>)> {
+        let mut single_file_paths = HashSet::default();
+        let other_paths = self
+            .restricted
+            .iter()
+            .filter_map(|&restricted_worktree_id| {
+                let worktree = worktree_store.worktree_for_id(restricted_worktree_id, cx)?;
+                let worktree = worktree.read(cx);
+                let abs_path = worktree.abs_path();
+                if worktree.is_single_file() {
+                    single_file_paths.insert((restricted_worktree_id, abs_path));
+                    None
+                } else {
+                    Some((restricted_worktree_id, abs_path))
+                }
+            })
+            .collect::<HashSet<_>>();
+
+        if !other_paths.is_empty() {
+            return other_paths;
+        } else {
+            single_file_paths
+        }
+    }
+
+    /// Switches the "trust nothing" mode to "automatically trust everything".
+    /// This does not influence already persisted data, but stops adding new worktrees there.
+    pub fn auto_trust_all(&mut self, cx: &mut Context<Self>) {
+        for (remote_host, worktrees) in std::mem::take(&mut self.restricted)
+            .into_iter()
+            .flat_map(|restricted_worktree| {
+                let (_, _, host) = self.find_worktree_data(restricted_worktree, cx)?;
+                Some((restricted_worktree, host))
+            })
+            .fold(HashMap::default(), |mut acc, (worktree_id, remote_host)| {
+                acc.entry(remote_host)
+                    .or_insert_with(HashSet::default)
+                    .insert(PathTrust::Worktree(worktree_id));
+                acc
+            })
+        {
+            self.trust(worktrees, remote_host, cx);
+        }
+    }
+
+    /// Returns a normalized representation of the trusted paths to store in the DB.
+    pub fn trusted_paths_for_serialization(
+        &mut self,
+        cx: &mut Context<Self>,
+    ) -> HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>> {
+        let new_trusted_worktrees = self
+            .trusted_paths
+            .clone()
+            .into_iter()
+            .map(|(host, paths)| {
+                let abs_paths = paths
+                    .into_iter()
+                    .flat_map(|path| match path {
+                        PathTrust::Worktree(worktree_id) => self
+                            .find_worktree_data(worktree_id, cx)
+                            .map(|(abs_path, ..)| abs_path.to_path_buf()),
+                        PathTrust::AbsPath(abs_path) => Some(abs_path),
+                    })
+                    .collect();
+                (host, abs_paths)
+            })
+            .collect();
+        new_trusted_worktrees
+    }
+
+    fn find_worktree_data(
+        &mut self,
+        worktree_id: WorktreeId,
+        cx: &mut Context<Self>,
+    ) -> Option<(Arc<Path>, bool, Option<RemoteHostLocation>)> {
+        let mut worktree_data = None;
+        self.worktree_stores.retain(
+            |worktree_store, remote_host| match worktree_store.upgrade() {
+                Some(worktree_store) => {
+                    if worktree_data.is_none() {
+                        if let Some(worktree) =
+                            worktree_store.read(cx).worktree_for_id(worktree_id, cx)
+                        {
+                            worktree_data = Some((
+                                worktree.read(cx).abs_path(),
+                                worktree.read(cx).is_single_file(),
+                                remote_host.clone(),
+                            ));
+                        }
+                    }
+                    true
+                }
+                None => false,
+            },
+        );
+        worktree_data
+    }
+
+    fn add_worktree_store(
+        &mut self,
+        worktree_store: Entity<WorktreeStore>,
+        remote_host: Option<RemoteHostLocation>,
+        cx: &mut Context<Self>,
+    ) {
+        self.worktree_stores
+            .insert(worktree_store.downgrade(), remote_host.clone());
+
+        if let Some(trusted_paths) = self.trusted_paths.remove(&remote_host) {
+            self.trusted_paths.insert(
+                remote_host.clone(),
+                trusted_paths
+                    .into_iter()
+                    .map(|path_trust| match path_trust {
+                        PathTrust::AbsPath(abs_path) => {
+                            find_worktree_in_store(worktree_store.read(cx), &abs_path, cx)
+                                .map(PathTrust::Worktree)
+                                .unwrap_or_else(|| PathTrust::AbsPath(abs_path))
+                        }
+                        other => other,
+                    })
+                    .collect(),
+            );
+        }
+    }
+}
+
+pub fn find_worktree_in_store(
+    worktree_store: &WorktreeStore,
+    abs_path: &Path,
+    cx: &App,
+) -> Option<WorktreeId> {
+    let (worktree, path_in_worktree) = worktree_store.find_worktree(&abs_path, cx)?;
+    if path_in_worktree.is_empty() {
+        Some(worktree.read(cx).id())
+    } else {
+        None
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{cell::RefCell, path::PathBuf, rc::Rc};
+
+    use collections::HashSet;
+    use gpui::TestAppContext;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
+
+    use crate::{FakeFs, Project};
+
+    use super::*;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            if cx.try_global::<SettingsStore>().is_none() {
+                let settings_store = SettingsStore::test(cx);
+                cx.set_global(settings_store);
+            }
+            if cx.try_global::<TrustedWorktrees>().is_some() {
+                cx.remove_global::<TrustedWorktrees>();
+            }
+        });
+    }
+
+    fn init_trust_global(
+        worktree_store: Entity<WorktreeStore>,
+        cx: &mut TestAppContext,
+    ) -> Entity<TrustedWorktreesStore> {
+        cx.update(|cx| {
+            init(HashMap::default(), None, None, cx);
+            track_worktree_trust(worktree_store, None, None, None, cx);
+            TrustedWorktrees::try_get_global(cx).expect("global should be set")
+        })
+    }
+
+    #[gpui::test]
+    async fn test_single_worktree_trust(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(path!("/root"), json!({ "main.rs": "fn main() {}" }))
+            .await;
+
+        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+        let worktree_id = worktree_store.read_with(cx, |store, cx| {
+            store.worktrees().next().unwrap().read(cx).id()
+        });
+
+        let trusted_worktrees = init_trust_global(worktree_store.clone(), cx);
+
+        let events: Rc<RefCell<Vec<TrustedWorktreesEvent>>> = Rc::default();
+        cx.update({
+            let events = events.clone();
+            |cx| {
+                cx.subscribe(&trusted_worktrees, move |_, event, _| {
+                    events.borrow_mut().push(match event {
+                        TrustedWorktreesEvent::Trusted(host, paths) => {
+                            TrustedWorktreesEvent::Trusted(host.clone(), paths.clone())
+                        }
+                        TrustedWorktreesEvent::Restricted(host, paths) => {
+                            TrustedWorktreesEvent::Restricted(host.clone(), paths.clone())
+                        }
+                    });
+                })
+            }
+        })
+        .detach();
+
+        let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+        assert!(!can_trust, "worktree should be restricted by default");
+
+        {
+            let events = events.borrow();
+            assert_eq!(events.len(), 1);
+            match &events[0] {
+                TrustedWorktreesEvent::Restricted(host, paths) => {
+                    assert!(host.is_none());
+                    assert!(paths.contains(&PathTrust::Worktree(worktree_id)));
+                }
+                _ => panic!("expected Restricted event"),
+            }
+        }
+
+        let has_restricted = trusted_worktrees.read_with(cx, |store, cx| {
+            store.has_restricted_worktrees(&worktree_store, cx)
+        });
+        assert!(has_restricted, "should have restricted worktrees");
+
+        let restricted = worktree_store.read_with(cx, |ws, cx| {
+            trusted_worktrees.read(cx).restricted_worktrees(ws, cx)
+        });
+        assert!(restricted.iter().any(|(id, _)| *id == worktree_id));
+
+        events.borrow_mut().clear();
+
+        let can_trust_again =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+        assert!(!can_trust_again, "worktree should still be restricted");
+        assert!(
+            events.borrow().is_empty(),
+            "no duplicate Restricted event on repeated can_trust"
+        );
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+                None,
+                cx,
+            );
+        });
+
+        {
+            let events = events.borrow();
+            assert_eq!(events.len(), 1);
+            match &events[0] {
+                TrustedWorktreesEvent::Trusted(host, paths) => {
+                    assert!(host.is_none());
+                    assert!(paths.contains(&PathTrust::Worktree(worktree_id)));
+                }
+                _ => panic!("expected Trusted event"),
+            }
+        }
+
+        let can_trust_after =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+        assert!(can_trust_after, "worktree should be trusted after trust()");
+
+        let has_restricted_after = trusted_worktrees.read_with(cx, |store, cx| {
+            store.has_restricted_worktrees(&worktree_store, cx)
+        });
+        assert!(
+            !has_restricted_after,
+            "should have no restricted worktrees after trust"
+        );
+
+        let restricted_after = worktree_store.read_with(cx, |ws, cx| {
+            trusted_worktrees.read(cx).restricted_worktrees(ws, cx)
+        });
+        assert!(
+            restricted_after.is_empty(),
+            "restricted set should be empty"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_single_file_worktree_trust(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(path!("/root"), json!({ "foo.rs": "fn foo() {}" }))
+            .await;
+
+        let project = Project::test(fs, [path!("/root/foo.rs").as_ref()], cx).await;
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+        let worktree_id = worktree_store.read_with(cx, |store, cx| {
+            let worktree = store.worktrees().next().unwrap();
+            let worktree = worktree.read(cx);
+            assert!(worktree.is_single_file(), "expected single-file worktree");
+            worktree.id()
+        });
+
+        let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+        let events: Rc<RefCell<Vec<TrustedWorktreesEvent>>> = Rc::default();
+        cx.update({
+            let events = events.clone();
+            |cx| {
+                cx.subscribe(&trusted_worktrees, move |_, event, _| {
+                    events.borrow_mut().push(match event {
+                        TrustedWorktreesEvent::Trusted(host, paths) => {
+                            TrustedWorktreesEvent::Trusted(host.clone(), paths.clone())
+                        }
+                        TrustedWorktreesEvent::Restricted(host, paths) => {
+                            TrustedWorktreesEvent::Restricted(host.clone(), paths.clone())
+                        }
+                    });
+                })
+            }
+        })
+        .detach();
+
+        let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+        assert!(
+            !can_trust,
+            "single-file worktree should be restricted by default"
+        );
+
+        {
+            let events = events.borrow();
+            assert_eq!(events.len(), 1);
+            match &events[0] {
+                TrustedWorktreesEvent::Restricted(host, paths) => {
+                    assert!(host.is_none());
+                    assert!(paths.contains(&PathTrust::Worktree(worktree_id)));
+                }
+                _ => panic!("expected Restricted event"),
+            }
+        }
+
+        events.borrow_mut().clear();
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+                None,
+                cx,
+            );
+        });
+
+        {
+            let events = events.borrow();
+            assert_eq!(events.len(), 1);
+            match &events[0] {
+                TrustedWorktreesEvent::Trusted(host, paths) => {
+                    assert!(host.is_none());
+                    assert!(paths.contains(&PathTrust::Worktree(worktree_id)));
+                }
+                _ => panic!("expected Trusted event"),
+            }
+        }
+
+        let can_trust_after =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+        assert!(
+            can_trust_after,
+            "single-file worktree should be trusted after trust()"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_multiple_single_file_worktrees_trust_one(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "a.rs": "fn a() {}",
+                "b.rs": "fn b() {}",
+                "c.rs": "fn c() {}"
+            }),
+        )
+        .await;
+
+        let project = Project::test(
+            fs,
+            [
+                path!("/root/a.rs").as_ref(),
+                path!("/root/b.rs").as_ref(),
+                path!("/root/c.rs").as_ref(),
+            ],
+            cx,
+        )
+        .await;
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+        let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+            store
+                .worktrees()
+                .map(|worktree| {
+                    let worktree = worktree.read(cx);
+                    assert!(worktree.is_single_file());
+                    worktree.id()
+                })
+                .collect()
+        });
+        assert_eq!(worktree_ids.len(), 3);
+
+        let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+        for &worktree_id in &worktree_ids {
+            let can_trust =
+                trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+            assert!(
+                !can_trust,
+                "worktree {worktree_id:?} should be restricted initially"
+            );
+        }
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
+                None,
+                cx,
+            );
+        });
+
+        let can_trust_0 =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx));
+        let can_trust_1 =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[1], cx));
+        let can_trust_2 =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[2], cx));
+
+        assert!(!can_trust_0, "worktree 0 should still be restricted");
+        assert!(can_trust_1, "worktree 1 should be trusted");
+        assert!(!can_trust_2, "worktree 2 should still be restricted");
+    }
+
+    #[gpui::test]
+    async fn test_two_directory_worktrees_separate_trust(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/projects"),
+            json!({
+                "project_a": { "main.rs": "fn main() {}" },
+                "project_b": { "lib.rs": "pub fn lib() {}" }
+            }),
+        )
+        .await;
+
+        let project = Project::test(
+            fs,
+            [
+                path!("/projects/project_a").as_ref(),
+                path!("/projects/project_b").as_ref(),
+            ],
+            cx,
+        )
+        .await;
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+        let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+            store
+                .worktrees()
+                .map(|worktree| {
+                    let worktree = worktree.read(cx);
+                    assert!(!worktree.is_single_file());
+                    worktree.id()
+                })
+                .collect()
+        });
+        assert_eq!(worktree_ids.len(), 2);
+
+        let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+        let can_trust_a =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx));
+        let can_trust_b =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[1], cx));
+        assert!(!can_trust_a, "project_a should be restricted initially");
+        assert!(!can_trust_b, "project_b should be restricted initially");
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]),
+                None,
+                cx,
+            );
+        });
+
+        let can_trust_a =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx));
+        let can_trust_b =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[1], cx));
+        assert!(can_trust_a, "project_a should be trusted after trust()");
+        assert!(!can_trust_b, "project_b should still be restricted");
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
+                None,
+                cx,
+            );
+        });
+
+        let can_trust_a =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[0], cx));
+        let can_trust_b =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_ids[1], cx));
+        assert!(can_trust_a, "project_a should remain trusted");
+        assert!(can_trust_b, "project_b should now be trusted");
+    }
+
+    #[gpui::test]
+    async fn test_directory_worktree_trust_enables_single_file(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/"),
+            json!({
+                "project": { "main.rs": "fn main() {}" },
+                "standalone.rs": "fn standalone() {}"
+            }),
+        )
+        .await;
+
+        let project = Project::test(
+            fs,
+            [path!("/project").as_ref(), path!("/standalone.rs").as_ref()],
+            cx,
+        )
+        .await;
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+        let (dir_worktree_id, file_worktree_id) = worktree_store.read_with(cx, |store, cx| {
+            let worktrees: Vec<_> = store.worktrees().collect();
+            assert_eq!(worktrees.len(), 2);
+            let (dir_worktree, file_worktree) = if worktrees[0].read(cx).is_single_file() {
+                (&worktrees[1], &worktrees[0])
+            } else {
+                (&worktrees[0], &worktrees[1])
+            };
+            assert!(!dir_worktree.read(cx).is_single_file());
+            assert!(file_worktree.read(cx).is_single_file());
+            (dir_worktree.read(cx).id(), file_worktree.read(cx).id())
+        });
+
+        let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+        let can_trust_file =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(file_worktree_id, cx));
+        assert!(
+            !can_trust_file,
+            "single-file worktree should be restricted initially"
+        );
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::Worktree(dir_worktree_id)]),
+                None,
+                cx,
+            );
+        });
+
+        let can_trust_dir =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(dir_worktree_id, cx));
+        let can_trust_file_after =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(file_worktree_id, cx));
+        assert!(can_trust_dir, "directory worktree should be trusted");
+        assert!(
+            can_trust_file_after,
+            "single-file worktree should be trusted after directory worktree trust"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_abs_path_trust_covers_multiple_worktrees(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "project_a": { "main.rs": "fn main() {}" },
+                "project_b": { "lib.rs": "pub fn lib() {}" }
+            }),
+        )
+        .await;
+
+        let project = Project::test(
+            fs,
+            [
+                path!("/root/project_a").as_ref(),
+                path!("/root/project_b").as_ref(),
+            ],
+            cx,
+        )
+        .await;
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+        let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+            store
+                .worktrees()
+                .map(|worktree| worktree.read(cx).id())
+                .collect()
+        });
+        assert_eq!(worktree_ids.len(), 2);
+
+        let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+        for &worktree_id in &worktree_ids {
+            let can_trust =
+                trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+            assert!(!can_trust, "worktree should be restricted initially");
+        }
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::AbsPath(PathBuf::from(path!("/root")))]),
+                None,
+                cx,
+            );
+        });
+
+        for &worktree_id in &worktree_ids {
+            let can_trust =
+                trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+            assert!(
+                can_trust,
+                "worktree should be trusted after parent path trust"
+            );
+        }
+    }
+
+    #[gpui::test]
+    async fn test_auto_trust_all(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/"),
+            json!({
+                "project_a": { "main.rs": "fn main() {}" },
+                "project_b": { "lib.rs": "pub fn lib() {}" },
+                "single.rs": "fn single() {}"
+            }),
+        )
+        .await;
+
+        let project = Project::test(
+            fs,
+            [
+                path!("/project_a").as_ref(),
+                path!("/project_b").as_ref(),
+                path!("/single.rs").as_ref(),
+            ],
+            cx,
+        )
+        .await;
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+        let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+            store
+                .worktrees()
+                .map(|worktree| worktree.read(cx).id())
+                .collect()
+        });
+        assert_eq!(worktree_ids.len(), 3);
+
+        let trusted_worktrees = init_trust_global(worktree_store.clone(), cx);
+
+        let events: Rc<RefCell<Vec<TrustedWorktreesEvent>>> = Rc::default();
+        cx.update({
+            let events = events.clone();
+            |cx| {
+                cx.subscribe(&trusted_worktrees, move |_, event, _| {
+                    events.borrow_mut().push(match event {
+                        TrustedWorktreesEvent::Trusted(host, paths) => {
+                            TrustedWorktreesEvent::Trusted(host.clone(), paths.clone())
+                        }
+                        TrustedWorktreesEvent::Restricted(host, paths) => {
+                            TrustedWorktreesEvent::Restricted(host.clone(), paths.clone())
+                        }
+                    });
+                })
+            }
+        })
+        .detach();
+
+        for &worktree_id in &worktree_ids {
+            let can_trust =
+                trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+            assert!(!can_trust, "worktree should be restricted initially");
+        }
+
+        let has_restricted = trusted_worktrees.read_with(cx, |store, cx| {
+            store.has_restricted_worktrees(&worktree_store, cx)
+        });
+        assert!(has_restricted, "should have restricted worktrees");
+
+        events.borrow_mut().clear();
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.auto_trust_all(cx);
+        });
+
+        for &worktree_id in &worktree_ids {
+            let can_trust =
+                trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+            assert!(
+                can_trust,
+                "worktree {worktree_id:?} should be trusted after auto_trust_all"
+            );
+        }
+
+        let has_restricted_after = trusted_worktrees.read_with(cx, |store, cx| {
+            store.has_restricted_worktrees(&worktree_store, cx)
+        });
+        assert!(
+            !has_restricted_after,
+            "should have no restricted worktrees after auto_trust_all"
+        );
+
+        let trusted_event_count = events
+            .borrow()
+            .iter()
+            .filter(|e| matches!(e, TrustedWorktreesEvent::Trusted(..)))
+            .count();
+        assert!(
+            trusted_event_count > 0,
+            "should have emitted Trusted events"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_trust_restrict_trust_cycle(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(path!("/root"), json!({ "main.rs": "fn main() {}" }))
+            .await;
+
+        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+        let worktree_id = worktree_store.read_with(cx, |store, cx| {
+            store.worktrees().next().unwrap().read(cx).id()
+        });
+
+        let trusted_worktrees = init_trust_global(worktree_store.clone(), cx);
+
+        let events: Rc<RefCell<Vec<TrustedWorktreesEvent>>> = Rc::default();
+        cx.update({
+            let events = events.clone();
+            |cx| {
+                cx.subscribe(&trusted_worktrees, move |_, event, _| {
+                    events.borrow_mut().push(match event {
+                        TrustedWorktreesEvent::Trusted(host, paths) => {
+                            TrustedWorktreesEvent::Trusted(host.clone(), paths.clone())
+                        }
+                        TrustedWorktreesEvent::Restricted(host, paths) => {
+                            TrustedWorktreesEvent::Restricted(host.clone(), paths.clone())
+                        }
+                    });
+                })
+            }
+        })
+        .detach();
+
+        let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+        assert!(!can_trust, "should be restricted initially");
+        assert_eq!(events.borrow().len(), 1);
+        events.borrow_mut().clear();
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+                None,
+                cx,
+            );
+        });
+        let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+        assert!(can_trust, "should be trusted after trust()");
+        assert_eq!(events.borrow().len(), 1);
+        assert!(matches!(
+            &events.borrow()[0],
+            TrustedWorktreesEvent::Trusted(..)
+        ));
+        events.borrow_mut().clear();
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.restrict(
+                HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+                None,
+                cx,
+            );
+        });
+        let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+        assert!(!can_trust, "should be restricted after restrict()");
+        assert_eq!(events.borrow().len(), 1);
+        assert!(matches!(
+            &events.borrow()[0],
+            TrustedWorktreesEvent::Restricted(..)
+        ));
+
+        let has_restricted = trusted_worktrees.read_with(cx, |store, cx| {
+            store.has_restricted_worktrees(&worktree_store, cx)
+        });
+        assert!(has_restricted);
+        events.borrow_mut().clear();
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+                None,
+                cx,
+            );
+        });
+        let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+        assert!(can_trust, "should be trusted again after second trust()");
+        assert_eq!(events.borrow().len(), 1);
+        assert!(matches!(
+            &events.borrow()[0],
+            TrustedWorktreesEvent::Trusted(..)
+        ));
+
+        let has_restricted = trusted_worktrees.read_with(cx, |store, cx| {
+            store.has_restricted_worktrees(&worktree_store, cx)
+        });
+        assert!(!has_restricted);
+    }
+
+    #[gpui::test]
+    async fn test_multi_host_trust_isolation(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/"),
+            json!({
+                "local_project": { "main.rs": "fn main() {}" },
+                "remote_project": { "lib.rs": "pub fn lib() {}" }
+            }),
+        )
+        .await;
+
+        let project = Project::test(
+            fs,
+            [
+                path!("/local_project").as_ref(),
+                path!("/remote_project").as_ref(),
+            ],
+            cx,
+        )
+        .await;
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
+        let worktree_ids: Vec<_> = worktree_store.read_with(cx, |store, cx| {
+            store
+                .worktrees()
+                .map(|worktree| worktree.read(cx).id())
+                .collect()
+        });
+        assert_eq!(worktree_ids.len(), 2);
+        let local_worktree = worktree_ids[0];
+        let _remote_worktree = worktree_ids[1];
+
+        let trusted_worktrees = init_trust_global(worktree_store, cx);
+
+        let host_a: Option<RemoteHostLocation> = None;
+
+        let can_trust_local =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(local_worktree, cx));
+        assert!(!can_trust_local, "local worktree restricted on host_a");
+
+        trusted_worktrees.update(cx, |store, cx| {
+            store.trust(
+                HashSet::from_iter([PathTrust::Worktree(local_worktree)]),
+                host_a.clone(),
+                cx,
+            );
+        });
+
+        let can_trust_local_after =
+            trusted_worktrees.update(cx, |store, cx| store.can_trust(local_worktree, cx));
+        assert!(
+            can_trust_local_after,
+            "local worktree should be trusted on host_a"
+        );
+    }
+}

crates/project_panel/Cargo.toml 🔗

@@ -45,6 +45,7 @@ workspace.workspace = true
 language.workspace = true
 zed_actions.workspace = true
 telemetry.workspace = true
+notifications.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }

crates/project_panel/src/project_panel.rs 🔗

@@ -29,6 +29,7 @@ use gpui::{
 };
 use language::DiagnosticSeverity;
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use notifications::status_toast::{StatusToast, ToastIcon};
 use project::{
     Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId,
     ProjectPath, Worktree, WorktreeId,
@@ -880,7 +881,7 @@ impl ProjectPanel {
                                 });
                                 if !focus_opened_item {
                                     let focus_handle = project_panel.read(cx).focus_handle.clone();
-                                    window.focus(&focus_handle);
+                                    window.focus(&focus_handle, cx);
                                 }
                             }
                         }
@@ -1140,6 +1141,12 @@ impl ProjectPanel {
                                 "Copy Relative Path",
                                 Box::new(zed_actions::workspace::CopyRelativePath),
                             )
+                            .when(!is_dir && self.has_git_changes(entry_id), |menu| {
+                                menu.separator().action(
+                                    "Restore File",
+                                    Box::new(git::RestoreFile { skip_prompt: false }),
+                                )
+                            })
                             .when(has_git_repo, |menu| {
                                 menu.separator()
                                     .action("View File History", Box::new(git::FileHistory))
@@ -1169,7 +1176,7 @@ impl ProjectPanel {
                 })
             });
 
-            window.focus(&context_menu.focus_handle(cx));
+            window.focus(&context_menu.focus_handle(cx), cx);
             let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
                 this.context_menu.take();
                 cx.notify();
@@ -1180,6 +1187,19 @@ impl ProjectPanel {
         cx.notify();
     }
 
+    fn has_git_changes(&self, entry_id: ProjectEntryId) -> bool {
+        for visible in &self.state.visible_entries {
+            if let Some(git_entry) = visible.entries.iter().find(|e| e.id == entry_id) {
+                let total_modified =
+                    git_entry.git_summary.index.modified + git_entry.git_summary.worktree.modified;
+                let total_deleted =
+                    git_entry.git_summary.index.deleted + git_entry.git_summary.worktree.deleted;
+                return total_modified > 0 || total_deleted > 0;
+            }
+        }
+        false
+    }
+
     fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
         if !entry.is_dir() || self.state.unfolded_dir_ids.contains(&entry.id) {
             return false;
@@ -1376,7 +1396,7 @@ impl ProjectPanel {
                 }
             });
             self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx);
-            window.focus(&self.focus_handle);
+            window.focus(&self.focus_handle, cx);
             cx.notify();
         }
     }
@@ -1399,7 +1419,7 @@ impl ProjectPanel {
                 }
             }
             self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx);
-            window.focus(&self.focus_handle);
+            window.focus(&self.focus_handle, cx);
             cx.notify();
         }
     }
@@ -1719,7 +1739,7 @@ impl ProjectPanel {
             };
             if let Some(existing) = worktree.read(cx).entry_for_path(&new_path) {
                 if existing.id == entry.id && refocus {
-                    window.focus(&self.focus_handle);
+                    window.focus(&self.focus_handle, cx);
                 }
                 return None;
             }
@@ -1730,7 +1750,7 @@ impl ProjectPanel {
         };
 
         if refocus {
-            window.focus(&self.focus_handle);
+            window.focus(&self.focus_handle, cx);
         }
         edit_state.processing_filename = Some(filename);
         cx.notify();
@@ -1839,7 +1859,7 @@ impl ProjectPanel {
             self.autoscroll(cx);
         }
 
-        window.focus(&self.focus_handle);
+        window.focus(&self.focus_handle, cx);
         cx.notify();
     }
 
@@ -2041,6 +2061,100 @@ impl ProjectPanel {
         self.remove(false, action.skip_prompt, window, cx);
     }
 
+    fn restore_file(
+        &mut self,
+        action: &git::RestoreFile,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        maybe!({
+            let selection = self.state.selection?;
+            let project = self.project.read(cx);
+
+            let (_worktree, entry) = self.selected_sub_entry(cx)?;
+            if entry.is_dir() {
+                return None;
+            }
+
+            let project_path = project.path_for_entry(selection.entry_id, cx)?;
+
+            let git_store = project.git_store();
+            let (repository, repo_path) = git_store
+                .read(cx)
+                .repository_and_path_for_project_path(&project_path, cx)?;
+
+            let snapshot = repository.read(cx).snapshot();
+            let status = snapshot.status_for_path(&repo_path)?;
+            if !status.status.is_modified() && !status.status.is_deleted() {
+                return None;
+            }
+
+            let file_name = entry.path.file_name()?.to_string();
+
+            let answer = if !action.skip_prompt {
+                let prompt = format!("Discard changes to {}?", file_name);
+                Some(window.prompt(PromptLevel::Info, &prompt, None, &["Restore", "Cancel"], cx))
+            } else {
+                None
+            };
+
+            cx.spawn_in(window, async move |panel, cx| {
+                if let Some(answer) = answer
+                    && answer.await != Ok(0)
+                {
+                    return anyhow::Ok(());
+                }
+
+                let task = panel.update(cx, |_panel, cx| {
+                    repository.update(cx, |repo, cx| {
+                        repo.checkout_files("HEAD", vec![repo_path], cx)
+                    })
+                })?;
+
+                if let Err(e) = task.await {
+                    panel
+                        .update(cx, |panel, cx| {
+                            let message = format!("Failed to restore {}: {}", file_name, e);
+                            let toast = StatusToast::new(message, cx, |this, _| {
+                                this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
+                                    .dismiss_button(true)
+                            });
+                            panel
+                                .workspace
+                                .update(cx, |workspace, cx| {
+                                    workspace.toggle_status_toast(toast, cx);
+                                })
+                                .ok();
+                        })
+                        .ok();
+                }
+
+                panel
+                    .update(cx, |panel, cx| {
+                        panel.project.update(cx, |project, cx| {
+                            if let Some(buffer_id) = project
+                                .buffer_store()
+                                .read(cx)
+                                .buffer_id_for_project_path(&project_path)
+                            {
+                                if let Some(buffer) = project.buffer_for_id(*buffer_id, cx) {
+                                    buffer.update(cx, |buffer, cx| {
+                                        let _ = buffer.reload(cx);
+                                    });
+                                }
+                            }
+                        })
+                    })
+                    .ok();
+
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+
+            Some(())
+        });
+    }
+
     fn remove(
         &mut self,
         trash: bool,
@@ -3616,7 +3730,7 @@ impl ProjectPanel {
                 if this.update_visible_entries_task.focus_filename_editor {
                     this.update_visible_entries_task.focus_filename_editor = false;
                     this.filename_editor.update(cx, |editor, cx| {
-                        window.focus(&editor.focus_handle(cx));
+                        window.focus(&editor.focus_handle(cx), cx);
                     });
                 }
                 if this.update_visible_entries_task.autoscroll {
@@ -5631,6 +5745,7 @@ impl Render for ProjectPanel {
                         .on_action(cx.listener(Self::copy))
                         .on_action(cx.listener(Self::paste))
                         .on_action(cx.listener(Self::duplicate))
+                        .on_action(cx.listener(Self::restore_file))
                         .when(!project.is_remote(), |el| {
                             el.on_action(cx.listener(Self::trash))
                         })
@@ -5952,7 +6067,7 @@ impl Render for ProjectPanel {
                                     cx.stop_propagation();
                                     this.state.selection = None;
                                     this.marked_entries.clear();
-                                    this.focus_handle(cx).focus(window);
+                                    this.focus_handle(cx).focus(window, cx);
                                 }))
                                 .on_mouse_down(
                                     MouseButton::Right,

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -92,7 +92,13 @@ impl Settings for ProjectPanelSettings {
             entry_spacing: project_panel.entry_spacing.unwrap(),
             file_icons: project_panel.file_icons.unwrap(),
             folder_icons: project_panel.folder_icons.unwrap(),
-            git_status: project_panel.git_status.unwrap(),
+            git_status: project_panel.git_status.unwrap()
+                && content
+                    .git
+                    .unwrap()
+                    .enabled
+                    .unwrap()
+                    .is_git_status_enabled(),
             indent_size: project_panel.indent_size.unwrap(),
             indent_guides: IndentGuidesSettings {
                 show: project_panel.indent_guides.unwrap().show.unwrap(),

crates/prompt_store/Cargo.toml 🔗

@@ -28,6 +28,11 @@ parking_lot.workspace = true
 paths.workspace = true
 rope.workspace = true
 serde.workspace = true
+strum.workspace = true
 text.workspace = true
 util.workspace = true
 uuid.workspace = true
+
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }
+tempfile.workspace = true

crates/prompt_store/src/prompt_store.rs 🔗

@@ -1,6 +1,6 @@
 mod prompts;
 
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Result, anyhow};
 use chrono::{DateTime, Utc};
 use collections::HashMap;
 use futures::FutureExt as _;
@@ -23,6 +23,7 @@ use std::{
     path::PathBuf,
     sync::{Arc, atomic::AtomicBool},
 };
+use strum::{EnumIter, IntoEnumIterator as _};
 use text::LineEnding;
 use util::ResultExt;
 use uuid::Uuid;
@@ -51,11 +52,51 @@ pub struct PromptMetadata {
     pub saved_at: DateTime<Utc>,
 }
 
+impl PromptMetadata {
+    fn builtin(builtin: BuiltInPrompt) -> Self {
+        Self {
+            id: PromptId::BuiltIn(builtin),
+            title: Some(builtin.title().into()),
+            default: false,
+            saved_at: DateTime::default(),
+        }
+    }
+}
+
+/// Built-in prompts that have default content and can be customized by users.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
+pub enum BuiltInPrompt {
+    CommitMessage,
+}
+
+impl BuiltInPrompt {
+    pub fn title(&self) -> &'static str {
+        match self {
+            Self::CommitMessage => "Commit message",
+        }
+    }
+
+    /// Returns the default content for this built-in prompt.
+    pub fn default_content(&self) -> &'static str {
+        match self {
+            Self::CommitMessage => include_str!("../../git_ui/src/commit_message_prompt.txt"),
+        }
+    }
+}
+
+impl std::fmt::Display for BuiltInPrompt {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::CommitMessage => write!(f, "Commit message"),
+        }
+    }
+}
+
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 #[serde(tag = "kind")]
 pub enum PromptId {
     User { uuid: UserPromptId },
-    EditWorkflow,
+    BuiltIn(BuiltInPrompt),
 }
 
 impl PromptId {
@@ -63,8 +104,37 @@ impl PromptId {
         UserPromptId::new().into()
     }
 
+    pub fn as_user(&self) -> Option<UserPromptId> {
+        match self {
+            Self::User { uuid } => Some(*uuid),
+            Self::BuiltIn { .. } => None,
+        }
+    }
+
+    pub fn as_built_in(&self) -> Option<BuiltInPrompt> {
+        match self {
+            Self::User { .. } => None,
+            Self::BuiltIn(builtin) => Some(*builtin),
+        }
+    }
+
     pub fn is_built_in(&self) -> bool {
-        !matches!(self, PromptId::User { .. })
+        matches!(self, Self::BuiltIn { .. })
+    }
+
+    pub fn can_edit(&self) -> bool {
+        match self {
+            Self::User { .. } => true,
+            Self::BuiltIn(builtin) => match builtin {
+                BuiltInPrompt::CommitMessage => true,
+            },
+        }
+    }
+}
+
+impl From<BuiltInPrompt> for PromptId {
+    fn from(builtin: BuiltInPrompt) -> Self {
+        PromptId::BuiltIn(builtin)
     }
 }
 
@@ -94,7 +164,7 @@ impl std::fmt::Display for PromptId {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
             PromptId::User { uuid } => write!(f, "{}", uuid.0),
-            PromptId::EditWorkflow => write!(f, "Edit workflow"),
+            PromptId::BuiltIn(builtin) => write!(f, "{}", builtin),
         }
     }
 }
@@ -123,10 +193,28 @@ impl MetadataCache {
     ) -> Result<Self> {
         let mut cache = MetadataCache::default();
         for result in db.iter(txn)? {
-            let (prompt_id, metadata) = result?;
+            // Fail-open: skip records that can't be decoded (e.g. from a different branch)
+            // rather than failing the entire prompt store initialization.
+            let Ok((prompt_id, metadata)) = result else {
+                log::warn!(
+                    "Skipping unreadable prompt record in database: {:?}",
+                    result.err()
+                );
+                continue;
+            };
             cache.metadata.push(metadata.clone());
             cache.metadata_by_id.insert(prompt_id, metadata);
         }
+
+        // Insert all the built-in prompts that were not customized by the user
+        for builtin in BuiltInPrompt::iter() {
+            let builtin_id = PromptId::BuiltIn(builtin);
+            if !cache.metadata_by_id.contains_key(&builtin_id) {
+                let metadata = PromptMetadata::builtin(builtin);
+                cache.metadata.push(metadata.clone());
+                cache.metadata_by_id.insert(builtin_id, metadata);
+            }
+        }
         cache.sort();
         Ok(cache)
     }
@@ -175,12 +263,6 @@ impl PromptStore {
             let mut txn = db_env.write_txn()?;
             let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
             let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
-
-            // Remove edit workflow prompt, as we decided to opt into it using
-            // a slash command instead.
-            metadata.delete(&mut txn, &PromptId::EditWorkflow).ok();
-            bodies.delete(&mut txn, &PromptId::EditWorkflow).ok();
-
             txn.commit()?;
 
             Self::upgrade_dbs(&db_env, metadata, bodies).log_err();
@@ -273,7 +355,16 @@ impl PromptStore {
         let bodies = self.bodies;
         cx.background_spawn(async move {
             let txn = env.read_txn()?;
-            let mut prompt = bodies.get(&txn, &id)?.context("prompt not found")?.into();
+            let mut prompt: String = match bodies.get(&txn, &id)? {
+                Some(body) => body.into(),
+                None => {
+                    if let Some(built_in) = id.as_built_in() {
+                        built_in.default_content().into()
+                    } else {
+                        anyhow::bail!("prompt not found")
+                    }
+                }
+            };
             LineEnding::normalize(&mut prompt);
             Ok(prompt)
         })
@@ -318,11 +409,6 @@ impl PromptStore {
         })
     }
 
-    /// Returns the number of prompts in the store.
-    pub fn prompt_count(&self) -> usize {
-        self.metadata_cache.read().metadata.len()
-    }
-
     pub fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
         self.metadata_cache.read().metadata_by_id.get(&id).cloned()
     }
@@ -387,27 +473,42 @@ impl PromptStore {
         body: Rope,
         cx: &Context<Self>,
     ) -> Task<Result<()>> {
-        if id.is_built_in() {
-            return Task::ready(Err(anyhow!("built-in prompts cannot be saved")));
+        if !id.can_edit() {
+            return Task::ready(Err(anyhow!("this prompt cannot be edited")));
         }
 
-        let prompt_metadata = PromptMetadata {
-            id,
-            title,
-            default,
-            saved_at: Utc::now(),
+        let body = body.to_string();
+        let is_default_content = id
+            .as_built_in()
+            .is_some_and(|builtin| body.trim() == builtin.default_content().trim());
+
+        let metadata = if let Some(builtin) = id.as_built_in() {
+            PromptMetadata::builtin(builtin)
+        } else {
+            PromptMetadata {
+                id,
+                title,
+                default,
+                saved_at: Utc::now(),
+            }
         };
-        self.metadata_cache.write().insert(prompt_metadata.clone());
+
+        self.metadata_cache.write().insert(metadata.clone());
 
         let db_connection = self.env.clone();
         let bodies = self.bodies;
-        let metadata = self.metadata;
+        let metadata_db = self.metadata;
 
         let task = cx.background_spawn(async move {
             let mut txn = db_connection.write_txn()?;
 
-            metadata.put(&mut txn, &id, &prompt_metadata)?;
-            bodies.put(&mut txn, &id, &body.to_string())?;
+            if is_default_content {
+                metadata_db.delete(&mut txn, &id)?;
+                bodies.delete(&mut txn, &id)?;
+            } else {
+                metadata_db.put(&mut txn, &id, &metadata)?;
+                bodies.put(&mut txn, &id, &body)?;
+            }
 
             txn.commit()?;
 
@@ -430,7 +531,7 @@ impl PromptStore {
     ) -> Task<Result<()>> {
         let mut cache = self.metadata_cache.write();
 
-        if id.is_built_in() {
+        if !id.can_edit() {
             title = cache
                 .metadata_by_id
                 .get(&id)
@@ -469,3 +570,201 @@ impl PromptStore {
 pub struct GlobalPromptStore(Shared<Task<Result<Entity<PromptStore>, Arc<anyhow::Error>>>>);
 
 impl Global for GlobalPromptStore {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::TestAppContext;
+
+    #[gpui::test]
+    async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+
+        let temp_dir = tempfile::tempdir().unwrap();
+        let db_path = temp_dir.path().join("prompts-db");
+
+        let store = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap();
+        let store = cx.new(|_cx| store);
+
+        let commit_message_id = PromptId::BuiltIn(BuiltInPrompt::CommitMessage);
+
+        let loaded_content = store
+            .update(cx, |store, cx| store.load(commit_message_id, cx))
+            .await
+            .unwrap();
+
+        let mut expected_content = BuiltInPrompt::CommitMessage.default_content().to_string();
+        LineEnding::normalize(&mut expected_content);
+        assert_eq!(
+            loaded_content.trim(),
+            expected_content.trim(),
+            "Loading a built-in prompt not in DB should return default content"
+        );
+
+        let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id));
+        assert!(
+            metadata.is_some(),
+            "Built-in prompt should always have metadata"
+        );
+        assert!(
+            store.read_with(cx, |store, _| {
+                store
+                    .metadata_cache
+                    .read()
+                    .metadata_by_id
+                    .contains_key(&commit_message_id)
+            }),
+            "Built-in prompt should always be in cache"
+        );
+
+        let custom_content = "Custom commit message prompt";
+        store
+            .update(cx, |store, cx| {
+                store.save(
+                    commit_message_id,
+                    Some("Commit message".into()),
+                    false,
+                    Rope::from(custom_content),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        let loaded_custom = store
+            .update(cx, |store, cx| store.load(commit_message_id, cx))
+            .await
+            .unwrap();
+        assert_eq!(
+            loaded_custom.trim(),
+            custom_content.trim(),
+            "Custom content should be loaded after saving"
+        );
+
+        assert!(
+            store
+                .read_with(cx, |store, _| store.metadata(commit_message_id))
+                .is_some(),
+            "Built-in prompt should have metadata after customization"
+        );
+
+        store
+            .update(cx, |store, cx| {
+                store.save(
+                    commit_message_id,
+                    Some("Commit message".into()),
+                    false,
+                    Rope::from(BuiltInPrompt::CommitMessage.default_content()),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        let metadata_after_reset =
+            store.read_with(cx, |store, _| store.metadata(commit_message_id));
+        assert!(
+            metadata_after_reset.is_some(),
+            "Built-in prompt should still have metadata after reset"
+        );
+        assert_eq!(
+            metadata_after_reset
+                .as_ref()
+                .and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
+            Some("Commit message"),
+            "Built-in prompt should have default title after reset"
+        );
+
+        let loaded_after_reset = store
+            .update(cx, |store, cx| store.load(commit_message_id, cx))
+            .await
+            .unwrap();
+        let mut expected_content_after_reset =
+            BuiltInPrompt::CommitMessage.default_content().to_string();
+        LineEnding::normalize(&mut expected_content_after_reset);
+        assert_eq!(
+            loaded_after_reset.trim(),
+            expected_content_after_reset.trim(),
+            "Content should be back to default after saving default content"
+        );
+    }
+
+    /// Test that the prompt store initializes successfully even when the database
+    /// contains records with incompatible/undecodable PromptId keys (e.g., from
+    /// a different branch that used a different serialization format).
+    ///
+    /// This is a regression test for the "fail-open" behavior: we should skip
+    /// bad records rather than failing the entire store initialization.
+    #[gpui::test]
+    async fn test_prompt_store_handles_incompatible_db_records(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+
+        let temp_dir = tempfile::tempdir().unwrap();
+        let db_path = temp_dir.path().join("prompts-db-with-bad-records");
+        std::fs::create_dir_all(&db_path).unwrap();
+
+        // First, create the DB and write an incompatible record directly.
+        // We simulate a record written by a different branch that used
+        // `{"kind":"CommitMessage"}` instead of `{"kind":"BuiltIn", ...}`.
+        {
+            let db_env = unsafe {
+                heed::EnvOpenOptions::new()
+                    .map_size(1024 * 1024 * 1024)
+                    .max_dbs(4)
+                    .open(&db_path)
+                    .unwrap()
+            };
+
+            let mut txn = db_env.write_txn().unwrap();
+            // Create the metadata.v2 database with raw bytes so we can write
+            // an incompatible key format.
+            let metadata_db: Database<heed::types::Bytes, heed::types::Bytes> = db_env
+                .create_database(&mut txn, Some("metadata.v2"))
+                .unwrap();
+
+            // Write an incompatible PromptId key: `{"kind":"CommitMessage"}`
+            // This is the old/branch format that current code can't decode.
+            let bad_key = br#"{"kind":"CommitMessage"}"#;
+            let dummy_metadata = br#"{"id":{"kind":"CommitMessage"},"title":"Bad Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
+            metadata_db.put(&mut txn, bad_key, dummy_metadata).unwrap();
+
+            // Also write a valid record to ensure we can still read good data.
+            let good_key = br#"{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"}"#;
+            let good_metadata = br#"{"id":{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"},"title":"Good Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
+            metadata_db.put(&mut txn, good_key, good_metadata).unwrap();
+
+            txn.commit().unwrap();
+        }
+
+        // Now try to create a PromptStore from this DB.
+        // With fail-open behavior, this should succeed and skip the bad record.
+        // Without fail-open, this would return an error.
+        let store_result = cx.update(|cx| PromptStore::new(db_path, cx)).await;
+
+        assert!(
+            store_result.is_ok(),
+            "PromptStore should initialize successfully even with incompatible DB records. \
+             Got error: {:?}",
+            store_result.err()
+        );
+
+        let store = cx.new(|_cx| store_result.unwrap());
+
+        // Verify the good record was loaded.
+        let good_id = PromptId::User {
+            uuid: UserPromptId("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
+        };
+        let metadata = store.read_with(cx, |store, _| store.metadata(good_id));
+        assert!(
+            metadata.is_some(),
+            "Valid records should still be loaded after skipping bad ones"
+        );
+        assert_eq!(
+            metadata
+                .as_ref()
+                .and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
+            Some("Good Record"),
+            "Valid record should have correct title"
+        );
+    }
+}

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>,
@@ -100,7 +112,7 @@ pub struct ContentPromptContextV2 {
     pub language_name: Option<String>,
     pub is_truncated: bool,
     pub document_content: String,
-    pub rewrite_section: Option<String>,
+    pub rewrite_section: String,
     pub diagnostic_errors: Vec<ContentPromptDiagnosticContext>,
 }
 
@@ -298,7 +310,6 @@ impl PromptBuilder {
         };
 
         const MAX_CTX: usize = 50000;
-        let is_insert = range.is_empty();
         let mut is_truncated = false;
 
         let before_range = 0..range.start;
@@ -323,28 +334,19 @@ impl PromptBuilder {
         for chunk in buffer.text_for_range(truncated_before) {
             document_content.push_str(chunk);
         }
-        if is_insert {
-            document_content.push_str("<insert_here></insert_here>");
-        } else {
-            document_content.push_str("<rewrite_this>\n");
-            for chunk in buffer.text_for_range(range.clone()) {
-                document_content.push_str(chunk);
-            }
-            document_content.push_str("\n</rewrite_this>");
+
+        document_content.push_str("<rewrite_this>\n");
+        for chunk in buffer.text_for_range(range.clone()) {
+            document_content.push_str(chunk);
         }
+        document_content.push_str("\n</rewrite_this>");
+
         for chunk in buffer.text_for_range(truncated_after) {
             document_content.push_str(chunk);
         }
 
-        let rewrite_section = if !is_insert {
-            let mut section = String::new();
-            for chunk in buffer.text_for_range(range.clone()) {
-                section.push_str(chunk);
-            }
-            Some(section)
-        } else {
-            None
-        };
+        let rewrite_section: String = buffer.text_for_range(range.clone()).collect();
+
         let diagnostics = buffer.diagnostics_in_range::<_, Point>(range, false);
         let diagnostic_errors: Vec<ContentPromptDiagnosticContext> = diagnostics
             .map(|entry| {

crates/proto/proto/worktree.proto 🔗

@@ -2,159 +2,181 @@ syntax = "proto3";
 package zed.messages;
 
 message Timestamp {
-    uint64 seconds = 1;
-    uint32 nanos = 2;
+  uint64 seconds = 1;
+  uint32 nanos = 2;
 }
 
 message File {
-    uint64 worktree_id = 1;
-    optional uint64 entry_id = 2;
-    string path = 3;
-    Timestamp mtime = 4;
-    bool is_deleted = 5;
+  uint64 worktree_id = 1;
+  optional uint64 entry_id = 2;
+  string path = 3;
+  Timestamp mtime = 4;
+  bool is_deleted = 5;
+  bool is_historic = 6;
 }
 
 message Entry {
-    uint64 id = 1;
-    bool is_dir = 2;
-    string path = 3;
-    uint64 inode = 4;
-    Timestamp mtime = 5;
-    bool is_ignored = 7;
-    bool is_external = 8;
-    reserved 6;
-    reserved 9;
-    bool is_fifo = 10;
-    optional uint64 size = 11;
-    optional string canonical_path = 12;
-    bool is_hidden = 13;
+  uint64 id = 1;
+  bool is_dir = 2;
+  string path = 3;
+  uint64 inode = 4;
+  Timestamp mtime = 5;
+  bool is_ignored = 7;
+  bool is_external = 8;
+  reserved 6;
+  reserved 9;
+  bool is_fifo = 10;
+  optional uint64 size = 11;
+  optional string canonical_path = 12;
+  bool is_hidden = 13;
 }
 
 message AddWorktree {
-    string path = 1;
-    uint64 project_id = 2;
-    bool visible = 3;
+  string path = 1;
+  uint64 project_id = 2;
+  bool visible = 3;
 }
 
 message AddWorktreeResponse {
-    uint64 worktree_id = 1;
-    string canonicalized_path = 2;
+  uint64 worktree_id = 1;
+  string canonicalized_path = 2;
 }
 
 message RemoveWorktree {
-    uint64 worktree_id = 1;
+  uint64 worktree_id = 1;
 }
 
 message GetPathMetadata {
-    uint64 project_id = 1;
-    string path = 2;
+  uint64 project_id = 1;
+  string path = 2;
 }
 
 message GetPathMetadataResponse {
-    bool exists = 1;
-    string path = 2;
-    bool is_dir = 3;
+  bool exists = 1;
+  string path = 2;
+  bool is_dir = 3;
 }
 
 message WorktreeMetadata {
-    uint64 id = 1;
-    string root_name = 2;
-    bool visible = 3;
-    string abs_path = 4;
+  uint64 id = 1;
+  string root_name = 2;
+  bool visible = 3;
+  string abs_path = 4;
 }
 
 message ProjectPath {
-    uint64 worktree_id = 1;
-    string path = 2;
+  uint64 worktree_id = 1;
+  string path = 2;
 }
 
 message ListRemoteDirectoryConfig {
-    bool is_dir = 1;
+  bool is_dir = 1;
 }
 
 message ListRemoteDirectory {
-    uint64 dev_server_id = 1;
-    string path = 2;
-    ListRemoteDirectoryConfig config = 3;
+  uint64 dev_server_id = 1;
+  string path = 2;
+  ListRemoteDirectoryConfig config = 3;
 }
 
 message EntryInfo {
-    bool is_dir = 1;
+  bool is_dir = 1;
 }
 
 message ListRemoteDirectoryResponse {
-    repeated string entries = 1;
-    repeated EntryInfo entry_info = 2;
+  repeated string entries = 1;
+  repeated EntryInfo entry_info = 2;
 }
 
 message CreateProjectEntry {
-    uint64 project_id = 1;
-    uint64 worktree_id = 2;
-    string path = 3;
-    bool is_directory = 4;
-    optional bytes content = 5;
+  uint64 project_id = 1;
+  uint64 worktree_id = 2;
+  string path = 3;
+  bool is_directory = 4;
+  optional bytes content = 5;
 }
 
 message RenameProjectEntry {
-    uint64 project_id = 1;
-    uint64 entry_id = 2;
-    string new_path = 3;
-    uint64 new_worktree_id = 4;
+  uint64 project_id = 1;
+  uint64 entry_id = 2;
+  string new_path = 3;
+  uint64 new_worktree_id = 4;
 }
 
 message CopyProjectEntry {
-    uint64 project_id = 1;
-    uint64 entry_id = 2;
-    string new_path = 3;
-    uint64 new_worktree_id = 5;
-    reserved 4;
+  uint64 project_id = 1;
+  uint64 entry_id = 2;
+  string new_path = 3;
+  uint64 new_worktree_id = 5;
+  reserved 4;
 }
 
 message DeleteProjectEntry {
-    uint64 project_id = 1;
-    uint64 entry_id = 2;
-    bool use_trash = 3;
+  uint64 project_id = 1;
+  uint64 entry_id = 2;
+  bool use_trash = 3;
 }
 
 message ExpandProjectEntry {
-    uint64 project_id = 1;
-    uint64 entry_id = 2;
+  uint64 project_id = 1;
+  uint64 entry_id = 2;
 }
 
 message ExpandProjectEntryResponse {
-    uint64 worktree_scan_id = 1;
+  uint64 worktree_scan_id = 1;
 }
 
 message ExpandAllForProjectEntry {
-    uint64 project_id = 1;
-    uint64 entry_id = 2;
+  uint64 project_id = 1;
+  uint64 entry_id = 2;
 }
 
 message ExpandAllForProjectEntryResponse {
-    uint64 worktree_scan_id = 1;
+  uint64 worktree_scan_id = 1;
 }
 
 message ProjectEntryResponse {
-    optional Entry entry = 1;
-    uint64 worktree_scan_id = 2;
+  optional Entry entry = 1;
+  uint64 worktree_scan_id = 2;
 }
 
 message UpdateWorktreeSettings {
-    uint64 project_id = 1;
-    uint64 worktree_id = 2;
-    string path = 3;
-    optional string content = 4;
-    optional LocalSettingsKind kind = 5;
+  uint64 project_id = 1;
+  uint64 worktree_id = 2;
+  string path = 3;
+  optional string content = 4;
+  optional LocalSettingsKind kind = 5;
 }
 
 enum LocalSettingsKind {
-    Settings = 0;
-    Tasks = 1;
-    Editorconfig = 2;
-    Debug = 3;
+  Settings = 0;
+  Tasks = 1;
+  Editorconfig = 2;
+  Debug = 3;
 }
 
 message UpdateUserSettings {
+  uint64 project_id = 1;
+  string contents = 2;
+}
+
+message TrustWorktrees {
+    uint64 project_id = 1;
+    repeated PathTrust trusted_paths = 2;
+}
+
+message PathTrust {
+    oneof content {
+        uint64 worktree_id = 2;
+        string abs_path = 3;
+    }
+
+    reserved 1;
+}
+
+message RestrictWorktrees {
     uint64 project_id = 1;
-    string contents = 2;
+    repeated uint64 worktree_ids = 3;
+
+    reserved 2;
 }

crates/proto/proto/zed.proto 🔗

@@ -448,7 +448,10 @@ message Envelope {
         ExternalExtensionAgentsUpdated external_extension_agents_updated = 401;
 
         GitCreateRemote git_create_remote = 402;
-        GitRemoveRemote git_remove_remote = 403;// current max
+        GitRemoveRemote git_remove_remote = 403;
+
+        TrustWorktrees trust_worktrees = 404;
+        RestrictWorktrees restrict_worktrees = 405; // current max
     }
 
     reserved 87 to 88, 396;

crates/proto/src/proto.rs 🔗

@@ -310,6 +310,8 @@ messages!(
     (GitCreateBranch, Background),
     (GitChangeBranch, Background),
     (GitRenameBranch, Background),
+    (TrustWorktrees, Background),
+    (RestrictWorktrees, Background),
     (CheckForPushedCommits, Background),
     (CheckForPushedCommitsResponse, Background),
     (GitDiff, Background),
@@ -529,7 +531,9 @@ request_messages!(
     (GetAgentServerCommand, AgentServerCommand),
     (RemoteStarted, Ack),
     (GitGetWorktrees, GitWorktreesResponse),
-    (GitCreateWorktree, Ack)
+    (GitCreateWorktree, Ack),
+    (TrustWorktrees, Ack),
+    (RestrictWorktrees, Ack),
 );
 
 lsp_messages!(
@@ -702,7 +706,9 @@ entity_messages!(
     ExternalAgentLoadingStatusUpdated,
     NewExternalAgentVersionAvailable,
     GitGetWorktrees,
-    GitCreateWorktree
+    GitCreateWorktree,
+    TrustWorktrees,
+    RestrictWorktrees,
 );
 
 entity_messages!(

crates/recent_projects/src/remote_connections.rs 🔗

@@ -16,6 +16,7 @@ use gpui::{
 
 use language::{CursorShape, Point};
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use project::trusted_worktrees;
 use release_channel::ReleaseChannel;
 use remote::{
     ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection,
@@ -51,7 +52,7 @@ impl SshSettings {
 
     pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
         for conn in self.ssh_connections() {
-            if conn.host == options.host
+            if conn.host == options.host.to_string()
                 && conn.username == options.username
                 && conn.port == options.port
             {
@@ -71,7 +72,7 @@ impl SshSettings {
         username: Option<String>,
     ) -> SshConnectionOptions {
         let mut options = SshConnectionOptions {
-            host,
+            host: host.into(),
             port,
             username,
             ..Default::default()
@@ -208,7 +209,7 @@ impl RemoteConnectionPrompt {
         let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
         self.prompt = Some((markdown, tx));
         self.status_message.take();
-        window.focus(&self.editor.focus_handle(cx));
+        window.focus(&self.editor.focus_handle(cx), cx);
         cx.notify();
     }
 
@@ -532,8 +533,8 @@ impl remote::RemoteClientDelegate for RemoteClientDelegate {
             AutoUpdater::download_remote_server_release(
                 release_channel,
                 version.clone(),
-                platform.os,
-                platform.arch,
+                platform.os.as_str(),
+                platform.arch.as_str(),
                 move |status, cx| this.set_status(Some(status), cx),
                 cx,
             )
@@ -563,8 +564,8 @@ impl remote::RemoteClientDelegate for RemoteClientDelegate {
             AutoUpdater::get_remote_server_release_url(
                 release_channel,
                 version,
-                platform.os,
-                platform.arch,
+                platform.os.as_str(),
+                platform.arch.as_str(),
                 cx,
             )
             .await
@@ -646,6 +647,7 @@ pub async fn open_remote_project(
                 app_state.languages.clone(),
                 app_state.fs.clone(),
                 None,
+                false,
                 cx,
             );
             cx.new(|cx| {
@@ -788,11 +790,20 @@ pub async fn open_remote_project(
                     continue;
                 }
 
-                if created_new_window {
-                    window
-                        .update(cx, |_, window, _| window.remove_window())
-                        .ok();
-                }
+                window
+                    .update(cx, |workspace, window, cx| {
+                        if created_new_window {
+                            window.remove_window();
+                        }
+                        trusted_worktrees::track_worktree_trust(
+                            workspace.project().read(cx).worktree_store(),
+                            None,
+                            None,
+                            None,
+                            cx,
+                        );
+                    })
+                    .ok();
             }
 
             Ok(items) => {

crates/recent_projects/src/remote_servers.rs 🔗

@@ -76,7 +76,7 @@ impl CreateRemoteServer {
     fn new(window: &mut Window, cx: &mut App) -> Self {
         let address_editor = cx.new(|cx| Editor::single_line(window, cx));
         address_editor.update(cx, |this, cx| {
-            this.focus_handle(cx).focus(window);
+            this.focus_handle(cx).focus(window, cx);
         });
         Self {
             address_editor,
@@ -107,7 +107,7 @@ struct CreateRemoteDevContainer {
 impl CreateRemoteDevContainer {
     fn new(window: &mut Window, cx: &mut Context<RemoteServerProjects>) -> Self {
         let entries = std::array::from_fn(|_| NavigableEntry::focusable(cx));
-        entries[0].focus_handle.focus(window);
+        entries[0].focus_handle.focus(window, cx);
         Self {
             entries,
             progress: DevContainerCreationProgress::Initial,
@@ -199,7 +199,7 @@ impl EditNicknameState {
                 this.set_text(starting_text, window, cx);
             }
         });
-        this.editor.focus_handle(cx).focus(window);
+        this.editor.focus_handle(cx).focus(window, cx);
         this
     }
 }
@@ -792,7 +792,7 @@ impl RemoteServerProjects {
                         this.retained_connections.push(client);
                         this.add_ssh_server(connection_options, cx);
                         this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
-                        this.focus_handle(cx).focus(window);
+                        this.focus_handle(cx).focus(window, cx);
                         cx.notify()
                     })
                     .log_err(),
@@ -875,7 +875,7 @@ impl RemoteServerProjects {
 
                     crate::add_wsl_distro(fs, &connection_options, cx);
                     this.mode = Mode::default_mode(&BTreeSet::new(), cx);
-                    this.focus_handle(cx).focus(window);
+                    this.focus_handle(cx).focus(window, cx);
                     cx.notify();
                 }),
                 _ => this.update(cx, |this, cx| {
@@ -924,7 +924,7 @@ impl RemoteServerProjects {
                 return;
             }
         });
-        self.focus_handle(cx).focus(window);
+        self.focus_handle(cx).focus(window, cx);
         cx.notify();
     }
 
@@ -933,7 +933,7 @@ impl RemoteServerProjects {
             CreateRemoteDevContainer::new(window, cx)
                 .progress(DevContainerCreationProgress::Creating),
         );
-        self.focus_handle(cx).focus(window);
+        self.focus_handle(cx).focus(window, cx);
         cx.notify();
     }
 
@@ -1000,6 +1000,7 @@ impl RemoteServerProjects {
                                 app_state.user_store.clone(),
                                 app_state.languages.clone(),
                                 app_state.fs.clone(),
+                                true,
                                 cx,
                             ),
                         )
@@ -1067,7 +1068,7 @@ impl RemoteServerProjects {
                     }
                 });
                 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
-                self.focus_handle.focus(window);
+                self.focus_handle.focus(window, cx);
             }
             #[cfg(target_os = "windows")]
             Mode::AddWslDistro(state) => {
@@ -1093,7 +1094,7 @@ impl RemoteServerProjects {
             }
             _ => {
                 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
-                self.focus_handle(cx).focus(window);
+                self.focus_handle(cx).focus(window, cx);
                 cx.notify();
             }
         }
@@ -1517,7 +1518,7 @@ impl RemoteServerProjects {
                 .ssh_connections
                 .get_or_insert(Default::default())
                 .push(SshConnection {
-                    host: SharedString::from(connection_options.host),
+                    host: SharedString::from(connection_options.host.to_string()),
                     username: connection_options.username,
                     port: connection_options.port,
                     projects: BTreeSet::new(),
@@ -1639,7 +1640,7 @@ impl RemoteServerProjects {
     ) -> impl IntoElement {
         match &state.progress {
             DevContainerCreationProgress::Error(message) => {
-                self.focus_handle(cx).focus(window);
+                self.focus_handle(cx).focus(window, cx);
                 return div()
                     .track_focus(&self.focus_handle(cx))
                     .size_full()
@@ -1951,7 +1952,7 @@ impl RemoteServerProjects {
         let connection_prompt = state.connection_prompt.clone();
 
         state.picker.update(cx, |picker, cx| {
-            picker.focus_handle(cx).focus(window);
+            picker.focus_handle(cx).focus(window, cx);
         });
 
         v_flex()
@@ -1982,7 +1983,7 @@ impl RemoteServerProjects {
                 .size_full()
                 .child(match &options {
                     ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
-                        connection_string: connection.host.clone().into(),
+                        connection_string: connection.host.to_string().into(),
                         paths: Default::default(),
                         nickname: connection.nickname.clone().map(|s| s.into()),
                         is_wsl: false,
@@ -2147,7 +2148,7 @@ impl RemoteServerProjects {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let connection_string = SharedString::new(connection.host.clone());
+        let connection_string = SharedString::new(connection.host.to_string());
 
         v_flex()
             .child({
@@ -2658,7 +2659,7 @@ impl RemoteServerProjects {
 
         self.add_ssh_server(
             SshConnectionOptions {
-                host: ssh_config_host.to_string(),
+                host: ssh_config_host.to_string().into(),
                 ..SshConnectionOptions::default()
             },
             cx,
@@ -2751,7 +2752,7 @@ impl Render for RemoteServerProjects {
             .on_action(cx.listener(Self::cancel))
             .on_action(cx.listener(Self::confirm))
             .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
-                this.focus_handle(cx).focus(window);
+                this.focus_handle(cx).focus(window, cx);
             }))
             .on_mouse_down_out(cx.listener(|this, _, _, cx| {
                 if matches!(this.mode, Mode::Default(_)) {

crates/remote/src/remote.rs 🔗

@@ -7,8 +7,9 @@ mod transport;
 #[cfg(target_os = "windows")]
 pub use remote_client::OpenWslPath;
 pub use remote_client::{
-    ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
-    RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect,
+    ConnectionIdentifier, ConnectionState, RemoteArch, RemoteClient, RemoteClientDelegate,
+    RemoteClientEvent, RemoteConnection, RemoteConnectionOptions, RemoteOs, RemotePlatform,
+    connect,
 };
 pub use transport::docker::DockerConnectionOptions;
 pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};

crates/remote/src/remote_client.rs 🔗

@@ -49,10 +49,58 @@ use util::{
     paths::{PathStyle, RemotePathBuf},
 };
 
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum RemoteOs {
+    Linux,
+    MacOs,
+    Windows,
+}
+
+impl RemoteOs {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            RemoteOs::Linux => "linux",
+            RemoteOs::MacOs => "macos",
+            RemoteOs::Windows => "windows",
+        }
+    }
+
+    pub fn is_windows(&self) -> bool {
+        matches!(self, RemoteOs::Windows)
+    }
+}
+
+impl std::fmt::Display for RemoteOs {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum RemoteArch {
+    X86_64,
+    Aarch64,
+}
+
+impl RemoteArch {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            RemoteArch::X86_64 => "x86_64",
+            RemoteArch::Aarch64 => "aarch64",
+        }
+    }
+}
+
+impl std::fmt::Display for RemoteArch {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
 #[derive(Copy, Clone, Debug)]
 pub struct RemotePlatform {
-    pub os: &'static str,
-    pub arch: &'static str,
+    pub os: RemoteOs,
+    pub arch: RemoteArch,
 }
 
 #[derive(Clone, Debug)]
@@ -89,7 +137,8 @@ pub trait RemoteClientDelegate: Send + Sync {
 const MAX_MISSED_HEARTBEATS: usize = 5;
 const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
 const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5);
-const INITIAL_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60);
+const INITIAL_CONNECTION_TIMEOUT: Duration =
+    Duration::from_secs(if cfg!(debug_assertions) { 5 } else { 60 });
 
 const MAX_RECONNECT_ATTEMPTS: usize = 3;
 
@@ -921,10 +970,12 @@ impl RemoteClient {
         client_cx: &mut gpui::TestAppContext,
         server_cx: &mut gpui::TestAppContext,
     ) -> (RemoteConnectionOptions, AnyProtoClient) {
+        use crate::transport::ssh::SshConnectionHost;
+
         let port = client_cx
             .update(|cx| cx.default_global::<ConnectionPool>().connections.len() as u16 + 1);
         let opts = RemoteConnectionOptions::Ssh(SshConnectionOptions {
-            host: "<fake>".to_string(),
+            host: SshConnectionHost::from("<fake>".to_string()),
             port: Some(port),
             ..Default::default()
         });
@@ -1089,7 +1140,7 @@ pub enum RemoteConnectionOptions {
 impl RemoteConnectionOptions {
     pub fn display_name(&self) -> String {
         match self {
-            RemoteConnectionOptions::Ssh(opts) => opts.host.clone(),
+            RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(),
             RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(),
             RemoteConnectionOptions::Docker(opts) => opts.name.clone(),
         }

crates/remote/src/transport.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{
-    RemotePlatform,
+    RemoteArch, RemoteOs, RemotePlatform,
     json_log::LogRecord,
     protocol::{MESSAGE_LEN_SIZE, message_len_from_buffer, read_message_with_len, write_message},
 };
@@ -26,8 +26,8 @@ fn parse_platform(output: &str) -> Result<RemotePlatform> {
     };
 
     let os = match os {
-        "Darwin" => "macos",
-        "Linux" => "linux",
+        "Darwin" => RemoteOs::MacOs,
+        "Linux" => RemoteOs::Linux,
         _ => anyhow::bail!(
             "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
         ),
@@ -39,9 +39,9 @@ fn parse_platform(output: &str) -> Result<RemotePlatform> {
         || arch.starts_with("arm64")
         || arch.starts_with("aarch64")
     {
-        "aarch64"
+        RemoteArch::Aarch64
     } else if arch.starts_with("x86") {
-        "x86_64"
+        RemoteArch::X86_64
     } else {
         anyhow::bail!(
             "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
@@ -193,7 +193,8 @@ async fn build_remote_server_from_source(
             .await?;
         anyhow::ensure!(
             output.status.success(),
-            "Failed to run command: {command:?}"
+            "Failed to run command: {command:?}: output: {}",
+            String::from_utf8_lossy(&output.stderr)
         );
         Ok(())
     }
@@ -203,14 +204,15 @@ async fn build_remote_server_from_source(
         "{}-{}",
         platform.arch,
         match platform.os {
-            "linux" =>
+            RemoteOs::Linux =>
                 if use_musl {
                     "unknown-linux-musl"
                 } else {
                     "unknown-linux-gnu"
                 },
-            "macos" => "apple-darwin",
-            _ => anyhow::bail!("can't cross compile for: {:?}", platform),
+            RemoteOs::MacOs => "apple-darwin",
+            RemoteOs::Windows if cfg!(windows) => "pc-windows-msvc",
+            RemoteOs::Windows => "pc-windows-gnu",
         }
     );
     let mut rust_flags = match std::env::var("RUSTFLAGS") {
@@ -221,7 +223,7 @@ async fn build_remote_server_from_source(
             String::new()
         }
     };
-    if platform.os == "linux" && use_musl {
+    if platform.os == RemoteOs::Linux && use_musl {
         rust_flags.push_str(" -C target-feature=+crt-static");
 
         if let Ok(path) = std::env::var("ZED_ZSTD_MUSL_LIB") {
@@ -232,7 +234,9 @@ async fn build_remote_server_from_source(
         rust_flags.push_str(" -C link-arg=-fuse-ld=mold");
     }
 
-    if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS {
+    if platform.arch.as_str() == std::env::consts::ARCH
+        && platform.os.as_str() == std::env::consts::OS
+    {
         delegate.set_status(Some("Building remote server binary from source"), cx);
         log::info!("building remote server binary from source");
         run_cmd(
@@ -308,7 +312,8 @@ async fn build_remote_server_from_source(
         .join("remote_server")
         .join(&triple)
         .join("debug")
-        .join("remote_server");
+        .join("remote_server")
+        .with_extension(if platform.os.is_windows() { "exe" } else { "" });
 
     let path = if !build_remote_server.contains("nocompress") {
         delegate.set_status(Some("Compressing binary"), cx);
@@ -374,35 +379,44 @@ mod tests {
     #[test]
     fn test_parse_platform() {
         let result = parse_platform("Linux x86_64\n").unwrap();
-        assert_eq!(result.os, "linux");
-        assert_eq!(result.arch, "x86_64");
+        assert_eq!(result.os, RemoteOs::Linux);
+        assert_eq!(result.arch, RemoteArch::X86_64);
 
         let result = parse_platform("Darwin arm64\n").unwrap();
-        assert_eq!(result.os, "macos");
-        assert_eq!(result.arch, "aarch64");
+        assert_eq!(result.os, RemoteOs::MacOs);
+        assert_eq!(result.arch, RemoteArch::Aarch64);
 
         let result = parse_platform("Linux x86_64").unwrap();
-        assert_eq!(result.os, "linux");
-        assert_eq!(result.arch, "x86_64");
+        assert_eq!(result.os, RemoteOs::Linux);
+        assert_eq!(result.arch, RemoteArch::X86_64);
 
         let result = parse_platform("some shell init output\nLinux aarch64\n").unwrap();
-        assert_eq!(result.os, "linux");
-        assert_eq!(result.arch, "aarch64");
+        assert_eq!(result.os, RemoteOs::Linux);
+        assert_eq!(result.arch, RemoteArch::Aarch64);
 
         let result = parse_platform("some shell init output\nLinux aarch64").unwrap();
-        assert_eq!(result.os, "linux");
-        assert_eq!(result.arch, "aarch64");
+        assert_eq!(result.os, RemoteOs::Linux);
+        assert_eq!(result.arch, RemoteArch::Aarch64);
 
-        assert_eq!(parse_platform("Linux armv8l\n").unwrap().arch, "aarch64");
-        assert_eq!(parse_platform("Linux aarch64\n").unwrap().arch, "aarch64");
-        assert_eq!(parse_platform("Linux x86_64\n").unwrap().arch, "x86_64");
+        assert_eq!(
+            parse_platform("Linux armv8l\n").unwrap().arch,
+            RemoteArch::Aarch64
+        );
+        assert_eq!(
+            parse_platform("Linux aarch64\n").unwrap().arch,
+            RemoteArch::Aarch64
+        );
+        assert_eq!(
+            parse_platform("Linux x86_64\n").unwrap().arch,
+            RemoteArch::X86_64
+        );
 
         let result = parse_platform(
             r#"Linux x86_64 - What you're referring to as Linux, is in fact, GNU/Linux...\n"#,
         )
         .unwrap();
-        assert_eq!(result.os, "linux");
-        assert_eq!(result.arch, "x86_64");
+        assert_eq!(result.os, RemoteOs::Linux);
+        assert_eq!(result.arch, RemoteArch::X86_64);
 
         assert!(parse_platform("Windows x86_64\n").is_err());
         assert!(parse_platform("Linux armv7l\n").is_err());

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

@@ -24,8 +24,8 @@ use gpui::{App, AppContext, AsyncApp, Task};
 use rpc::proto::Envelope;
 
 use crate::{
-    RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
-    remote_client::CommandTemplate,
+    RemoteArch, RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemoteOs,
+    RemotePlatform, remote_client::CommandTemplate,
 };
 
 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
@@ -70,7 +70,7 @@ impl DockerExecConnection {
         let remote_platform = this.check_remote_platform().await?;
 
         this.path_style = match remote_platform.os {
-            "windows" => Some(PathStyle::Windows),
+            RemoteOs::Windows => Some(PathStyle::Windows),
             _ => Some(PathStyle::Posix),
         };
 
@@ -124,8 +124,8 @@ impl DockerExecConnection {
         };
 
         let os = match os.trim() {
-            "Darwin" => "macos",
-            "Linux" => "linux",
+            "Darwin" => RemoteOs::MacOs,
+            "Linux" => RemoteOs::Linux,
             _ => anyhow::bail!(
                 "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
             ),
@@ -136,9 +136,9 @@ impl DockerExecConnection {
             || arch.starts_with("arm64")
             || arch.starts_with("aarch64")
         {
-            "aarch64"
+            RemoteArch::Aarch64
         } else if arch.starts_with("x86") {
-            "x86_64"
+            RemoteArch::X86_64
         } else {
             anyhow::bail!(
                 "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"

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

@@ -1,5 +1,5 @@
 use crate::{
-    RemoteClientDelegate, RemotePlatform,
+    RemoteArch, RemoteClientDelegate, RemoteOs, RemotePlatform,
     remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions},
     transport::{parse_platform, parse_shell},
 };
@@ -23,6 +23,7 @@ use smol::{
     process::{self, Child, Stdio},
 };
 use std::{
+    net::IpAddr,
     path::{Path, PathBuf},
     sync::Arc,
     time::Instant,
@@ -31,8 +32,7 @@ use tempfile::TempDir;
 use util::{
     paths::{PathStyle, RemotePathBuf},
     rel_path::RelPath,
-    shell::{Shell, ShellKind},
-    shell_builder::ShellBuilder,
+    shell::ShellKind,
 };
 
 pub(crate) struct SshRemoteConnection {
@@ -47,9 +47,58 @@ pub(crate) struct SshRemoteConnection {
     _temp_dir: TempDir,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum SshConnectionHost {
+    IpAddr(IpAddr),
+    Hostname(String),
+}
+
+impl SshConnectionHost {
+    pub fn to_bracketed_string(&self) -> String {
+        match self {
+            Self::IpAddr(IpAddr::V4(ip)) => ip.to_string(),
+            Self::IpAddr(IpAddr::V6(ip)) => format!("[{}]", ip),
+            Self::Hostname(hostname) => hostname.clone(),
+        }
+    }
+
+    pub fn to_string(&self) -> String {
+        match self {
+            Self::IpAddr(ip) => ip.to_string(),
+            Self::Hostname(hostname) => hostname.clone(),
+        }
+    }
+}
+
+impl From<&str> for SshConnectionHost {
+    fn from(value: &str) -> Self {
+        if let Ok(address) = value.parse() {
+            Self::IpAddr(address)
+        } else {
+            Self::Hostname(value.to_string())
+        }
+    }
+}
+
+impl From<String> for SshConnectionHost {
+    fn from(value: String) -> Self {
+        if let Ok(address) = value.parse() {
+            Self::IpAddr(address)
+        } else {
+            Self::Hostname(value)
+        }
+    }
+}
+
+impl Default for SshConnectionHost {
+    fn default() -> Self {
+        Self::Hostname(Default::default())
+    }
+}
+
 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
 pub struct SshConnectionOptions {
-    pub host: String,
+    pub host: SshConnectionHost,
     pub username: Option<String>,
     pub port: Option<u16>,
     pub password: Option<String>,
@@ -64,7 +113,7 @@ pub struct SshConnectionOptions {
 impl From<settings::SshConnection> for SshConnectionOptions {
     fn from(val: settings::SshConnection) -> Self {
         SshConnectionOptions {
-            host: val.host.into(),
+            host: val.host.to_string().into(),
             username: val.username,
             port: val.port,
             password: None,
@@ -96,7 +145,7 @@ impl MasterProcess {
         askpass_script_path: &std::ffi::OsStr,
         additional_args: Vec<String>,
         socket_path: &std::path::Path,
-        url: &str,
+        destination: &str,
     ) -> Result<Self> {
         let args = [
             "-N",
@@ -120,7 +169,7 @@ impl MasterProcess {
 
         master_process.arg(format!("ControlPath={}", socket_path.display()));
 
-        let process = master_process.arg(&url).spawn()?;
+        let process = master_process.arg(&destination).spawn()?;
 
         Ok(MasterProcess { process })
     }
@@ -143,7 +192,7 @@ impl MasterProcess {
     pub fn new(
         askpass_script_path: &std::ffi::OsStr,
         additional_args: Vec<String>,
-        url: &str,
+        destination: &str,
     ) -> Result<Self> {
         // On Windows, `ControlMaster` and `ControlPath` are not supported:
         // https://github.com/PowerShell/Win32-OpenSSH/issues/405
@@ -165,7 +214,7 @@ impl MasterProcess {
             .env("SSH_ASKPASS_REQUIRE", "force")
             .env("SSH_ASKPASS", askpass_script_path)
             .args(additional_args)
-            .arg(url)
+            .arg(destination)
             .args(args);
 
         let process = master_process.spawn()?;
@@ -352,30 +401,50 @@ impl RemoteConnection for SshRemoteConnection {
         delegate: Arc<dyn RemoteClientDelegate>,
         cx: &mut AsyncApp,
     ) -> Task<Result<i32>> {
+        const VARS: [&str; 3] = ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"];
         delegate.set_status(Some("Starting proxy"), cx);
 
         let Some(remote_binary_path) = self.remote_binary_path.clone() else {
             return Task::ready(Err(anyhow!("Remote binary path not set")));
         };
 
-        let mut proxy_args = vec![];
-        for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
-            if let Some(value) = std::env::var(env_var).ok() {
-                proxy_args.push(format!("{}='{}'", env_var, value));
+        let mut ssh_command = if self.ssh_platform.os.is_windows() {
+            // TODO: Set the `VARS` environment variables, we do not have `env` on windows
+            // so this needs a different approach
+            let mut proxy_args = vec![];
+            proxy_args.push("proxy".to_owned());
+            proxy_args.push("--identifier".to_owned());
+            proxy_args.push(unique_identifier);
+
+            if reconnect {
+                proxy_args.push("--reconnect".to_owned());
             }
-        }
-        proxy_args.push(remote_binary_path.display(self.path_style()).into_owned());
-        proxy_args.push("proxy".to_owned());
-        proxy_args.push("--identifier".to_owned());
-        proxy_args.push(unique_identifier);
+            self.socket.ssh_command(
+                self.ssh_shell_kind,
+                &remote_binary_path.display(self.path_style()),
+                &proxy_args,
+                false,
+            )
+        } else {
+            let mut proxy_args = vec![];
+            for env_var in VARS {
+                if let Some(value) = std::env::var(env_var).ok() {
+                    proxy_args.push(format!("{}='{}'", env_var, value));
+                }
+            }
+            proxy_args.push(remote_binary_path.display(self.path_style()).into_owned());
+            proxy_args.push("proxy".to_owned());
+            proxy_args.push("--identifier".to_owned());
+            proxy_args.push(unique_identifier);
 
-        if reconnect {
-            proxy_args.push("--reconnect".to_owned());
-        }
+            if reconnect {
+                proxy_args.push("--reconnect".to_owned());
+            }
+            self.socket
+                .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false)
+        };
 
-        let ssh_proxy_process = match self
-            .socket
-            .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false)
+        let ssh_proxy_process = match ssh_command
             // IMPORTANT: we kill this process when we drop the task that uses it.
             .kill_on_drop(true)
             .spawn()
@@ -412,7 +481,7 @@ impl SshRemoteConnection {
     ) -> Result<Self> {
         use askpass::AskPassResult;
 
-        let url = connection_options.ssh_url();
+        let destination = connection_options.ssh_destination();
 
         let temp_dir = tempfile::Builder::new()
             .prefix("zed-ssh-session")
@@ -437,14 +506,14 @@ impl SshRemoteConnection {
         let mut master_process = MasterProcess::new(
             askpass.script_path().as_ref(),
             connection_options.additional_args(),
-            &url,
+            &destination,
         )?;
         #[cfg(not(target_os = "windows"))]
         let mut master_process = MasterProcess::new(
             askpass.script_path().as_ref(),
             connection_options.additional_args(),
             &socket_path,
-            &url,
+            &destination,
         )?;
 
         let result = select_biased! {
@@ -495,22 +564,20 @@ impl SshRemoteConnection {
         .await?;
         drop(askpass);
 
-        let ssh_shell = socket.shell().await;
+        let is_windows = socket.probe_is_windows().await;
+        log::info!("Remote is windows: {}", is_windows);
+
+        let ssh_shell = socket.shell(is_windows).await;
         log::info!("Remote shell discovered: {}", ssh_shell);
-        let ssh_platform = socket.platform(ShellKind::new(&ssh_shell, false)).await?;
+
+        let ssh_shell_kind = ShellKind::new(&ssh_shell, is_windows);
+        let ssh_platform = socket.platform(ssh_shell_kind, is_windows).await?;
         log::info!("Remote platform discovered: {:?}", ssh_platform);
-        let ssh_path_style = match ssh_platform.os {
-            "windows" => PathStyle::Windows,
-            _ => PathStyle::Posix,
+
+        let (ssh_path_style, ssh_default_system_shell) = match ssh_platform.os {
+            RemoteOs::Windows => (PathStyle::Windows, ssh_shell.clone()),
+            _ => (PathStyle::Posix, String::from("/bin/sh")),
         };
-        let ssh_default_system_shell = String::from("/bin/sh");
-        let ssh_shell_kind = ShellKind::new(
-            &ssh_shell,
-            match ssh_platform.os {
-                "windows" => true,
-                _ => false,
-            },
-        );
 
         let mut this = Self {
             socket,
@@ -546,9 +613,14 @@ impl SshRemoteConnection {
             _ => version.to_string(),
         };
         let binary_name = format!(
-            "zed-remote-server-{}-{}",
+            "zed-remote-server-{}-{}{}",
             release_channel.dev_name(),
-            version_str
+            version_str,
+            if self.ssh_platform.os.is_windows() {
+                ".exe"
+            } else {
+                ""
+            }
         );
         let dst_path =
             paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
@@ -660,14 +732,19 @@ impl SshRemoteConnection {
         cx: &mut AsyncApp,
     ) -> Result<()> {
         if let Some(parent) = tmp_path_gz.parent() {
-            self.socket
+            let res = self
+                .socket
                 .run_command(
                     self.ssh_shell_kind,
                     "mkdir",
                     &["-p", parent.display(self.path_style()).as_ref()],
                     true,
                 )
-                .await?;
+                .await;
+            if !self.ssh_platform.os.is_windows() {
+                // mkdir fails on windows if the path already exists ...
+                res?;
+            }
         }
 
         delegate.set_status(Some("Downloading remote development server on host"), cx);
@@ -755,17 +832,24 @@ impl SshRemoteConnection {
         cx: &mut AsyncApp,
     ) -> Result<()> {
         if let Some(parent) = tmp_path_gz.parent() {
-            self.socket
+            let res = self
+                .socket
                 .run_command(
                     self.ssh_shell_kind,
                     "mkdir",
                     &["-p", parent.display(self.path_style()).as_ref()],
                     true,
                 )
-                .await?;
+                .await;
+            if !self.ssh_platform.os.is_windows() {
+                // mkdir fails on windows if the path already exists ...
+                res?;
+            }
         }
 
-        let src_stat = fs::metadata(&src_path).await?;
+        let src_stat = fs::metadata(&src_path)
+            .await
+            .with_context(|| format!("failed to get metadata for {:?}", src_path))?;
         let size = src_stat.len();
 
         let t0 = Instant::now();
@@ -816,7 +900,7 @@ impl SshRemoteConnection {
         };
         let args = shell_kind.args_for_shell(false, script.to_string());
         self.socket
-            .run_command(shell_kind, "sh", &args, true)
+            .run_command(self.ssh_shell_kind, "sh", &args, true)
             .await?;
         Ok(())
     }
@@ -840,7 +924,7 @@ impl SshRemoteConnection {
         }
         command.arg(src_path).arg(format!(
             "{}:{}",
-            self.socket.connection_options.scp_url(),
+            self.socket.connection_options.scp_destination(),
             dest_path_str
         ));
         command
@@ -856,7 +940,7 @@ impl SshRemoteConnection {
                 .unwrap_or_default(),
         );
         command.arg("-b").arg("-");
-        command.arg(self.socket.connection_options.scp_url());
+        command.arg(self.socket.connection_options.scp_destination());
         command.stdin(Stdio::piped());
         command
     }
@@ -986,7 +1070,7 @@ impl SshSocket {
         let separator = shell_kind.sequential_commands_separator();
         let to_run = format!("cd{separator} {to_run}");
         self.ssh_options(&mut command, true)
-            .arg(self.connection_options.ssh_url());
+            .arg(self.connection_options.ssh_destination());
         if !allow_pseudo_tty {
             command.arg("-T");
         }
@@ -1004,6 +1088,7 @@ impl SshSocket {
     ) -> Result<String> {
         let mut command = self.ssh_command(shell_kind, program, args, allow_pseudo_tty);
         let output = command.output().await?;
+        log::debug!("{:?}: {:?}", command, output);
         anyhow::ensure!(
             output.status.success(),
             "failed to run command {command:?}: {}",
@@ -1063,7 +1148,7 @@ impl SshSocket {
             "ControlMaster=no".to_string(),
             "-o".to_string(),
             format!("ControlPath={}", self.socket_path.display()),
-            self.connection_options.ssh_url(),
+            self.connection_options.ssh_destination(),
         ]);
         arguments
     }
@@ -1071,16 +1156,75 @@ impl SshSocket {
     #[cfg(target_os = "windows")]
     fn ssh_args(&self) -> Vec<String> {
         let mut arguments = self.connection_options.additional_args();
-        arguments.push(self.connection_options.ssh_url());
+        arguments.push(self.connection_options.ssh_destination());
         arguments
     }
 
-    async fn platform(&self, shell: ShellKind) -> Result<RemotePlatform> {
-        let output = self.run_command(shell, "uname", &["-sm"], false).await?;
+    async fn platform(&self, shell: ShellKind, is_windows: bool) -> Result<RemotePlatform> {
+        if is_windows {
+            self.platform_windows(shell).await
+        } else {
+            self.platform_posix(shell).await
+        }
+    }
+
+    async fn platform_posix(&self, shell: ShellKind) -> Result<RemotePlatform> {
+        let output = self
+            .run_command(shell, "uname", &["-sm"], false)
+            .await
+            .context("Failed to run 'uname -sm' to determine platform")?;
         parse_platform(&output)
     }
 
-    async fn shell(&self) -> String {
+    async fn platform_windows(&self, shell: ShellKind) -> Result<RemotePlatform> {
+        let output = self
+            .run_command(
+                shell,
+                "cmd",
+                &["/c", "echo", "%PROCESSOR_ARCHITECTURE%"],
+                false,
+            )
+            .await
+            .context(
+                "Failed to run 'echo %PROCESSOR_ARCHITECTURE%' to determine Windows architecture",
+            )?;
+
+        Ok(RemotePlatform {
+            os: RemoteOs::Windows,
+            arch: match output.trim() {
+                "AMD64" => RemoteArch::X86_64,
+                "ARM64" => RemoteArch::Aarch64,
+                arch => anyhow::bail!(
+                    "Prebuilt remote servers are not yet available for windows-{arch}. See https://zed.dev/docs/remote-development"
+                ),
+            },
+        })
+    }
+
+    /// Probes whether the remote host is running Windows.
+    ///
+    /// This is done by attempting to run a simple Windows-specific command.
+    /// If it succeeds and returns Windows-like output, we assume it's Windows.
+    async fn probe_is_windows(&self) -> bool {
+        match self
+            .run_command(ShellKind::PowerShell, "cmd", &["/c", "ver"], false)
+            .await
+        {
+            // Windows 'ver' command outputs something like "Microsoft Windows [Version 10.0.19045.5011]"
+            Ok(output) => output.trim().contains("indows"),
+            Err(_) => false,
+        }
+    }
+
+    async fn shell(&self, is_windows: bool) -> String {
+        if is_windows {
+            self.shell_windows().await
+        } else {
+            self.shell_posix().await
+        }
+    }
+
+    async fn shell_posix(&self) -> String {
         const DEFAULT_SHELL: &str = "sh";
         match self
             .run_command(ShellKind::Posix, "sh", &["-c", "echo $SHELL"], false)
@@ -1093,6 +1237,13 @@ impl SshSocket {
             }
         }
     }
+
+    async fn shell_windows(&self) -> String {
+        // powershell is always the default, and cannot really be removed from the system
+        // so we can rely on that fact and reasonably assume that we will be running in a
+        // powershell environment
+        "powershell.exe".to_owned()
+    }
 }
 
 fn parse_port_number(port_str: &str) -> Result<u16> {
@@ -1208,10 +1359,24 @@ impl SshConnectionOptions {
                 input = rest;
                 username = Some(u.to_string());
             }
-            if let Some((rest, p)) = input.split_once(':') {
+
+            // Handle port parsing, accounting for IPv6 addresses
+            // IPv6 addresses can be: 2001:db8::1 or [2001:db8::1]:22
+            if input.starts_with('[') {
+                if let Some((rest, p)) = input.rsplit_once("]:") {
+                    input = rest.strip_prefix('[').unwrap_or(rest);
+                    port = p.parse().ok();
+                } else if input.ends_with(']') {
+                    input = input.strip_prefix('[').unwrap_or(input);
+                    input = input.strip_suffix(']').unwrap_or(input);
+                }
+            } else if let Some((rest, p)) = input.rsplit_once(':')
+                && !rest.contains(":")
+            {
                 input = rest;
-                port = p.parse().ok()
+                port = p.parse().ok();
             }
+
             hostname = Some(input.to_string())
         }
 
@@ -1225,7 +1390,7 @@ impl SshConnectionOptions {
         };
 
         Ok(Self {
-            host: hostname,
+            host: hostname.into(),
             username,
             port,
             port_forwards,
@@ -1237,19 +1402,16 @@ impl SshConnectionOptions {
         })
     }
 
-    pub fn ssh_url(&self) -> String {
-        let mut result = String::from("ssh://");
+    pub fn ssh_destination(&self) -> String {
+        let mut result = String::default();
         if let Some(username) = &self.username {
             // Username might be: username1@username2@ip2
             let username = urlencoding::encode(username);
             result.push_str(&username);
             result.push('@');
         }
-        result.push_str(&self.host);
-        if let Some(port) = self.port {
-            result.push(':');
-            result.push_str(&port.to_string());
-        }
+
+        result.push_str(&self.host.to_string());
         result
     }
 
@@ -1264,6 +1426,11 @@ impl SshConnectionOptions {
             args.extend(["-o".to_string(), format!("ConnectTimeout={}", timeout)]);
         }
 
+        if let Some(port) = self.port {
+            args.push("-p".to_string());
+            args.push(port.to_string());
+        }
+
         if let Some(forwards) = &self.port_forwards {
             args.extend(forwards.iter().map(|pf| {
                 let local_host = match &pf.local_host {
@@ -1285,22 +1452,23 @@ impl SshConnectionOptions {
         args
     }
 
-    fn scp_url(&self) -> String {
+    fn scp_destination(&self) -> String {
         if let Some(username) = &self.username {
-            format!("{}@{}", username, self.host)
+            format!("{}@{}", username, self.host.to_bracketed_string())
         } else {
-            self.host.clone()
+            self.host.to_string()
         }
     }
 
     pub fn connection_string(&self) -> String {
-        let host = if let Some(username) = &self.username {
-            format!("{}@{}", username, self.host)
+        let host = if let Some(port) = &self.port {
+            format!("{}:{}", self.host.to_bracketed_string(), port)
         } else {
-            self.host.clone()
+            self.host.to_string()
         };
-        if let Some(port) = &self.port {
-            format!("{}:{}", host, port)
+
+        if let Some(username) = &self.username {
+            format!("{}@{}", username, host)
         } else {
             host
         }
@@ -1375,8 +1543,6 @@ fn build_command(
     } else {
         write!(exec, "{ssh_shell} -l")?;
     };
-    let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false)
-        .build(Some(exec.clone()), &[]);
 
     let mut args = Vec::new();
     args.extend(ssh_args);
@@ -1387,8 +1553,7 @@ fn build_command(
     }
 
     args.push("-t".into());
-    args.push(command);
-    args.extend(command_args);
+    args.push(exec);
 
     Ok(CommandTemplate {
         program: "ssh".into(),
@@ -1428,9 +1593,6 @@ mod tests {
                 "-p",
                 "2222",
                 "-t",
-                "/bin/fish",
-                "-i",
-                "-c",
                 "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
             ]
         );
@@ -1463,9 +1625,6 @@ mod tests {
                 "-L",
                 "1:foo:2",
                 "-t",
-                "/bin/fish",
-                "-i",
-                "-c",
                 "cd && exec env INPUT_VA=val /bin/fish -l"
             ]
         );
@@ -1510,4 +1669,44 @@ mod tests {
             ]
         );
     }
+
+    #[test]
+    fn test_host_parsing() -> Result<()> {
+        let opts = SshConnectionOptions::parse_command_line("user@2001:db8::1")?;
+        assert_eq!(opts.host, "2001:db8::1".into());
+        assert_eq!(opts.username, Some("user".to_string()));
+        assert_eq!(opts.port, None);
+
+        let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]:2222")?;
+        assert_eq!(opts.host, "2001:db8::1".into());
+        assert_eq!(opts.username, Some("user".to_string()));
+        assert_eq!(opts.port, Some(2222));
+
+        let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]")?;
+        assert_eq!(opts.host, "2001:db8::1".into());
+        assert_eq!(opts.username, Some("user".to_string()));
+        assert_eq!(opts.port, None);
+
+        let opts = SshConnectionOptions::parse_command_line("2001:db8::1")?;
+        assert_eq!(opts.host, "2001:db8::1".into());
+        assert_eq!(opts.username, None);
+        assert_eq!(opts.port, None);
+
+        let opts = SshConnectionOptions::parse_command_line("[2001:db8::1]:2222")?;
+        assert_eq!(opts.host, "2001:db8::1".into());
+        assert_eq!(opts.username, None);
+        assert_eq!(opts.port, Some(2222));
+
+        let opts = SshConnectionOptions::parse_command_line("user@example.com:2222")?;
+        assert_eq!(opts.host, "example.com".into());
+        assert_eq!(opts.username, Some("user".to_string()));
+        assert_eq!(opts.port, Some(2222));
+
+        let opts = SshConnectionOptions::parse_command_line("user@192.168.1.1:2222")?;
+        assert_eq!(opts.host, "192.168.1.1".into());
+        assert_eq!(opts.username, Some("user".to_string()));
+        assert_eq!(opts.port, Some(2222));
+
+        Ok(())
+    }
 }

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

@@ -1,5 +1,5 @@
 use crate::{
-    RemoteClientDelegate, RemotePlatform,
+    RemoteArch, RemoteClientDelegate, RemoteOs, RemotePlatform,
     remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions},
     transport::{parse_platform, parse_shell},
 };
@@ -70,7 +70,10 @@ impl WslRemoteConnection {
         let mut this = Self {
             connection_options,
             remote_binary_path: None,
-            platform: RemotePlatform { os: "", arch: "" },
+            platform: RemotePlatform {
+                os: RemoteOs::Linux,
+                arch: RemoteArch::X86_64,
+            },
             shell: String::new(),
             shell_kind: ShellKind::Posix,
             default_system_shell: String::from("/bin/sh"),

crates/remote_server/Cargo.toml 🔗

@@ -26,6 +26,7 @@ anyhow.workspace = true
 askpass.workspace = true
 clap.workspace = true
 client.workspace = true
+collections.workspace = true
 dap_adapters.workspace = true
 debug_adapter_extension.workspace = true
 env_logger.workspace = true
@@ -81,7 +82,6 @@ action_log.workspace = true
 agent = { workspace = true, features = ["test-support"] }
 client = { workspace = true, features = ["test-support"] }
 clock = { workspace = true, features = ["test-support"] }
-collections.workspace = true
 dap = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }

crates/remote_server/src/headless_project.rs 🔗

@@ -1,4 +1,5 @@
 use anyhow::{Context as _, Result, anyhow};
+use collections::HashSet;
 use language::File;
 use lsp::LanguageServerId;
 
@@ -21,6 +22,7 @@ use project::{
     project_settings::SettingsObserver,
     search::SearchQuery,
     task_store::TaskStore,
+    trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
     worktree_store::WorktreeStore,
 };
 use rpc::{
@@ -86,6 +88,7 @@ impl HeadlessProject {
             languages,
             extension_host_proxy: proxy,
         }: HeadlessAppState,
+        init_worktree_trust: bool,
         cx: &mut Context<Self>,
     ) -> Self {
         debug_adapter_extension::init(proxy.clone(), cx);
@@ -97,6 +100,16 @@ impl HeadlessProject {
             store
         });
 
+        if init_worktree_trust {
+            project::trusted_worktrees::track_worktree_trust(
+                worktree_store.clone(),
+                None::<RemoteHostLocation>,
+                Some((session.clone(), REMOTE_SERVER_PROJECT_ID)),
+                None,
+                cx,
+            );
+        }
+
         let environment =
             cx.new(|cx| ProjectEnvironment::new(None, worktree_store.downgrade(), None, true, cx));
         let manifest_tree = ManifestTree::new(worktree_store.clone(), cx);
@@ -264,6 +277,8 @@ impl HeadlessProject {
         session.add_entity_request_handler(Self::handle_get_directory_environment);
         session.add_entity_message_handler(Self::handle_toggle_lsp_logs);
         session.add_entity_request_handler(Self::handle_open_image_by_path);
+        session.add_entity_request_handler(Self::handle_trust_worktrees);
+        session.add_entity_request_handler(Self::handle_restrict_worktrees);
 
         session.add_entity_request_handler(BufferStore::handle_update_buffer);
         session.add_entity_message_handler(BufferStore::handle_close_buffer);
@@ -595,6 +610,50 @@ impl HeadlessProject {
         })
     }
 
+    pub async fn handle_trust_worktrees(
+        _: Entity<Self>,
+        envelope: TypedEnvelope<proto::TrustWorktrees>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let trusted_worktrees = cx
+            .update(|cx| TrustedWorktrees::try_get_global(cx))?
+            .context("missing trusted worktrees")?;
+        trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
+            trusted_worktrees.trust(
+                envelope
+                    .payload
+                    .trusted_paths
+                    .into_iter()
+                    .filter_map(PathTrust::from_proto)
+                    .collect(),
+                None,
+                cx,
+            );
+        })?;
+        Ok(proto::Ack {})
+    }
+
+    pub async fn handle_restrict_worktrees(
+        _: Entity<Self>,
+        envelope: TypedEnvelope<proto::RestrictWorktrees>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let trusted_worktrees = cx
+            .update(|cx| TrustedWorktrees::try_get_global(cx))?
+            .context("missing trusted worktrees")?;
+        trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
+            let restricted_paths = envelope
+                .payload
+                .worktree_ids
+                .into_iter()
+                .map(WorktreeId::from_proto)
+                .map(PathTrust::Worktree)
+                .collect::<HashSet<_>>();
+            trusted_worktrees.restrict(restricted_paths, None, cx);
+        })?;
+        Ok(proto::Ack {})
+    }
+
     pub async fn handle_open_new_buffer(
         this: Entity<Self>,
         _message: TypedEnvelope<proto::OpenNewBuffer>,

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -1933,6 +1933,7 @@ pub async fn init_test(
                 languages,
                 extension_host_proxy: proxy,
             },
+            false,
             cx,
         )
     });
@@ -1977,5 +1978,5 @@ fn build_project(ssh: Entity<RemoteClient>, cx: &mut TestAppContext) -> Entity<P
         Project::init(&client, cx);
     });
 
-    cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, cx))
+    cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, false, cx))
 }

crates/remote_server/src/unix.rs 🔗

@@ -2,6 +2,8 @@ use crate::HeadlessProject;
 use crate::headless_project::HeadlessAppState;
 use anyhow::{Context as _, Result, anyhow};
 use client::ProxySettings;
+use collections::HashMap;
+use project::trusted_worktrees;
 use util::ResultExt;
 
 use extension::ExtensionHostProxy;
@@ -417,6 +419,7 @@ pub fn execute_run(
 
         log::info!("gpui app started, initializing server");
         let session = start_server(listeners, log_rx, cx, is_wsl_interop);
+        trusted_worktrees::init(HashMap::default(), Some((session.clone(), REMOTE_SERVER_PROJECT_ID)), None, cx);
 
         GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx);
         git_hosting_providers::init(cx);
@@ -468,6 +471,7 @@ pub fn execute_run(
                     languages,
                     extension_host_proxy,
                 },
+                true,
                 cx,
             )
         });

crates/rules_library/src/rules_library.rs 🔗

@@ -3,9 +3,9 @@ use collections::{HashMap, HashSet};
 use editor::{CompletionProvider, SelectionEffects};
 use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
 use gpui::{
-    Action, App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable,
-    PromptLevel, Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle,
-    WindowOptions, actions, point, size, transparent_black,
+    App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel,
+    Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions,
+    actions, point, size, transparent_black,
 };
 use language::{Buffer, LanguageRegistry, language_settings::SoftWrap};
 use language_model::{
@@ -21,9 +21,7 @@ use std::sync::atomic::AtomicBool;
 use std::time::Duration;
 use theme::ThemeSettings;
 use title_bar::platform_title_bar::PlatformTitleBar;
-use ui::{
-    Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Render, Tooltip, prelude::*,
-};
+use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
 use util::{ResultExt, TryFutureExt};
 use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
 use zed_actions::assistant::InlineAssist;
@@ -44,15 +42,12 @@ actions!(
         /// Duplicates the selected rule.
         DuplicateRule,
         /// Toggles whether the selected rule is a default rule.
-        ToggleDefaultRule
+        ToggleDefaultRule,
+        /// Restores a built-in rule to its default content.
+        RestoreDefaultContent
     ]
 );
 
-const BUILT_IN_TOOLTIP_TEXT: &str = concat!(
-    "This rule supports special functionality.\n",
-    "It's read-only, but you can remove it from your default rules."
-);
-
 pub trait InlineAssistDelegate {
     fn assist(
         &self,
@@ -211,13 +206,8 @@ impl PickerDelegate for RulePickerDelegate {
         self.filtered_entries.len()
     }
 
-    fn no_matches_text(&self, _window: &mut Window, cx: &mut App) -> Option<SharedString> {
-        let text = if self.store.read(cx).prompt_count() == 0 {
-            "No rules.".into()
-        } else {
-            "No rules found matching your search.".into()
-        };
-        Some(text)
+    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+        Some("No rules found matching your search.".into())
     }
 
     fn selected_index(&self) -> usize {
@@ -270,23 +260,35 @@ impl PickerDelegate for RulePickerDelegate {
                 .background_spawn(async move {
                     let matches = search.await;
 
-                    let (default_rules, non_default_rules): (Vec<_>, Vec<_>) =
-                        matches.iter().partition(|rule| rule.default);
+                    let (built_in_rules, user_rules): (Vec<_>, Vec<_>) =
+                        matches.into_iter().partition(|rule| rule.id.is_built_in());
+                    let (default_rules, other_rules): (Vec<_>, Vec<_>) =
+                        user_rules.into_iter().partition(|rule| rule.default);
 
                     let mut filtered_entries = Vec::new();
 
+                    if !built_in_rules.is_empty() {
+                        filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into()));
+
+                        for rule in built_in_rules {
+                            filtered_entries.push(RulePickerEntry::Rule(rule));
+                        }
+
+                        filtered_entries.push(RulePickerEntry::Separator);
+                    }
+
                     if !default_rules.is_empty() {
                         filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
 
                         for rule in default_rules {
-                            filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
+                            filtered_entries.push(RulePickerEntry::Rule(rule));
                         }
 
                         filtered_entries.push(RulePickerEntry::Separator);
                     }
 
-                    for rule in non_default_rules {
-                        filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
+                    for rule in other_rules {
+                        filtered_entries.push(RulePickerEntry::Rule(rule));
                     }
 
                     let selected_index = prev_prompt_id
@@ -341,21 +343,27 @@ impl PickerDelegate for RulePickerDelegate {
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         match self.filtered_entries.get(ix)? {
-            RulePickerEntry::Header(title) => Some(
-                ListSubHeader::new(title.clone())
-                    .end_slot(
-                        IconButton::new("info", IconName::Info)
-                            .style(ButtonStyle::Transparent)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
-                            .tooltip(Tooltip::text(
-                                "Default Rules are attached by default with every new thread.",
-                            ))
-                            .into_any_element(),
-                    )
-                    .inset(true)
-                    .into_any_element(),
-            ),
+            RulePickerEntry::Header(title) => {
+                let tooltip_text = if title.as_ref() == "Built-in Rules" {
+                    "Built-in rules are those included out of the box with Zed."
+                } else {
+                    "Default Rules are attached by default with every new thread."
+                };
+
+                Some(
+                    ListSubHeader::new(title.clone())
+                        .end_slot(
+                            IconButton::new("info", IconName::Info)
+                                .style(ButtonStyle::Transparent)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted)
+                                .tooltip(Tooltip::text(tooltip_text))
+                                .into_any_element(),
+                        )
+                        .inset(true)
+                        .into_any_element(),
+                )
+            }
             RulePickerEntry::Separator => Some(
                 h_flex()
                     .py_1()
@@ -376,7 +384,7 @@ impl PickerDelegate for RulePickerDelegate {
                                 .truncate()
                                 .mr_10(),
                         )
-                        .end_slot::<IconButton>(default.then(|| {
+                        .end_slot::<IconButton>((default && !prompt_id.is_built_in()).then(|| {
                             IconButton::new("toggle-default-rule", IconName::Paperclip)
                                 .toggle_state(true)
                                 .icon_color(Color::Accent)
@@ -386,62 +394,52 @@ impl PickerDelegate for RulePickerDelegate {
                                     cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
                                 }))
                         }))
-                        .end_hover_slot(
-                            h_flex()
-                                .child(if prompt_id.is_built_in() {
-                                    div()
-                                        .id("built-in-rule")
-                                        .child(Icon::new(IconName::FileLock).color(Color::Muted))
-                                        .tooltip(move |_window, cx| {
-                                            Tooltip::with_meta(
-                                                "Built-in rule",
-                                                None,
-                                                BUILT_IN_TOOLTIP_TEXT,
-                                                cx,
-                                            )
-                                        })
-                                        .into_any()
-                                } else {
-                                    IconButton::new("delete-rule", IconName::Trash)
-                                        .icon_color(Color::Muted)
-                                        .icon_size(IconSize::Small)
-                                        .tooltip(Tooltip::text("Delete Rule"))
-                                        .on_click(cx.listener(move |_, _, _, cx| {
-                                            cx.emit(RulePickerEvent::Deleted { prompt_id })
-                                        }))
-                                        .into_any_element()
-                                })
-                                .child(
-                                    IconButton::new("toggle-default-rule", IconName::Plus)
-                                        .selected_icon(IconName::Dash)
-                                        .toggle_state(default)
-                                        .icon_size(IconSize::Small)
-                                        .icon_color(if default {
-                                            Color::Accent
-                                        } else {
-                                            Color::Muted
-                                        })
-                                        .map(|this| {
-                                            if default {
-                                                this.tooltip(Tooltip::text(
-                                                    "Remove from Default Rules",
-                                                ))
+                        .when(!prompt_id.is_built_in(), |this| {
+                            this.end_hover_slot(
+                                h_flex()
+                                    .child(
+                                        IconButton::new("delete-rule", IconName::Trash)
+                                            .icon_color(Color::Muted)
+                                            .icon_size(IconSize::Small)
+                                            .tooltip(Tooltip::text("Delete Rule"))
+                                            .on_click(cx.listener(move |_, _, _, cx| {
+                                                cx.emit(RulePickerEvent::Deleted { prompt_id })
+                                            })),
+                                    )
+                                    .child(
+                                        IconButton::new("toggle-default-rule", IconName::Plus)
+                                            .selected_icon(IconName::Dash)
+                                            .toggle_state(default)
+                                            .icon_size(IconSize::Small)
+                                            .icon_color(if default {
+                                                Color::Accent
                                             } else {
-                                                this.tooltip(move |_window, cx| {
-                                                    Tooltip::with_meta(
-                                                        "Add to Default Rules",
-                                                        None,
-                                                        "Always included in every thread.",
-                                                        cx,
-                                                    )
+                                                Color::Muted
+                                            })
+                                            .map(|this| {
+                                                if default {
+                                                    this.tooltip(Tooltip::text(
+                                                        "Remove from Default Rules",
+                                                    ))
+                                                } else {
+                                                    this.tooltip(move |_window, cx| {
+                                                        Tooltip::with_meta(
+                                                            "Add to Default Rules",
+                                                            None,
+                                                            "Always included in every thread.",
+                                                            cx,
+                                                        )
+                                                    })
+                                                }
+                                            })
+                                            .on_click(cx.listener(move |_, _, _, cx| {
+                                                cx.emit(RulePickerEvent::ToggledDefault {
+                                                    prompt_id,
                                                 })
-                                            }
-                                        })
-                                        .on_click(cx.listener(move |_, _, _, cx| {
-                                            cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
-                                        })),
-                                ),
-                        )
+                                            })),
+                                    ),
+                            )
+                        })
                         .into_any_element(),
                 )
             }
@@ -573,7 +571,7 @@ impl RulesLibrary {
     pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
         const SAVE_THROTTLE: Duration = Duration::from_millis(500);
 
-        if prompt_id.is_built_in() {
+        if !prompt_id.can_edit() {
             return;
         }
 
@@ -661,6 +659,33 @@ impl RulesLibrary {
         }
     }
 
+    pub fn restore_default_content_for_active_rule(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(active_rule_id) = self.active_rule_id {
+            self.restore_default_content(active_rule_id, window, cx);
+        }
+    }
+
+    pub fn restore_default_content(
+        &mut self,
+        prompt_id: PromptId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(built_in) = prompt_id.as_built_in() else {
+            return;
+        };
+
+        if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
+            rule_editor.body_editor.update(cx, |editor, cx| {
+                editor.set_text(built_in.default_content(), window, cx);
+            });
+        }
+    }
+
     pub fn toggle_default_for_rule(
         &mut self,
         prompt_id: PromptId,
@@ -690,7 +715,7 @@ impl RulesLibrary {
             if focus {
                 rule_editor
                     .body_editor
-                    .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
+                    .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx));
             }
             self.set_active_rule(Some(prompt_id), window, cx);
         } else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) {
@@ -721,7 +746,7 @@ impl RulesLibrary {
                             });
 
                             let mut editor = Editor::for_buffer(buffer, None, window, cx);
-                            if prompt_id.is_built_in() {
+                            if !prompt_id.can_edit() {
                                 editor.set_read_only(true);
                                 editor.set_show_edit_predictions(Some(false), window, cx);
                             }
@@ -733,7 +758,7 @@ impl RulesLibrary {
                             editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
                             editor.set_completion_provider(Some(make_completion_provider()));
                             if focus {
-                                window.focus(&editor.focus_handle(cx));
+                                window.focus(&editor.focus_handle(cx), cx);
                             }
                             editor
                         });
@@ -909,7 +934,7 @@ impl RulesLibrary {
         if let Some(active_rule) = self.active_rule_id {
             self.rule_editors[&active_rule]
                 .body_editor
-                .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
+                .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx));
             cx.stop_propagation();
         }
     }
@@ -968,7 +993,7 @@ impl RulesLibrary {
         if let Some(rule_id) = self.active_rule_id
             && let Some(rule_editor) = self.rule_editors.get(&rule_id)
         {
-            window.focus(&rule_editor.body_editor.focus_handle(cx));
+            window.focus(&rule_editor.body_editor.focus_handle(cx), cx);
         }
     }
 
@@ -981,7 +1006,7 @@ impl RulesLibrary {
         if let Some(rule_id) = self.active_rule_id
             && let Some(rule_editor) = self.rule_editors.get(&rule_id)
         {
-            window.focus(&rule_editor.title_editor.focus_handle(cx));
+            window.focus(&rule_editor.title_editor.focus_handle(cx), cx);
         }
     }
 
@@ -1148,30 +1173,38 @@ impl RulesLibrary {
     fn render_active_rule_editor(
         &self,
         editor: &Entity<Editor>,
+        read_only: bool,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
         let settings = ThemeSettings::get_global(cx);
+        let text_color = if read_only {
+            cx.theme().colors().text_muted
+        } else {
+            cx.theme().colors().text
+        };
 
         div()
             .w_full()
-            .on_action(cx.listener(Self::move_down_from_title))
             .pl_1()
             .border_1()
             .border_color(transparent_black())
             .rounded_sm()
-            .group_hover("active-editor-header", |this| {
-                this.border_color(cx.theme().colors().border_variant)
+            .when(!read_only, |this| {
+                this.group_hover("active-editor-header", |this| {
+                    this.border_color(cx.theme().colors().border_variant)
+                })
             })
+            .on_action(cx.listener(Self::move_down_from_title))
             .child(EditorElement::new(
                 &editor,
                 EditorStyle {
                     background: cx.theme().system().transparent,
                     local_player: cx.theme().players().local(),
                     text: TextStyle {
-                        color: cx.theme().colors().editor_foreground,
+                        color: text_color,
                         font_family: settings.ui_font.family.clone(),
                         font_features: settings.ui_font.features.clone(),
-                        font_size: HeadlineSize::Large.rems().into(),
+                        font_size: HeadlineSize::Medium.rems().into(),
                         font_weight: settings.ui_font.weight,
                         line_height: relative(settings.buffer_line_height.value()),
                         ..Default::default()
@@ -1186,6 +1219,68 @@ impl RulesLibrary {
             ))
     }
 
+    fn render_duplicate_rule_button(&self) -> impl IntoElement {
+        IconButton::new("duplicate-rule", IconName::BookCopy)
+            .tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx))
+            .on_click(|_, window, cx| {
+                window.dispatch_action(Box::new(DuplicateRule), cx);
+            })
+    }
+
+    fn render_built_in_rule_controls(&self) -> impl IntoElement {
+        h_flex()
+            .gap_1()
+            .child(self.render_duplicate_rule_button())
+            .child(
+                IconButton::new("restore-default", IconName::RotateCcw)
+                    .tooltip(move |_window, cx| {
+                        Tooltip::for_action(
+                            "Restore to Default Content",
+                            &RestoreDefaultContent,
+                            cx,
+                        )
+                    })
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(Box::new(RestoreDefaultContent), cx);
+                    }),
+            )
+    }
+
+    fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement {
+        h_flex()
+            .gap_1()
+            .child(
+                IconButton::new("toggle-default-rule", IconName::Paperclip)
+                    .toggle_state(default)
+                    .when(default, |this| this.icon_color(Color::Accent))
+                    .map(|this| {
+                        if default {
+                            this.tooltip(Tooltip::text("Remove from Default Rules"))
+                        } else {
+                            this.tooltip(move |_window, cx| {
+                                Tooltip::with_meta(
+                                    "Add to Default Rules",
+                                    None,
+                                    "Always included in every thread.",
+                                    cx,
+                                )
+                            })
+                        }
+                    })
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(Box::new(ToggleDefaultRule), cx);
+                    }),
+            )
+            .child(self.render_duplicate_rule_button())
+            .child(
+                IconButton::new("delete-rule", IconName::Trash)
+                    .tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx))
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(Box::new(DeleteRule), cx);
+                    }),
+            )
+    }
+
     fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
         div()
             .id("rule-editor")
@@ -1198,9 +1293,9 @@ impl RulesLibrary {
                 let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
                 let rule_editor = &self.rule_editors[&prompt_id];
                 let focus_handle = rule_editor.body_editor.focus_handle(cx);
-                let model = LanguageModelRegistry::read_global(cx)
-                    .default_model()
-                    .map(|default| default.model);
+                let registry = LanguageModelRegistry::read_global(cx);
+                let model = registry.default_model().map(|default| default.model);
+                let built_in = prompt_id.is_built_in();
 
                 Some(
                     v_flex()
@@ -1208,20 +1303,21 @@ impl RulesLibrary {
                         .size_full()
                         .relative()
                         .overflow_hidden()
-                        .on_click(cx.listener(move |_, _, window, _| {
-                            window.focus(&focus_handle);
+                        .on_click(cx.listener(move |_, _, window, cx| {
+                            window.focus(&focus_handle, cx);
                         }))
                         .child(
                             h_flex()
                                 .group("active-editor-header")
-                                .pt_2()
-                                .pl_1p5()
-                                .pr_2p5()
+                                .h_12()
+                                .px_2()
                                 .gap_2()
                                 .justify_between()
-                                .child(
-                                    self.render_active_rule_editor(&rule_editor.title_editor, cx),
-                                )
+                                .child(self.render_active_rule_editor(
+                                    &rule_editor.title_editor,
+                                    built_in,
+                                    cx,
+                                ))
                                 .child(
                                     h_flex()
                                         .h_full()
@@ -1258,89 +1354,15 @@ impl RulesLibrary {
                                                     .color(Color::Muted),
                                                 )
                                         }))
-                                        .child(if prompt_id.is_built_in() {
-                                            div()
-                                                .id("built-in-rule")
-                                                .child(
-                                                    Icon::new(IconName::FileLock)
-                                                        .color(Color::Muted),
-                                                )
-                                                .tooltip(move |_window, cx| {
-                                                    Tooltip::with_meta(
-                                                        "Built-in rule",
-                                                        None,
-                                                        BUILT_IN_TOOLTIP_TEXT,
-                                                        cx,
-                                                    )
-                                                })
-                                                .into_any()
-                                        } else {
-                                            IconButton::new("delete-rule", IconName::Trash)
-                                                .tooltip(move |_window, cx| {
-                                                    Tooltip::for_action(
-                                                        "Delete Rule",
-                                                        &DeleteRule,
-                                                        cx,
-                                                    )
-                                                })
-                                                .on_click(|_, window, cx| {
-                                                    window
-                                                        .dispatch_action(Box::new(DeleteRule), cx);
-                                                })
-                                                .into_any_element()
-                                        })
-                                        .child(
-                                            IconButton::new("duplicate-rule", IconName::BookCopy)
-                                                .tooltip(move |_window, cx| {
-                                                    Tooltip::for_action(
-                                                        "Duplicate Rule",
-                                                        &DuplicateRule,
-                                                        cx,
-                                                    )
-                                                })
-                                                .on_click(|_, window, cx| {
-                                                    window.dispatch_action(
-                                                        Box::new(DuplicateRule),
-                                                        cx,
-                                                    );
-                                                }),
-                                        )
-                                        .child(
-                                            IconButton::new(
-                                                "toggle-default-rule",
-                                                IconName::Paperclip,
-                                            )
-                                            .toggle_state(rule_metadata.default)
-                                            .icon_color(if rule_metadata.default {
-                                                Color::Accent
+                                        .map(|this| {
+                                            if built_in {
+                                                this.child(self.render_built_in_rule_controls())
                                             } else {
-                                                Color::Muted
-                                            })
-                                            .map(|this| {
-                                                if rule_metadata.default {
-                                                    this.tooltip(Tooltip::text(
-                                                        "Remove from Default Rules",
-                                                    ))
-                                                } else {
-                                                    this.tooltip(move |_window, cx| {
-                                                        Tooltip::with_meta(
-                                                            "Add to Default Rules",
-                                                            None,
-                                                            "Always included in every thread.",
-                                                            cx,
-                                                        )
-                                                    })
-                                                }
-                                            })
-                                            .on_click(
-                                                |_, window, cx| {
-                                                    window.dispatch_action(
-                                                        Box::new(ToggleDefaultRule),
-                                                        cx,
-                                                    );
-                                                },
-                                            ),
-                                        ),
+                                                this.child(self.render_regular_rule_controls(
+                                                    rule_metadata.default,
+                                                ))
+                                            }
+                                        }),
                                 ),
                         )
                         .child(
@@ -1385,6 +1407,9 @@ impl Render for RulesLibrary {
                 .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
                     this.toggle_default_for_active_rule(window, cx)
                 }))
+                .on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| {
+                    this.restore_default_content_for_active_rule(window, cx)
+                }))
                 .size_full()
                 .overflow_hidden()
                 .font(ui_font)
@@ -1398,31 +1423,7 @@ impl Render for RulesLibrary {
                             this.border_t_1().border_color(cx.theme().colors().border)
                         })
                         .child(self.render_rule_list(cx))
-                        .map(|el| {
-                            if self.store.read(cx).prompt_count() == 0 {
-                                el.child(
-                                    v_flex()
-                                        .h_full()
-                                        .flex_1()
-                                        .items_center()
-                                        .justify_center()
-                                        .border_l_1()
-                                        .border_color(cx.theme().colors().border)
-                                        .bg(cx.theme().colors().editor_background)
-                                        .child(
-                                            Button::new("create-rule", "New Rule")
-                                                .style(ButtonStyle::Outlined)
-                                                .key_binding(KeyBinding::for_action(&NewRule, cx))
-                                                .on_click(|_, window, cx| {
-                                                    window
-                                                        .dispatch_action(NewRule.boxed_clone(), cx)
-                                                }),
-                                        ),
-                                )
-                            } else {
-                                el.child(self.render_active_rule(cx))
-                            }
-                        }),
+                        .child(self.render_active_rule(cx)),
                 ),
             window,
             cx,

crates/schema_generator/Cargo.toml 🔗

@@ -15,4 +15,5 @@ env_logger.workspace = true
 schemars = { workspace = true, features = ["indexmap2"] }
 serde.workspace = true
 serde_json.workspace = true
+settings.workspace = true
 theme.workspace = true

crates/schema_generator/src/main.rs 🔗

@@ -1,6 +1,7 @@
 use anyhow::Result;
 use clap::{Parser, ValueEnum};
 use schemars::schema_for;
+use settings::ProjectSettingsContent;
 use theme::{IconThemeFamilyContent, ThemeFamilyContent};
 
 #[derive(Parser, Debug)]
@@ -14,6 +15,7 @@ pub struct Args {
 pub enum SchemaType {
     Theme,
     IconTheme,
+    Project,
 }
 
 fn main() -> Result<()> {
@@ -30,6 +32,10 @@ fn main() -> Result<()> {
             let schema = schema_for!(IconThemeFamilyContent);
             println!("{}", serde_json::to_string_pretty(&schema)?);
         }
+        SchemaType::Project => {
+            let schema = schema_for!(ProjectSettingsContent);
+            println!("{}", serde_json::to_string_pretty(&schema)?);
+        }
     }
 
     Ok(())

crates/search/src/buffer_search.rs 🔗

@@ -7,7 +7,6 @@ use crate::{
     search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input},
 };
 use any_vec::AnyVec;
-use anyhow::Context as _;
 use collections::HashMap;
 use editor::{
     DisplayPoint, Editor, EditorSettings, MultiBufferOffset,
@@ -108,7 +107,10 @@ pub struct BufferSearchBar {
     replacement_editor_focused: bool,
     active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
     active_match_index: Option<usize>,
-    active_searchable_item_subscription: Option<Subscription>,
+    #[cfg(target_os = "macos")]
+    active_searchable_item_subscriptions: Option<[Subscription; 2]>,
+    #[cfg(not(target_os = "macos"))]
+    active_searchable_item_subscriptions: Option<Subscription>,
     active_search: Option<Arc<SearchQuery>>,
     searchable_items_with_matches: HashMap<Box<dyn WeakSearchableItemHandle>, AnyVec<dyn Send>>,
     pending_search: Option<Task<()>>,
@@ -539,7 +541,7 @@ impl ToolbarItemView for BufferSearchBar {
         cx: &mut Context<Self>,
     ) -> ToolbarItemLocation {
         cx.notify();
-        self.active_searchable_item_subscription.take();
+        self.active_searchable_item_subscriptions.take();
         self.active_searchable_item.take();
 
         self.pending_search.take();
@@ -549,18 +551,58 @@ impl ToolbarItemView for BufferSearchBar {
         {
             let this = cx.entity().downgrade();
 
-            self.active_searchable_item_subscription =
-                Some(searchable_item_handle.subscribe_to_search_events(
-                    window,
-                    cx,
-                    Box::new(move |search_event, window, cx| {
-                        if let Some(this) = this.upgrade() {
-                            this.update(cx, |this, cx| {
-                                this.on_active_searchable_item_event(search_event, window, cx)
-                            });
+            let search_event_subscription = searchable_item_handle.subscribe_to_search_events(
+                window,
+                cx,
+                Box::new(move |search_event, window, cx| {
+                    if let Some(this) = this.upgrade() {
+                        this.update(cx, |this, cx| {
+                            this.on_active_searchable_item_event(search_event, window, cx)
+                        });
+                    }
+                }),
+            );
+
+            #[cfg(target_os = "macos")]
+            {
+                let item_focus_handle = searchable_item_handle.item_focus_handle(cx);
+
+                self.active_searchable_item_subscriptions = Some([
+                    search_event_subscription,
+                    cx.on_focus(&item_focus_handle, window, |this, window, cx| {
+                        if this.query_editor_focused || this.replacement_editor_focused {
+                            // no need to read pasteboard since focus came from toolbar
+                            return;
                         }
+
+                        cx.defer_in(window, |this, window, cx| {
+                            if let Some(item) = cx.read_from_find_pasteboard()
+                                && let Some(text) = item.text()
+                            {
+                                if this.query(cx) != text {
+                                    let search_options = item
+                                        .metadata()
+                                        .and_then(|m| m.parse().ok())
+                                        .and_then(SearchOptions::from_bits)
+                                        .unwrap_or(this.search_options);
+
+                                    drop(this.search(
+                                        &text,
+                                        Some(search_options),
+                                        true,
+                                        window,
+                                        cx,
+                                    ));
+                                }
+                            }
+                        });
                     }),
-                ));
+                ]);
+            }
+            #[cfg(not(target_os = "macos"))]
+            {
+                self.active_searchable_item_subscriptions = Some(search_event_subscription);
+            }
 
             let is_project_search = searchable_item_handle.supported_options(cx).find_in_results;
             self.active_searchable_item = Some(searchable_item_handle);
@@ -598,7 +640,7 @@ impl BufferSearchBar {
 
     pub fn register(registrar: &mut impl SearchActionsRegistrar) {
         registrar.register_handler(ForDeployed(|this, _: &FocusSearch, window, cx| {
-            this.query_editor.focus_handle(cx).focus(window);
+            this.query_editor.focus_handle(cx).focus(window, cx);
             this.select_query(window, cx);
         }));
         registrar.register_handler(ForDeployed(
@@ -714,15 +756,19 @@ impl BufferSearchBar {
                 .read(cx)
                 .as_singleton()
                 .expect("query editor should be backed by a singleton buffer");
+
             query_buffer
                 .read(cx)
                 .set_language_registry(languages.clone());
 
             cx.spawn(async move |buffer_search_bar, cx| {
+                use anyhow::Context as _;
+
                 let regex_language = languages
                     .language_for_name("regex")
                     .await
                     .context("loading regex language")?;
+
                 buffer_search_bar
                     .update(cx, |buffer_search_bar, cx| {
                         buffer_search_bar.regex_language = Some(regex_language);
@@ -740,7 +786,7 @@ impl BufferSearchBar {
             replacement_editor,
             replacement_editor_focused: false,
             active_searchable_item: None,
-            active_searchable_item_subscription: None,
+            active_searchable_item_subscriptions: None,
             active_match_index: None,
             searchable_items_with_matches: Default::default(),
             default_options: search_options,
@@ -792,7 +838,7 @@ impl BufferSearchBar {
             active_editor.toggle_filtered_search_ranges(None, window, cx);
             is_in_project_search = active_editor.supported_options(cx).find_in_results;
             let handle = active_editor.item_focus_handle(cx);
-            self.focus(&handle, window);
+            self.focus(&handle, window, cx);
         }
 
         if needs_collapse_expand && !is_in_project_search {
@@ -843,7 +889,7 @@ impl BufferSearchBar {
                     self.select_query(window, cx);
                 }
 
-                window.focus(&handle);
+                window.focus(&handle, cx);
             }
             return true;
         }
@@ -1013,7 +1059,7 @@ impl BufferSearchBar {
     }
 
     pub fn focus_replace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.focus(&self.replacement_editor.focus_handle(cx), window);
+        self.focus(&self.replacement_editor.focus_handle(cx), window, cx);
         cx.notify();
     }
 
@@ -1036,15 +1082,25 @@ impl BufferSearchBar {
             });
             self.set_search_options(options, cx);
             self.clear_matches(window, cx);
+            #[cfg(target_os = "macos")]
+            self.update_find_pasteboard(cx);
             cx.notify();
         }
         self.update_matches(!updated, add_to_history, window, cx)
     }
 
+    #[cfg(target_os = "macos")]
+    pub fn update_find_pasteboard(&mut self, cx: &mut App) {
+        cx.write_to_find_pasteboard(gpui::ClipboardItem::new_string_with_metadata(
+            self.query(cx),
+            self.search_options.bits().to_string(),
+        ));
+    }
+
     pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(active_editor) = self.active_searchable_item.as_ref() {
             let handle = active_editor.item_focus_handle(cx);
-            window.focus(&handle);
+            window.focus(&handle, cx);
         }
     }
 
@@ -1230,11 +1286,12 @@ impl BufferSearchBar {
                 cx.spawn_in(window, async move |this, cx| {
                     if search.await.is_ok() {
                         this.update_in(cx, |this, window, cx| {
-                            this.activate_current_match(window, cx)
-                        })
-                    } else {
-                        Ok(())
+                            this.activate_current_match(window, cx);
+                            #[cfg(target_os = "macos")]
+                            this.update_find_pasteboard(cx);
+                        })?;
                     }
+                    anyhow::Ok(())
                 })
                 .detach_and_log_err(cx);
             }
@@ -1434,6 +1491,7 @@ impl BufferSearchBar {
                                 .insert(active_searchable_item.downgrade(), matches);
 
                             this.update_match_index(window, cx);
+
                             if add_to_history {
                                 this.search_history
                                     .add(&mut this.search_history_cursor, query_text);
@@ -1528,7 +1586,7 @@ impl BufferSearchBar {
             Direction::Prev => (current_index - 1) % handles.len(),
         };
         let next_focus_handle = &handles[new_index];
-        self.focus(next_focus_handle, window);
+        self.focus(next_focus_handle, window, cx);
         cx.stop_propagation();
     }
 
@@ -1575,9 +1633,9 @@ impl BufferSearchBar {
         }
     }
 
-    fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window) {
+    fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window, cx: &mut App) {
         window.invalidate_character_coordinates();
-        window.focus(handle);
+        window.focus(handle, cx);
     }
 
     fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
@@ -1588,7 +1646,7 @@ impl BufferSearchBar {
             } else {
                 self.query_editor.focus_handle(cx)
             };
-            self.focus(&handle, window);
+            self.focus(&handle, window, cx);
             cx.notify();
         }
     }
@@ -2182,7 +2240,7 @@ mod tests {
             .update(cx, |_, window, cx| {
                 search_bar.update(cx, |search_bar, cx| {
                     let handle = search_bar.query_editor.focus_handle(cx);
-                    window.focus(&handle);
+                    window.focus(&handle, cx);
                     search_bar.activate_current_match(window, cx);
                 });
                 assert!(
@@ -2200,7 +2258,7 @@ mod tests {
                 search_bar.update(cx, |search_bar, cx| {
                     assert_eq!(search_bar.active_match_index, Some(0));
                     let handle = search_bar.query_editor.focus_handle(cx);
-                    window.focus(&handle);
+                    window.focus(&handle, cx);
                     search_bar.select_all_matches(&SelectAllMatches, window, cx);
                 });
                 assert!(
@@ -2253,7 +2311,7 @@ mod tests {
                         "Match index should be updated to the next one"
                     );
                     let handle = search_bar.query_editor.focus_handle(cx);
-                    window.focus(&handle);
+                    window.focus(&handle, cx);
                     search_bar.select_all_matches(&SelectAllMatches, window, cx);
                 });
             })
@@ -2319,7 +2377,7 @@ mod tests {
             .update(cx, |_, window, cx| {
                 search_bar.update(cx, |search_bar, cx| {
                     let handle = search_bar.query_editor.focus_handle(cx);
-                    window.focus(&handle);
+                    window.focus(&handle, cx);
                     search_bar.search("abas_nonexistent_match", None, true, window, cx)
                 })
             })

crates/search/src/project_search.rs 🔗

@@ -911,9 +911,9 @@ impl ProjectSearchView {
             cx.on_next_frame(window, |this, window, cx| {
                 if this.focus_handle.is_focused(window) {
                     if this.has_matches() {
-                        this.results_editor.focus_handle(cx).focus(window);
+                        this.results_editor.focus_handle(cx).focus(window, cx);
                     } else {
-                        this.query_editor.focus_handle(cx).focus(window);
+                        this.query_editor.focus_handle(cx).focus(window, cx);
                     }
                 }
             });
@@ -1410,7 +1410,7 @@ impl ProjectSearchView {
             query_editor.select_all(&SelectAll, window, cx);
         });
         let editor_handle = self.query_editor.focus_handle(cx);
-        window.focus(&editor_handle);
+        window.focus(&editor_handle, cx);
     }
 
     fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
@@ -1450,7 +1450,7 @@ impl ProjectSearchView {
             });
         });
         let results_handle = self.results_editor.focus_handle(cx);
-        window.focus(&results_handle);
+        window.focus(&results_handle, cx);
     }
 
     fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -1707,7 +1707,7 @@ impl ProjectSearchBar {
     fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(search_view) = self.active_project_search.as_ref() {
             search_view.update(cx, |search_view, cx| {
-                search_view.query_editor.focus_handle(cx).focus(window);
+                search_view.query_editor.focus_handle(cx).focus(window, cx);
             });
         }
     }
@@ -1740,7 +1740,7 @@ impl ProjectSearchBar {
                 Direction::Prev => (current_index - 1) % views.len(),
             };
             let next_focus_handle = &views[new_index];
-            window.focus(next_focus_handle);
+            window.focus(next_focus_handle, cx);
             cx.stop_propagation();
         });
     }
@@ -1789,7 +1789,7 @@ impl ProjectSearchBar {
                 } else {
                     this.query_editor.focus_handle(cx)
                 };
-                window.focus(&editor_to_focus);
+                window.focus(&editor_to_focus, cx);
                 cx.notify();
             });
         }
@@ -4339,7 +4339,7 @@ pub mod tests {
         let buffer_search_query = "search bar query";
         buffer_search_bar
             .update_in(&mut cx, |buffer_search_bar, window, cx| {
-                buffer_search_bar.focus_handle(cx).focus(window);
+                buffer_search_bar.focus_handle(cx).focus(window, cx);
                 buffer_search_bar.search(buffer_search_query, None, true, window, cx)
             })
             .await

crates/search/src/search.rs 🔗

@@ -143,7 +143,7 @@ impl SearchOption {
                 let focus_handle = focus_handle.clone();
                 button.on_click(move |_: &ClickEvent, window, cx| {
                     if !focus_handle.is_focused(window) {
-                        window.focus(&focus_handle);
+                        window.focus(&focus_handle, cx);
                     }
                     window.dispatch_action(action.boxed_clone(), cx);
                 })

crates/search/src/search_bar.rs 🔗

@@ -27,7 +27,7 @@ pub(super) fn render_action_button(
         let focus_handle = focus_handle.clone();
         move |_, window, cx| {
             if !focus_handle.is_focused(window) {
-                window.focus(&focus_handle);
+                window.focus(&focus_handle, cx);
             }
             window.dispatch_action(action.boxed_clone(), cx);
         }

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 🔗

@@ -158,6 +158,9 @@ pub struct SettingsContent {
     /// Default: false
     pub disable_ai: Option<SaturatingBool>,
 
+    /// Settings for the which-key popup.
+    pub which_key: Option<WhichKeySettingsContent>,
+
     /// Settings related to Vim mode in Zed.
     pub vim: Option<VimSettingsContent>,
 }
@@ -976,6 +979,19 @@ pub struct ReplSettingsContent {
     pub max_columns: Option<usize>,
 }
 
+/// Settings for configuring the which-key popup behaviour.
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct WhichKeySettingsContent {
+    /// Whether to show the which-key popup when holding down key combinations
+    ///
+    /// Default: false
+    pub enabled: Option<bool>,
+    /// Delay in milliseconds before showing the which-key popup.
+    ///
+    /// Default: 700
+    pub delay_ms: Option<u64>,
+}
+
 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
 /// An ExtendingVec in the settings can only accumulate new values.
 ///

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

@@ -38,6 +38,9 @@ pub struct AgentSettingsContent {
     pub default_height: Option<f32>,
     /// The default model to use when creating new chats and for other features when a specific model is not specified.
     pub default_model: Option<LanguageModelSelection>,
+    /// Favorite models to show at the top of the model selector.
+    #[serde(default)]
+    pub favorite_models: Vec<LanguageModelSelection>,
     /// Model to use for the inline assistant. Defaults to default_model when not specified.
     pub inline_assistant_model: Option<LanguageModelSelection>,
     /// Model to use for the inline assistant when streaming tools are enabled.
@@ -176,6 +179,16 @@ impl AgentSettingsContent {
     pub fn set_profile(&mut self, profile_id: Arc<str>) {
         self.default_profile = Some(profile_id);
     }
+
+    pub fn add_favorite_model(&mut self, model: LanguageModelSelection) {
+        if !self.favorite_models.contains(&model) {
+            self.favorite_models.push(model);
+        }
+    }
+
+    pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) {
+        self.favorite_models.retain(|m| m != model);
+    }
 }
 
 #[with_fallible_options]

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

@@ -363,6 +363,14 @@ pub struct LanguageSettingsContent {
     ///
     /// Default: true
     pub extend_comment_on_newline: Option<bool>,
+    /// Whether to continue markdown lists when pressing enter.
+    ///
+    /// Default: true
+    pub extend_list_on_newline: Option<bool>,
+    /// Whether to indent list items when pressing tab after a list marker.
+    ///
+    /// Default: true
+    pub indent_list_on_tab: Option<bool>,
     /// Inlay hint related settings.
     pub inlay_hints: Option<InlayHintSettingsContent>,
     /// Whether to automatically type closing characters for you. For example,

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

@@ -83,6 +83,8 @@ pub enum BedrockAuthMethodContent {
     NamedProfile,
     #[serde(rename = "sso")]
     SingleSignOn,
+    #[serde(rename = "api_key")]
+    ApiKey,
     /// IMDSv2, PodIdentity, env vars, etc.
     #[serde(rename = "default")]
     Automatic,
@@ -92,6 +94,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/project.rs 🔗

@@ -187,6 +187,12 @@ pub struct SessionSettingsContent {
     ///
     /// Default: true
     pub restore_unsaved_buffers: Option<bool>,
+    /// Whether or not to skip worktree trust checks.
+    /// When trusted, project settings are synchronized automatically,
+    /// language and MCP servers are downloaded and started automatically.
+    ///
+    /// Default: false
+    pub trust_all_worktrees: Option<bool>,
 }
 
 #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)]
@@ -282,6 +288,11 @@ impl std::fmt::Debug for ContextServerCommand {
 #[with_fallible_options]
 #[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
 pub struct GitSettings {
+    /// Whether or not to enable git integration.
+    ///
+    /// Default: true
+    #[serde(flatten)]
+    pub enabled: Option<GitEnabledSettings>,
     /// Whether or not to show the git gutter.
     ///
     /// Default: tracked_files
@@ -311,6 +322,25 @@ pub struct GitSettings {
     pub path_style: Option<GitPathStyle>,
 }
 
+#[with_fallible_options]
+#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
+#[serde(rename_all = "snake_case")]
+pub struct GitEnabledSettings {
+    pub disable_git: Option<bool>,
+    pub enable_status: Option<bool>,
+    pub enable_diff: Option<bool>,
+}
+
+impl GitEnabledSettings {
+    pub fn is_git_status_enabled(&self) -> bool {
+        !self.disable_git.unwrap_or(false) && self.enable_status.unwrap_or(true)
+    }
+
+    pub fn is_git_diff_enabled(&self) -> bool {
+        !self.disable_git.unwrap_or(false) && self.enable_diff.unwrap_or(true)
+    }
+}
+
 #[derive(
     Clone,
     Copy,

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/src/vscode_import.rs 🔗

@@ -215,6 +215,7 @@ impl VsCodeSettings {
             vim: None,
             vim_mode: None,
             workspace: self.workspace_settings_content(),
+            which_key: None,
         }
     }
 
@@ -429,6 +430,8 @@ impl VsCodeSettings {
             enable_language_server: None,
             ensure_final_newline_on_save: self.read_bool("files.insertFinalNewline"),
             extend_comment_on_newline: None,
+            extend_list_on_newline: None,
+            indent_list_on_tab: None,
             format_on_save: self.read_bool("editor.guides.formatOnSave").map(|b| {
                 if b {
                     FormatOnSave::On

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();
@@ -138,6 +138,28 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                     metadata: None,
                     files: USER,
                 }),
+                SettingsPageItem::SectionHeader("Security"),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Trust All Projects By Default",
+                    description: "When opening Zed, avoid Restricted Mode by auto-trusting all projects, enabling use of all features without having to give permission to each new project.",
+                    field: Box::new(SettingField {
+                        json_path: Some("session.trust_all_projects"),
+                        pick: |settings_content| {
+                            settings_content
+                                .session
+                                .as_ref()
+                                .and_then(|session| session.trust_all_worktrees.as_ref())
+                        },
+                        write: |settings_content, value| {
+                            settings_content
+                                .session
+                                .get_or_insert_default()
+                                .trust_all_worktrees = value;
+                        },
+                    }),
+                    metadata: None,
+                    files: USER,
+                }),
                 SettingsPageItem::SectionHeader("Workspace Restoration"),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Restore Unsaved Buffers",
@@ -1054,6 +1076,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",
@@ -1192,6 +1233,49 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                             }
                         }).collect(),
                     }),
+                    SettingsPageItem::SectionHeader("Which-key Menu"),
+                    SettingsPageItem::SettingItem(SettingItem {
+                        title: "Show Which-key Menu",
+                        description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending.",
+                        field: Box::new(SettingField {
+                            json_path: Some("which_key.enabled"),
+                            pick: |settings_content| {
+                                settings_content
+                                    .which_key
+                                    .as_ref()
+                                    .and_then(|settings| settings.enabled.as_ref())
+                            },
+                            write: |settings_content, value| {
+                                settings_content
+                                    .which_key
+                                    .get_or_insert_default()
+                                    .enabled = value;
+                            },
+                        }),
+                        metadata: None,
+                        files: USER,
+                    }),
+                    SettingsPageItem::SettingItem(SettingItem {
+                        title: "Menu Delay",
+                        description: "Delay in milliseconds before the which-key menu appears.",
+                        field: Box::new(SettingField {
+                            json_path: Some("which_key.delay_ms"),
+                            pick: |settings_content| {
+                                settings_content
+                                    .which_key
+                                    .as_ref()
+                                    .and_then(|settings| settings.delay_ms.as_ref())
+                            },
+                            write: |settings_content, value| {
+                                settings_content
+                                    .which_key
+                                    .get_or_insert_default()
+                                    .delay_ms = value;
+                            },
+                        }),
+                        metadata: None,
+                        files: USER,
+                    }),
                     SettingsPageItem::SectionHeader("Multibuffer"),
                     SettingsPageItem::SettingItem(SettingItem {
                         title: "Double Click In Multibuffer",
@@ -5435,6 +5519,102 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
         SettingsPage {
             title: "Version Control",
             items: vec![
+                SettingsPageItem::SectionHeader("Git Integration"),
+                SettingsPageItem::DynamicItem(DynamicItem {
+                    discriminant: SettingItem {
+                        files: USER,
+                        title: "Disable Git Integration",
+                        description: "Disable all Git integration features in Zed.",
+                        field: Box::new(SettingField::<bool> {
+                            json_path: Some("git.disable_git"),
+                            pick: |settings_content| {
+                                settings_content
+                                    .git
+                                    .as_ref()?
+                                    .enabled
+                                    .as_ref()?
+                                    .disable_git
+                                    .as_ref()
+                            },
+                            write: |settings_content, value| {
+                                settings_content
+                                    .git
+                                    .get_or_insert_default()
+                                    .enabled
+                                    .get_or_insert_default()
+                                    .disable_git = value;
+                            },
+                        }),
+                        metadata: None,
+                    },
+                    pick_discriminant: |settings_content| {
+                        let disabled = settings_content
+                            .git
+                            .as_ref()?
+                            .enabled
+                            .as_ref()?
+                            .disable_git
+                            .unwrap_or(false);
+                        Some(if disabled { 0 } else { 1 })
+                    },
+                    fields: vec![
+                        vec![],
+                        vec![
+                            SettingItem {
+                                files: USER,
+                                title: "Enable Git Status",
+                                description: "Show Git status information in the editor.",
+                                field: Box::new(SettingField::<bool> {
+                                    json_path: Some("git.enable_status"),
+                                    pick: |settings_content| {
+                                        settings_content
+                                            .git
+                                            .as_ref()?
+                                            .enabled
+                                            .as_ref()?
+                                            .enable_status
+                                            .as_ref()
+                                    },
+                                    write: |settings_content, value| {
+                                        settings_content
+                                            .git
+                                            .get_or_insert_default()
+                                            .enabled
+                                            .get_or_insert_default()
+                                            .enable_status = value;
+                                    },
+                                }),
+                                metadata: None,
+                            },
+                            SettingItem {
+                                files: USER,
+                                title: "Enable Git Diff",
+                                description: "Show Git diff information in the editor.",
+                                field: Box::new(SettingField::<bool> {
+                                    json_path: Some("git.enable_diff"),
+                                    pick: |settings_content| {
+                                        settings_content
+                                            .git
+                                            .as_ref()?
+                                            .enabled
+                                            .as_ref()?
+                                            .enable_diff
+                                            .as_ref()
+                                    },
+                                    write: |settings_content, value| {
+                                        settings_content
+                                            .git
+                                            .get_or_insert_default()
+                                            .enabled
+                                            .get_or_insert_default()
+                                            .enable_diff = value;
+                                    },
+                                }),
+                                metadata: None,
+                            },
+                        ],
+                    ],
+                }),
                 SettingsPageItem::SectionHeader("Git Gutter"),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Visibility",

crates/settings_ui/src/settings_ui.rs 🔗

@@ -345,8 +345,8 @@ impl NonFocusableHandle {
     fn from_handle(handle: FocusHandle, window: &mut Window, cx: &mut App) -> Entity<Self> {
         cx.new(|cx| {
             let _subscription = cx.on_focus(&handle, window, {
-                move |_, window, _| {
-                    window.focus_next();
+                move |_, window, cx| {
+                    window.focus_next(cx);
                 }
             });
             Self {
@@ -602,7 +602,7 @@ pub fn open_settings_editor(
                 focus: true,
                 show: true,
                 is_movable: true,
-                kind: gpui::WindowKind::Floating,
+                kind: gpui::WindowKind::Normal,
                 window_background: cx.theme().window_background_appearance(),
                 app_id: Some(app_id.to_owned()),
                 window_decorations: Some(window_decorations),
@@ -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)
+            }
         }
     }
 }
@@ -886,7 +890,7 @@ impl SettingsPageItem {
                             .size(ButtonSize::Medium)
                             .on_click({
                                 let sub_page_link = sub_page_link.clone();
-                                cx.listener(move |this, _, _, cx| {
+                                cx.listener(move |this, _, window, cx| {
                                     let mut section_index = item_index;
                                     let current_page = this.current_page();
 
@@ -905,7 +909,7 @@ impl SettingsPageItem {
                                         )
                                     };
 
-                                    this.push_sub_page(sub_page_link.clone(), header, cx)
+                                    this.push_sub_page(sub_page_link.clone(), header, window, cx)
                                 })
                             }),
                         )
@@ -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()
@@ -1470,7 +1537,7 @@ impl SettingsWindow {
         this.build_search_index();
 
         this.search_bar.update(cx, |editor, cx| {
-            editor.focus_handle(cx).focus(window);
+            editor.focus_handle(cx).focus(window, cx);
         });
 
         this
@@ -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);
@@ -2092,7 +2174,7 @@ impl SettingsWindow {
                     let focus_handle = focus_handle.clone();
                     move |this, _: &gpui::ClickEvent, window, cx| {
                         this.change_file(ix, window, cx);
-                        focus_handle.focus(window);
+                        focus_handle.focus(window, cx);
                     }
                 }))
             };
@@ -2169,7 +2251,7 @@ impl SettingsWindow {
                                                             this.update(cx, |this, cx| {
                                                                 this.change_file(ix, window, cx);
                                                             });
-                                                            focus_handle.focus(window);
+                                                            focus_handle.focus(window, cx);
                                                         }
                                                     },
                                                 );
@@ -2303,7 +2385,7 @@ impl SettingsWindow {
                 let focused_entry_parent = this.root_entry_containing(focused_entry);
                 if this.navbar_entries[focused_entry_parent].expanded {
                     this.toggle_navbar_entry(focused_entry_parent);
-                    window.focus(&this.navbar_entries[focused_entry_parent].focus_handle);
+                    window.focus(&this.navbar_entries[focused_entry_parent].focus_handle, cx);
                 }
                 cx.notify();
             }))
@@ -2452,6 +2534,7 @@ impl SettingsWindow {
                                                         window.focus(
                                                             &this.navbar_entries[entry_index]
                                                                 .focus_handle,
+                                                            cx,
                                                         );
                                                         cx.notify();
                                                     },
@@ -2576,7 +2659,7 @@ impl SettingsWindow {
         // back to back.
         cx.on_next_frame(window, move |_, window, cx| {
             if let Some(handle) = handle_to_focus.as_ref() {
-                window.focus(handle);
+                window.focus(handle, cx);
             }
 
             cx.on_next_frame(window, |_, _, cx| {
@@ -2643,7 +2726,7 @@ impl SettingsWindow {
         };
         self.navbar_scroll_handle
             .scroll_to_item(position, gpui::ScrollStrategy::Top);
-        window.focus(&self.navbar_entries[nav_entry_index].focus_handle);
+        window.focus(&self.navbar_entries[nav_entry_index].focus_handle, cx);
         cx.notify();
     }
 
@@ -2913,8 +2996,8 @@ impl SettingsWindow {
                             IconButton::new("back-btn", IconName::ArrowLeft)
                                 .icon_size(IconSize::Small)
                                 .shape(IconButtonShape::Square)
-                                .on_click(cx.listener(|this, _, _, cx| {
-                                    this.pop_sub_page(cx);
+                                .on_click(cx.listener(|this, _, window, cx| {
+                                    this.pop_sub_page(window, cx);
                                 })),
                         )
                         .child(self.render_sub_page_breadcrumbs()),
@@ -3018,7 +3101,7 @@ impl SettingsWindow {
             .id("settings-ui-page")
             .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
                 if !sub_page_stack().is_empty() {
-                    window.focus_next();
+                    window.focus_next(cx);
                     return;
                 }
                 for (logical_index, (actual_index, _)) in this.visible_page_items().enumerate() {
@@ -3038,7 +3121,7 @@ impl SettingsWindow {
                         cx.on_next_frame(window, |_, window, cx| {
                             cx.notify();
                             cx.on_next_frame(window, |_, window, cx| {
-                                window.focus_next();
+                                window.focus_next(cx);
                                 cx.notify();
                             });
                         });
@@ -3046,11 +3129,11 @@ impl SettingsWindow {
                         return;
                     }
                 }
-                window.focus_next();
+                window.focus_next(cx);
             }))
             .on_action(cx.listener(|this, _: &menu::SelectPrevious, window, cx| {
                 if !sub_page_stack().is_empty() {
-                    window.focus_prev();
+                    window.focus_prev(cx);
                     return;
                 }
                 let mut prev_was_header = false;
@@ -3070,7 +3153,7 @@ impl SettingsWindow {
                         cx.on_next_frame(window, |_, window, cx| {
                             cx.notify();
                             cx.on_next_frame(window, |_, window, cx| {
-                                window.focus_prev();
+                                window.focus_prev(cx);
                                 cx.notify();
                             });
                         });
@@ -3079,7 +3162,7 @@ impl SettingsWindow {
                     }
                     prev_was_header = is_header;
                 }
-                window.focus_prev();
+                window.focus_prev(cx);
             }))
             .when(sub_page_stack().is_empty(), |this| {
                 this.vertical_scrollbar_for(&self.list_state, window, cx)
@@ -3273,23 +3356,28 @@ impl SettingsWindow {
         &mut self,
         sub_page_link: SubPageLink,
         section_header: &'static str,
+        window: &mut Window,
         cx: &mut Context<SettingsWindow>,
     ) {
         sub_page_stack_mut().push(SubPage {
             link: sub_page_link,
             section_header,
         });
+        self.sub_page_scroll_handle
+            .set_offset(point(px(0.), px(0.)));
+        self.content_focus_handle.focus_handle(cx).focus(window, cx);
         cx.notify();
     }
 
-    fn pop_sub_page(&mut self, cx: &mut Context<SettingsWindow>) {
+    fn pop_sub_page(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
         sub_page_stack_mut().pop();
+        self.content_focus_handle.focus_handle(cx).focus(window, cx);
         cx.notify();
     }
 
-    fn focus_file_at_index(&mut self, index: usize, window: &mut Window) {
+    fn focus_file_at_index(&mut self, index: usize, window: &mut Window, cx: &mut App) {
         if let Some((_, handle)) = self.files.get(index) {
-            handle.focus(window);
+            handle.focus(window, cx);
         }
     }
 
@@ -3369,7 +3457,7 @@ impl Render for SettingsWindow {
                             window.minimize_window();
                         })
                         .on_action(cx.listener(|this, _: &search::FocusSearch, window, cx| {
-                            this.search_bar.focus_handle(cx).focus(window);
+                            this.search_bar.focus_handle(cx).focus(window, cx);
                         }))
                         .on_action(cx.listener(|this, _: &ToggleFocusNav, window, cx| {
                             if this
@@ -3389,8 +3477,8 @@ impl Render for SettingsWindow {
                             }
                         }))
                         .on_action(cx.listener(
-                            |this, FocusFile(file_index): &FocusFile, window, _| {
-                                this.focus_file_at_index(*file_index as usize, window);
+                            |this, FocusFile(file_index): &FocusFile, window, cx| {
+                                this.focus_file_at_index(*file_index as usize, window, cx);
                             },
                         ))
                         .on_action(cx.listener(|this, _: &FocusNextFile, window, cx| {
@@ -3398,11 +3486,11 @@ impl Render for SettingsWindow {
                                 this.focused_file_index(window, cx) + 1,
                                 this.files.len().saturating_sub(1),
                             );
-                            this.focus_file_at_index(next_index, window);
+                            this.focus_file_at_index(next_index, window, cx);
                         }))
                         .on_action(cx.listener(|this, _: &FocusPreviousFile, window, cx| {
                             let prev_index = this.focused_file_index(window, cx).saturating_sub(1);
-                            this.focus_file_at_index(prev_index, window);
+                            this.focus_file_at_index(prev_index, window, cx);
                         }))
                         .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
                             if this
@@ -3412,11 +3500,11 @@ impl Render for SettingsWindow {
                             {
                                 this.focus_and_scroll_to_first_visible_nav_entry(window, cx);
                             } else {
-                                window.focus_next();
+                                window.focus_next(cx);
                             }
                         }))
-                        .on_action(|_: &menu::SelectPrevious, window, _| {
-                            window.focus_prev();
+                        .on_action(|_: &menu::SelectPrevious, window, cx| {
+                            window.focus_prev(cx);
                         })
                         .flex()
                         .flex_row()

crates/supermaven/src/supermaven_edit_prediction_delegate.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{Supermaven, SupermavenCompletionStateId};
 use anyhow::Result;
-use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate};
+use edit_prediction_types::{EditPrediction, EditPredictionDelegate};
 use futures::StreamExt as _;
 use gpui::{App, Context, Entity, EntityId, Task};
 use language::{Anchor, Buffer, BufferSnapshot};
@@ -189,15 +189,6 @@ impl EditPredictionDelegate for SupermavenEditPredictionDelegate {
         }));
     }
 
-    fn cycle(
-        &mut self,
-        _buffer: Entity<Buffer>,
-        _cursor_position: Anchor,
-        _direction: Direction,
-        _cx: &mut Context<Self>,
-    ) {
-    }
-
     fn accept(&mut self, _cx: &mut Context<Self>) {
         reset_completion_cache(self, _cx);
     }

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 🔗

@@ -155,8 +155,8 @@ enum InternalEvent {
     ScrollToAlacPoint(AlacPoint),
     SetSelection(Option<(Selection, AlacPoint)>),
     UpdateSelection(Point<Pixels>),
-    // Adjusted mouse position, should open
     FindHyperlink(Point<Pixels>, bool),
+    ProcessHyperlink((String, bool, Match), bool),
     // Whether keep selection when copy
     Copy(Option<bool>),
     // Vi mode events
@@ -380,6 +380,7 @@ impl TerminalBuilder {
             is_remote_terminal: false,
             last_mouse_move_time: Instant::now(),
             last_hyperlink_search_position: None,
+            mouse_down_hyperlink: None,
             #[cfg(windows)]
             shell_program: None,
             activation_script: Vec::new(),
@@ -610,6 +611,7 @@ impl TerminalBuilder {
                 is_remote_terminal,
                 last_mouse_move_time: Instant::now(),
                 last_hyperlink_search_position: None,
+                mouse_down_hyperlink: None,
                 #[cfg(windows)]
                 shell_program,
                 activation_script: activation_script.clone(),
@@ -840,6 +842,7 @@ pub struct Terminal {
     is_remote_terminal: bool,
     last_mouse_move_time: Instant,
     last_hyperlink_search_position: Option<Point<Pixels>>,
+    mouse_down_hyperlink: Option<(String, bool, Match)>,
     #[cfg(windows)]
     shell_program: Option<String>,
     template: CopyTemplate,
@@ -892,6 +895,8 @@ impl TaskStatus {
     }
 }
 
+const FIND_HYPERLINK_THROTTLE_PX: Pixels = px(5.0);
+
 impl Terminal {
     fn process_event(&mut self, event: AlacTermEvent, cx: &mut Context<Self>) {
         match event {
@@ -1150,7 +1155,6 @@ impl Terminal {
             }
             InternalEvent::FindHyperlink(position, open) => {
                 trace!("Finding hyperlink at position: position={position:?}, open={open:?}");
-                let prev_hovered_word = self.last_content.last_hovered_word.take();
 
                 let point = grid_point(
                     *position,
@@ -1164,47 +1168,53 @@ impl Terminal {
                     point,
                     &mut self.hyperlink_regex_searches,
                 ) {
-                    Some((maybe_url_or_path, is_url, url_match)) => {
-                        let target = if is_url {
-                            // Treat "file://" URLs like file paths to ensure
-                            // that line numbers at the end of the path are
-                            // handled correctly.
-                            // file://{path} should be urldecoded, returning a urldecoded {path}
-                            if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
-                                let decoded_path = urlencoding::decode(path)
-                                    .map(|decoded| decoded.into_owned())
-                                    .unwrap_or(path.to_owned());
-
-                                MaybeNavigationTarget::PathLike(PathLikeTarget {
-                                    maybe_path: decoded_path,
-                                    terminal_dir: self.working_directory(),
-                                })
-                            } else {
-                                MaybeNavigationTarget::Url(maybe_url_or_path.clone())
-                            }
-                        } else {
-                            MaybeNavigationTarget::PathLike(PathLikeTarget {
-                                maybe_path: maybe_url_or_path.clone(),
-                                terminal_dir: self.working_directory(),
-                            })
-                        };
-                        if *open {
-                            cx.emit(Event::Open(target));
-                        } else {
-                            self.update_selected_word(
-                                prev_hovered_word,
-                                url_match,
-                                maybe_url_or_path,
-                                target,
-                                cx,
-                            );
-                        }
+                    Some(hyperlink) => {
+                        self.process_hyperlink(hyperlink, *open, cx);
                     }
                     None => {
                         cx.emit(Event::NewNavigationTarget(None));
                     }
                 }
             }
+            InternalEvent::ProcessHyperlink(hyperlink, open) => {
+                self.process_hyperlink(hyperlink.clone(), *open, cx);
+            }
+        }
+    }
+
+    fn process_hyperlink(
+        &mut self,
+        hyperlink: (String, bool, Match),
+        open: bool,
+        cx: &mut Context<Self>,
+    ) {
+        let (maybe_url_or_path, is_url, url_match) = hyperlink;
+        let prev_hovered_word = self.last_content.last_hovered_word.take();
+
+        let target = if is_url {
+            if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
+                let decoded_path = urlencoding::decode(path)
+                    .map(|decoded| decoded.into_owned())
+                    .unwrap_or(path.to_owned());
+
+                MaybeNavigationTarget::PathLike(PathLikeTarget {
+                    maybe_path: decoded_path,
+                    terminal_dir: self.working_directory(),
+                })
+            } else {
+                MaybeNavigationTarget::Url(maybe_url_or_path.clone())
+            }
+        } else {
+            MaybeNavigationTarget::PathLike(PathLikeTarget {
+                maybe_path: maybe_url_or_path.clone(),
+                terminal_dir: self.working_directory(),
+            })
+        };
+
+        if open {
+            cx.emit(Event::Open(target));
+        } else {
+            self.update_selected_word(prev_hovered_word, url_match, maybe_url_or_path, target, cx);
         }
     }
 
@@ -1718,38 +1728,40 @@ impl Terminal {
             {
                 self.write_to_pty(bytes);
             }
-        } else if e.modifiers.secondary() {
-            self.word_from_position(e.position);
+        } else {
+            self.schedule_find_hyperlink(e.modifiers, e.position);
         }
         cx.notify();
     }
 
-    fn word_from_position(&mut self, position: Point<Pixels>) {
-        if self.selection_phase == SelectionPhase::Selecting {
+    fn schedule_find_hyperlink(&mut self, modifiers: Modifiers, position: Point<Pixels>) {
+        if self.selection_phase == SelectionPhase::Selecting
+            || !modifiers.secondary()
+            || !self.last_content.terminal_bounds.bounds.contains(&position)
+        {
             self.last_content.last_hovered_word = None;
-        } else if self.last_content.terminal_bounds.bounds.contains(&position) {
-            // Throttle hyperlink searches to avoid excessive processing
-            let now = Instant::now();
-            let should_search = if let Some(last_pos) = self.last_hyperlink_search_position {
+            return;
+        }
+
+        // Throttle hyperlink searches to avoid excessive processing
+        let now = Instant::now();
+        if self
+            .last_hyperlink_search_position
+            .map_or(true, |last_pos| {
                 // Only search if mouse moved significantly or enough time passed
-                let distance_moved =
-                    ((position.x - last_pos.x).abs() + (position.y - last_pos.y).abs()) > px(5.0);
+                let distance_moved = ((position.x - last_pos.x).abs()
+                    + (position.y - last_pos.y).abs())
+                    > FIND_HYPERLINK_THROTTLE_PX;
                 let time_elapsed = now.duration_since(self.last_mouse_move_time).as_millis() > 100;
                 distance_moved || time_elapsed
-            } else {
-                true
-            };
-
-            if should_search {
-                self.last_mouse_move_time = now;
-                self.last_hyperlink_search_position = Some(position);
-                self.events.push_back(InternalEvent::FindHyperlink(
-                    position - self.last_content.terminal_bounds.bounds.origin,
-                    false,
-                ));
-            }
-        } else {
-            self.last_content.last_hovered_word = None;
+            })
+        {
+            self.last_mouse_move_time = now;
+            self.last_hyperlink_search_position = Some(position);
+            self.events.push_back(InternalEvent::FindHyperlink(
+                position - self.last_content.terminal_bounds.bounds.origin,
+                false,
+            ));
         }
     }
 
@@ -1773,6 +1785,20 @@ impl Terminal {
     ) {
         let position = e.position - self.last_content.terminal_bounds.bounds.origin;
         if !self.mouse_mode(e.modifiers.shift) {
+            if let Some((.., hyperlink_range)) = &self.mouse_down_hyperlink {
+                let point = grid_point(
+                    position,
+                    self.last_content.terminal_bounds,
+                    self.last_content.display_offset,
+                );
+
+                if !hyperlink_range.contains(&point) {
+                    self.mouse_down_hyperlink = None;
+                } else {
+                    return;
+                }
+            }
+
             self.selection_phase = SelectionPhase::Selecting;
             // Alacritty has the same ordering, of first updating the selection
             // then scrolling 15ms later
@@ -1819,6 +1845,23 @@ impl Terminal {
             self.last_content.display_offset,
         );
 
+        if e.button == MouseButton::Left
+            && e.modifiers.secondary()
+            && !self.mouse_mode(e.modifiers.shift)
+        {
+            let term_lock = self.term.lock();
+            self.mouse_down_hyperlink = terminal_hyperlinks::find_from_grid_point(
+                &term_lock,
+                point,
+                &mut self.hyperlink_regex_searches,
+            );
+            drop(term_lock);
+
+            if self.mouse_down_hyperlink.is_some() {
+                return;
+            }
+        }
+
         if self.mouse_mode(e.modifiers.shift) {
             if let Some(bytes) =
                 mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode)
@@ -1889,6 +1932,31 @@ impl Terminal {
                 self.copy(Some(true));
             }
 
+            if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() {
+                let point = grid_point(
+                    position,
+                    self.last_content.terminal_bounds,
+                    self.last_content.display_offset,
+                );
+
+                if let Some(mouse_up_hyperlink) = {
+                    let term_lock = self.term.lock();
+                    terminal_hyperlinks::find_from_grid_point(
+                        &term_lock,
+                        point,
+                        &mut self.hyperlink_regex_searches,
+                    )
+                } {
+                    if mouse_down_hyperlink == mouse_up_hyperlink {
+                        self.events
+                            .push_back(InternalEvent::ProcessHyperlink(mouse_up_hyperlink, true));
+                        self.selection_phase = SelectionPhase::Ended;
+                        self.last_mouse = None;
+                        return;
+                    }
+                }
+            }
+
             //Hyperlinks
             if self.selection_phase == SelectionPhase::Ended {
                 let mouse_cell_index =
@@ -1941,7 +2009,7 @@ impl Terminal {
     }
 
     fn refresh_hovered_word(&mut self, window: &Window) {
-        self.word_from_position(window.mouse_position());
+        self.schedule_find_hyperlink(window.modifiers(), window.mouse_position());
     }
 
     fn determine_scroll_lines(
@@ -2405,10 +2473,91 @@ mod tests {
         term::cell::Cell,
     };
     use collections::HashMap;
-    use gpui::{Pixels, Point, TestAppContext, bounds, point, size, smol_timeout};
+    use gpui::{
+        Entity, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
+        Point, TestAppContext, bounds, point, size, smol_timeout,
+    };
     use rand::{Rng, distr, rngs::ThreadRng};
     use task::ShellBuilder;
 
+    fn init_ctrl_click_hyperlink_test(cx: &mut TestAppContext, output: &[u8]) -> Entity<Terminal> {
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+
+        let terminal = cx.new(|cx| {
+            TerminalBuilder::new_display_only(CursorShape::default(), AlternateScroll::On, None, 0)
+                .unwrap()
+                .subscribe(cx)
+        });
+
+        terminal.update(cx, |terminal, cx| {
+            terminal.write_output(output, cx);
+        });
+
+        cx.run_until_parked();
+
+        terminal.update(cx, |terminal, _cx| {
+            let term_lock = terminal.term.lock();
+            terminal.last_content = Terminal::make_content(&term_lock, &terminal.last_content);
+            drop(term_lock);
+
+            let terminal_bounds = TerminalBounds::new(
+                px(20.0),
+                px(10.0),
+                bounds(point(px(0.0), px(0.0)), size(px(400.0), px(400.0))),
+            );
+            terminal.last_content.terminal_bounds = terminal_bounds;
+            terminal.events.clear();
+        });
+
+        terminal
+    }
+
+    fn ctrl_mouse_down_at(
+        terminal: &mut Terminal,
+        position: Point<Pixels>,
+        cx: &mut Context<Terminal>,
+    ) {
+        let mouse_down = MouseDownEvent {
+            button: MouseButton::Left,
+            position,
+            modifiers: Modifiers::secondary_key(),
+            click_count: 1,
+            first_mouse: true,
+        };
+        terminal.mouse_down(&mouse_down, cx);
+    }
+
+    fn ctrl_mouse_move_to(
+        terminal: &mut Terminal,
+        position: Point<Pixels>,
+        cx: &mut Context<Terminal>,
+    ) {
+        let terminal_bounds = terminal.last_content.terminal_bounds.bounds;
+        let drag_event = MouseMoveEvent {
+            position,
+            pressed_button: Some(MouseButton::Left),
+            modifiers: Modifiers::secondary_key(),
+        };
+        terminal.mouse_drag(&drag_event, terminal_bounds, cx);
+    }
+
+    fn ctrl_mouse_up_at(
+        terminal: &mut Terminal,
+        position: Point<Pixels>,
+        cx: &mut Context<Terminal>,
+    ) {
+        let mouse_up = MouseUpEvent {
+            button: MouseButton::Left,
+            position,
+            modifiers: Modifiers::secondary_key(),
+            click_count: 1,
+        };
+        terminal.mouse_up(&mouse_up, cx);
+    }
+
     #[gpui::test]
     async fn test_basic_terminal(cx: &mut TestAppContext) {
         cx.executor().allow_parking();
@@ -2858,4 +3007,168 @@ mod tests {
             text
         );
     }
+
+    #[gpui::test]
+    async fn test_hyperlink_ctrl_click_same_position(cx: &mut TestAppContext) {
+        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
+
+        terminal.update(cx, |terminal, cx| {
+            let click_position = point(px(80.0), px(10.0));
+            ctrl_mouse_down_at(terminal, click_position, cx);
+            ctrl_mouse_up_at(terminal, click_position, cx);
+
+            assert!(
+                terminal
+                    .events
+                    .iter()
+                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))),
+                "Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_hyperlink_ctrl_click_drag_outside_bounds(cx: &mut TestAppContext) {
+        let terminal = init_ctrl_click_hyperlink_test(
+            cx,
+            b"Visit https://zed.dev/ for more\r\nThis is another line\r\n",
+        );
+
+        terminal.update(cx, |terminal, cx| {
+            let down_position = point(px(80.0), px(10.0));
+            let up_position = point(px(10.0), px(50.0));
+
+            ctrl_mouse_down_at(terminal, down_position, cx);
+            ctrl_mouse_move_to(terminal, up_position, cx);
+            ctrl_mouse_up_at(terminal, up_position, cx);
+
+            assert!(
+                !terminal
+                    .events
+                    .iter()
+                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
+                "Should NOT have ProcessHyperlink event when dragging outside the hyperlink"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_hyperlink_ctrl_click_drag_within_bounds(cx: &mut TestAppContext) {
+        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
+
+        terminal.update(cx, |terminal, cx| {
+            let down_position = point(px(70.0), px(10.0));
+            let up_position = point(px(130.0), px(10.0));
+
+            ctrl_mouse_down_at(terminal, down_position, cx);
+            ctrl_mouse_move_to(terminal, up_position, cx);
+            ctrl_mouse_up_at(terminal, up_position, cx);
+
+            assert!(
+                terminal
+                    .events
+                    .iter()
+                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))),
+                "Should have ProcessHyperlink event when dragging within hyperlink bounds"
+            );
+        });
+    }
+
+    mod perf {
+        use super::super::*;
+        use gpui::{
+            Entity, Point, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualContext,
+            VisualTestContext, point,
+        };
+        use util::default;
+        use util_macros::perf;
+
+        async fn init_scroll_perf_test(
+            cx: &mut TestAppContext,
+        ) -> (Entity<Terminal>, &mut VisualTestContext) {
+            cx.update(|cx| {
+                let settings_store = settings::SettingsStore::test(cx);
+                cx.set_global(settings_store);
+            });
+
+            cx.executor().allow_parking();
+
+            let window = cx.add_empty_window();
+            let builder = window
+                .update(|window, cx| {
+                    let settings = TerminalSettings::get_global(cx);
+                    let test_path_hyperlink_timeout_ms = 100;
+                    TerminalBuilder::new(
+                        None,
+                        None,
+                        task::Shell::System,
+                        HashMap::default(),
+                        CursorShape::default(),
+                        AlternateScroll::On,
+                        None,
+                        settings.path_hyperlink_regexes.clone(),
+                        test_path_hyperlink_timeout_ms,
+                        false,
+                        window.window_handle().window_id().as_u64(),
+                        None,
+                        cx,
+                        vec![],
+                    )
+                })
+                .await
+                .unwrap();
+            let terminal = window.new(|cx| builder.subscribe(cx));
+
+            terminal.update(window, |term, cx| {
+                term.write_output("long line ".repeat(1000).as_bytes(), cx);
+            });
+
+            (terminal, window)
+        }
+
+        #[perf]
+        #[gpui::test]
+        async fn scroll_long_line_benchmark(cx: &mut TestAppContext) {
+            let (terminal, window) = init_scroll_perf_test(cx).await;
+            let wobble = point(FIND_HYPERLINK_THROTTLE_PX, px(0.0));
+            let mut scroll_by = |lines: i32| {
+                window.update_window_entity(&terminal, |terminal, window, cx| {
+                    let bounds = terminal.last_content.terminal_bounds.bounds;
+                    let center = bounds.origin + bounds.center();
+                    let position = center + wobble * lines as f32;
+
+                    terminal.mouse_move(
+                        &MouseMoveEvent {
+                            position,
+                            ..default()
+                        },
+                        cx,
+                    );
+
+                    terminal.scroll_wheel(
+                        &ScrollWheelEvent {
+                            position,
+                            delta: ScrollDelta::Lines(Point::new(0.0, lines as f32)),
+                            ..default()
+                        },
+                        1.0,
+                    );
+
+                    assert!(
+                        terminal
+                            .events
+                            .iter()
+                            .any(|event| matches!(event, InternalEvent::Scroll(_))),
+                        "Should have Scroll event when scrolling within terminal bounds"
+                    );
+                    terminal.sync(window, cx);
+                });
+            };
+
+            for _ in 0..20000 {
+                scroll_by(1);
+                scroll_by(-1);
+            }
+        }
+    }
 }

crates/terminal/src/terminal_hyperlinks.rs 🔗

@@ -11,6 +11,7 @@ use alacritty_terminal::{
 use log::{info, warn};
 use regex::Regex;
 use std::{
+    iter::{once, once_with},
     ops::{Index, Range},
     time::{Duration, Instant},
 };
@@ -160,8 +161,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 +171,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;
         }
     }
 
@@ -238,14 +233,17 @@ fn path_match<T>(
         (line_end.line.0 - line_start.line.0 + 1) as usize * term.grid().columns(),
     );
     let first_cell = &term.grid()[line_start];
+    let mut prev_len = 0;
     line.push(first_cell.c);
-    let mut start_offset = 0;
+    let mut prev_char_is_space = first_cell.c == ' ';
     let mut hovered_point_byte_offset = None;
+    let mut hovered_word_start_offset = None;
+    let mut hovered_word_end_offset = None;
 
-    if !first_cell.flags.intersects(WIDE_CHAR_SPACERS) {
-        start_offset += first_cell.c.len_utf8();
-        if line_start == hovered {
-            hovered_point_byte_offset = Some(0);
+    if line_start == hovered {
+        hovered_point_byte_offset = Some(0);
+        if first_cell.c != ' ' {
+            hovered_word_start_offset = Some(0);
         }
     }
 
@@ -253,27 +251,44 @@ fn path_match<T>(
         if cell.point > line_end {
             break;
         }
-        let is_spacer = cell.flags.intersects(WIDE_CHAR_SPACERS);
-        if cell.point == hovered {
-            debug_assert!(hovered_point_byte_offset.is_none());
-            if start_offset > 0 && cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
-                // If we hovered on a trailing spacer, back up to the end of the previous char's bytes.
-                start_offset -= 1;
+
+        if !cell.flags.intersects(WIDE_CHAR_SPACERS) {
+            prev_len = line.len();
+            match cell.c {
+                ' ' | '\t' => {
+                    if hovered_point_byte_offset.is_some() && !prev_char_is_space {
+                        if hovered_word_end_offset.is_none() {
+                            hovered_word_end_offset = Some(line.len());
+                        }
+                    }
+                    line.push(' ');
+                    prev_char_is_space = true;
+                }
+                c @ _ => {
+                    if hovered_point_byte_offset.is_none() && prev_char_is_space {
+                        hovered_word_start_offset = Some(line.len());
+                    }
+                    line.push(c);
+                    prev_char_is_space = false;
+                }
             }
-            hovered_point_byte_offset = Some(start_offset);
-        } else if cell.point < hovered && !is_spacer {
-            start_offset += cell.c.len_utf8();
         }
 
-        if !is_spacer {
-            line.push(match cell.c {
-                '\t' => ' ',
-                c @ _ => c,
-            });
+        if cell.point == hovered {
+            debug_assert!(hovered_point_byte_offset.is_none());
+            hovered_point_byte_offset = Some(prev_len);
         }
     }
     let line = line.trim_ascii_end();
     let hovered_point_byte_offset = hovered_point_byte_offset?;
+    let hovered_word_range = {
+        let word_start_offset = hovered_word_start_offset.unwrap_or(0);
+        (word_start_offset != 0)
+            .then_some(word_start_offset..hovered_word_end_offset.unwrap_or(line.len()))
+    };
+    if line.len() <= hovered_point_byte_offset {
+        return None;
+    }
     let found_from_range = |path_range: Range<usize>,
                             link_range: Range<usize>,
                             position: Option<(u32, Option<u32>)>| {
@@ -319,10 +334,27 @@ fn path_match<T>(
     for regex in path_hyperlink_regexes {
         let mut path_found = false;
 
-        for captures in regex.captures_iter(&line) {
+        for (line_start_offset, captures) in once(
+            regex
+                .captures_iter(&line)
+                .next()
+                .map(|captures| (0, captures)),
+        )
+        .chain(once_with(|| {
+            if let Some(hovered_word_range) = &hovered_word_range {
+                regex
+                    .captures_iter(&line[hovered_word_range.clone()])
+                    .next()
+                    .map(|captures| (hovered_word_range.start, captures))
+            } else {
+                None
+            }
+        }))
+        .flatten()
+        {
             path_found = true;
             let match_range = captures.get(0).unwrap().range();
-            let (path_range, line_column) = if let Some(path) = captures.name("path") {
+            let (mut path_range, line_column) = if let Some(path) = captures.name("path") {
                 let parse = |name: &str| {
                     captures
                         .name(name)
@@ -336,10 +368,15 @@ fn path_match<T>(
             } else {
                 (match_range.clone(), None)
             };
-            let link_range = captures
+            let mut link_range = captures
                 .name("link")
                 .map_or_else(|| match_range.clone(), |link| link.range());
 
+            path_range.start += line_start_offset;
+            path_range.end += line_start_offset;
+            link_range.start += line_start_offset;
+            link_range.end += line_start_offset;
+
             if !link_range.contains(&hovered_point_byte_offset) {
                 // No match, just skip.
                 continue;
@@ -413,6 +450,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 +482,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 +505,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 +546,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);
         }
@@ -620,9 +681,6 @@ mod tests {
             test_path!(
                 "‹«🦀 multiple_👉same_line 🦀» 🚣«4» 🏛️«2»›: 🦀 multiple_same_line 🦀 🚣4 🏛️2:"
             );
-            test_path!(
-                "🦀 multiple_same_line 🦀 🚣4 🏛️2 ‹«🦀 multiple_👉same_line 🦀» 🚣«4» 🏛️«2»›:"
-            );
 
             // ls output (tab separated)
             test_path!(
@@ -959,7 +1017,7 @@ mod tests {
             use crate::TerminalSettings;
             use alacritty_terminal::{
                 event::VoidListener,
-                grid::Dimensions,
+                grid::Scroll,
                 index::{Column, Point as AlacPoint},
                 term::test::mock_term,
                 term::{Term, search::Match},
@@ -968,14 +1026,20 @@ mod tests {
             use std::{cell::RefCell, rc::Rc};
             use util_macros::perf;
 
-            fn build_test_term(line: &str) -> (Term<VoidListener>, AlacPoint) {
-                let content = line.repeat(500);
-                let term = mock_term(&content);
-                let point = AlacPoint::new(
-                    term.grid().bottommost_line() - 1,
-                    Column(term.grid().last_column().0 / 2),
-                );
-
+            fn build_test_term(
+                line: &str,
+                repeat: usize,
+                hover_offset_column: usize,
+            ) -> (Term<VoidListener>, AlacPoint) {
+                let content = line.repeat(repeat);
+                let mut term = mock_term(&content);
+                term.resize(TermSize {
+                    columns: 1024,
+                    screen_lines: 10,
+                });
+                term.scroll_display(Scroll::Top);
+                let point =
+                    AlacPoint::new(Line(term.topmost_line().0 + 3), Column(hover_offset_column));
                 (term, point)
             }
 
@@ -984,11 +1048,14 @@ mod tests {
                 const LINE: &str = "    Compiling terminal v0.1.0 (/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal)\r\n";
                 thread_local! {
                     static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
-                        build_test_term(LINE);
+                        build_test_term(LINE, 500, 50);
                 }
                 TEST_TERM_AND_POINT.with(|(term, point)| {
-                    assert!(
-                        find_from_grid_point_bench(term, *point).is_some(),
+                    assert_eq!(
+                        find_from_grid_point_bench(term, *point)
+                            .map(|(path, ..)| path)
+                            .unwrap_or_default(),
+                        "/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal",
                         "Hyperlink should have been found"
                     );
                 });
@@ -999,11 +1066,14 @@ mod tests {
                 const LINE: &str = "    --> /Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42\r\n";
                 thread_local! {
                     static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
-                        build_test_term(LINE);
+                        build_test_term(LINE, 500, 50);
                 }
                 TEST_TERM_AND_POINT.with(|(term, point)| {
-                    assert!(
-                        find_from_grid_point_bench(term, *point).is_some(),
+                    assert_eq!(
+                        find_from_grid_point_bench(term, *point)
+                            .map(|(path, ..)| path)
+                            .unwrap_or_default(),
+                        "/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42",
                         "Hyperlink should have been found"
                     );
                 });
@@ -1014,11 +1084,111 @@ mod tests {
                 const LINE: &str = "Cargo.toml        experiments        notebooks        rust-toolchain.toml    tooling\r\n";
                 thread_local! {
                     static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
-                        build_test_term(LINE);
+                        build_test_term(LINE, 500, 60);
                 }
                 TEST_TERM_AND_POINT.with(|(term, point)| {
-                    assert!(
-                        find_from_grid_point_bench(term, *point).is_some(),
+                    assert_eq!(
+                        find_from_grid_point_bench(term, *point)
+                            .map(|(path, ..)| path)
+                            .unwrap_or_default(),
+                        "rust-toolchain.toml",
+                        "Hyperlink should have been found"
+                    );
+                });
+            }
+
+            #[perf]
+            // https://github.com/zed-industries/zed/pull/44407
+            pub fn pr_44407_hyperlink_benchmark() {
+                const LINE: &str = "-748, 706, 163, 222, -980, 949, 381, -568, 199, 501, 760, -821, 90, -451, 183, 867, -351, -810, -762, -109, 423, 84, 14, -77, -820, -345, 74, -791, 930, -618, -900, 862, -959, 289, -19, 471, -757, 793, 155, -554, 249, 830, 402, 732, -731, -866, -720, -703, -257, -439, 731, 872, -489, 676, -167, 613, -698, 415, -80, -453, -896, 333, -511, 621, -450, 624, -309, -575, 177, 141, 891, -104, -97, -367, -599, -675, 607, -225, -760, 552, -465, 804, 55, 282, 104, -929, -252,\
+-311, 900, 550, 599, -80, 774, 553, 837, -395, 541, 953, 154, -396, -596, -111, -802, -221, -337, -633, -73, -527, -82, -658, -264, 222, 375, 434, 204, -756, -703, 303, 239, -257, -365, -351, 904, 364, -743, -484, 655, -542, 446, 888, 632, -167, -260, 716, 150, 806, 723, 513, -118, -323, -683, 983, -564, 358, -16, -287, 277, -607, 87, 365, -1, 164, 401, 257, 369, -893, 145, -969, 375, -53, 541, -408, -865, 753, 258, 337, -886, 593, -378, -528, 191, 204, 566, -61, -621, 769, 524, -628, 6,\
+249, 896, -785, -776, 321, -681, 604, -740, 886, 426, -480, -983, 23, -247, 125, -666, 913, 842, -460, -797, -483, -58, -565, -587, -206, 197, 715, 764, -97, 457, -149, -226, 261, 194, -390, 431, 180, -778, 829, -657, -668, 397, 859, 152, -178, 677, -18, 687, -247, 96, 466, -572, 478, 622, -143, -25, -471, 265, 335, 957, 152, -951, -647, 670, 57, 152, -115, 206, 87, 629, -798, -125, -725, -31, 844, 398, -876, 44, 963, -211, 518, -8, -103, -999, 948, 823, 149, -803, 769, -236, -683, 527,\
+-108, -36, 18, -437, 687, -305, -526, 972, -965, 276, 420, -259, -379, -142, -747, 600, -578, 197, 673, 890, 324, -931, 755, -765, -422, 785, -369, -110, -505, 532, -208, -438, 713, 110, 853, 996, -360, 823, 289, -699, 629, -661, 560, -329, -323, 439, 571, -537, 644, -84, 25, -536, -161, 112, 169, -922, -537, -734, -423, 37, 451, -149, 408, 18, -672, 206, -784, 444, 593, -241, 502, -259, -798, -352, -658, 712, -675, -734, 627, -620, 64, -554, 999, -537, -160, -641, 464, 894, 29, 322, 566,\
+-510, -749, 982, 204, 967, -261, -986, -136, 251, -598, 995, -831, 891, 22, 761, -783, -415, 125, 470, -919, -97, -668, 85, 205, -175, -550, 502, 652, -468, 798, 775, -216, 89, -433, -24, -621, 877, -126, 951, 809, 782, 156, -618, -841, -463, 19, -723, -904, 550, 263, 991, -758, -114, 446, -731, -623, -634, 462, 48, 851, 333, -846, 480, 892, -966, -910, -436, 317, -711, -341, -294, 124, 238, -214, -281, 467, -950, -342, 913, -90, -388, -573, 740, -883, -451, 493, -500, 863, 930, 127, 530,\
+-810, 540, 541, -664, -951, -227, -420, -476, -581, -534, 549, 253, 984, -985, -84, -521, 538, 484, -440, 371, 784, -306, -850, 530, -133, 251, -799, 446, -170, -243, -674, 769, 646, 778, -680, -714, -442, 804, 901, -774, 69, 307, -293, 755, 443, 224, -918, -771, 723, 40, 132, 568, -847, -47, 844, 69, 986, -293, -459, 313, 155, 331, 69, 280, -637, 569, 104, -119, -988, 252, 857, -590, 810, -891, 484, 566, -934, -587, -290, 566, 587, 489, 870, 280, 454, -252, 613, -701, -278, 195, -198,\
+683, 533, -372, 707, -152, 371, 866, 609, -5, -372, -30, -694, 552, 192, 452, -663, 350, -985, 10, 884, 813, -592, -331, -470, 711, -941, 928, 379, -339, 220, 999, 376, 507, 179, 916, 84, 104, 392, 192, 299, -860, 218, -698, -919, -452, 37, 850, 5, -874, 287, 123, -746, -575, 776, -909, 118, 903, -275, 450, -996, -591, -920, -850, 453, -896, 73, 83, -535, -20, 287, -765, 442, 808, 45, 445, 202, 917, -208, 783, 790, -534, 373, -129, 556, -757, -69, 459, -163, -59, 265, -563, -889, 635,\
+-583, -261, -790, 799, 826, 953, 85, 619, 334, 842, 672, -869, -4, -833, 315, 942, -524, 579, 926, 628, -404, 128, -629, 161, 568, -117, -526, 223, -876, 906, 176, -549, -317, 381, 375, -801, -416, 647, 335, 253, -386, -375, -254, 635, 352, 317, 398, -422, 111, 201, 220, 554, -972, 853, 378, 956, 942, -857, -289, -333, -180, 488, -814, -42, -595, 721, 39, 644, 721, -242, -44, 643, -457, -419, 560, -863, 974, 458, 222, -882, 526, -243, -318, -343, -707, -401, 117, 677, -489, 546, -903,\
+-960, -881, -684, 125, -928, -995, -692, -773, 647, -718, -862, -814, 671, 664, -130, -856, -674, 653, 711, 194, -685, -160, 138, -27, -128, -671, -242, 526, 494, -674, 424, -921, -778, 313, -237, 332, 913, 252, 808, -936, 289, 755, 52, -139, 57, -19, -827, -775, -561, -14, 107, -84, 622, -303, -747, 258, -942, 290, 211, -919, -207, 797, 95, 794, -830, -181, -788, 757, 75, -946, -949, -988, 152, 340, 732, 886, -891, -642, -666, 321, -910, 841, 632, 298, 55, -349, 498, 287, -711, 97, 305,\
+-974, -987, 790, -64, 605, -583, -821, 345, 887, -861, 548, 894, 288, 452, 556, -448, 813, 420, 545, 967, 127, -947, 19, -314, -607, -513, -851, 254, -290, -938, -783, -93, 474, 368, -485, -935, -539, 81, 404, -283, 779, 345, -164, 53, 563, -771, 911, -323, 522, -998, 315, 415, 460, 58, -541, -878, -152, -886, 201, -446, -810, 549, -142, -575, -632, 521, 549, 209, -681, 998, 798, -611, -919, -708, -4, 677, -172, 588, 750, -435, 508, 609, 498, -535, -691, -738, 85, 615, 705, 169, 425,\
+-669, -491, -783, 73, -847, 228, -981, -812, -229, 950, -904, 175, -438, 632, -556, 910, 173, 576, -751, -53, -169, 635, 607, -944, -13, -84, 105, -644, 984, 935, 259, -445, 620, -405, 832, 167, 114, 209, -181, -944, -496, 693, -473, 137, 38, -873, -334, -353, -57, 397, 944, 698, 811, -401, 712, -667, 905, 276, -653, 368, -543, -349, 414, 287, 894, 935, 461, 55, 741, -623, -660, -773, 617, 834, 278, -121, 52, 495, -855, -440, -210, -99, 279, -661, 540, 934, 540, 784, 895, 268, -503, 513,\
+-484, -352, 528, 341, -451, 885, -71, 799, -195, -885, -585, -233, 92, 453, 994, 464, 694, 190, -561, -116, 675, -775, -236, 556, -110, -465, 77, -781, 507, -960, -410, 229, -632, 717, 597, 429, 358, -430, -692, -825, 576, 571, 758, -891, 528, -267, 190, -869, 132, -811, 796, 750, -596, -681, 870, 360, 969, 860, -412, -567, 694, -86, -498, 38, -178, -583, -778, 412, 842, -586, 722, -192, 350, 363, 81, -677, -163, 564, 543, 671, 110, 314, 739, -552, -224, -644, 922, 685, 134, 613, 793,\
+-363, -244, -284, -257, -561, 418, 988, 333, 110, -966, 790, 927, 536, -620, -309, -358, 895, -867, -796, -357, 308, -740, 287, -732, -363, -969, 658, 711, 511, 256, 590, -574, 815, -845, -84, 546, -581, -71, -334, -890, 652, -959, 320, -236, 445, -851, 825, -756, -4, 877, 308, 573, -117, 293, 686, -483, 391, 342, -550, -982, 713, 886, 552, 474, -673, 283, -591, -383, 988, 435, -131, 708, -326, -884, 87, 680, -818, -408, -486, 813, -307, -799, 23, -497, 802, -146, -100, 541, 7, -493, 577,\
+50, -270, 672, 834, 111, -788, 247, 337, 628, -33, -964, -519, 683, 54, -703, 633, -127, -448, 759, -975, 696, 2, -870, -760, 67, 696, 306, 750, 615, 155, -933, -568, 399, 795, 164, -460, 205, 439, -526, -691, 35, -136, -481, -63, 73, -598, 748, 133, 874, -29, 4, -73, 472, 389, 962, 231, -328, 240, 149, 959, 46, -207, 72, -514, -608, 0, -14, 32, 374, -478, -806, 919, -729, -286, 652, 109, 509, -879, -979, -865, 584, -92, -346, -992, 781, 401, 575, 993, -746, -33, 684, -683, 750, -105,\
+-425, -508, -627, 27, 770, -45, 338, 921, -139, -392, -933, 634, 563, 224, -780, 921, 991, 737, 22, 64, 414, -249, -687, 869, 50, 759, -97, 515, 20, -775, -332, 957, 138, -542, -835, 591, -819, 363, -715, -146, -950, -641, -35, -435, -407, -548, -984, 383, -216, -559, 853, 4, -410, -319, -831, -459, -628, -819, -324, 755, 696, -192, 238, -234, -724, -445, 915, 302, -708, 484, 224, -641, 25, -771, 528, -106, -744, -588, 913, -554, -515, -239, -843, -812, -171, 721, 543, -269, 440, 151,\
+996, -723, -557, -522, -280, -514, -593, 208, 715, 404, 353, 270, -483, -785, 318, -313, 798, 638, 764, 748, -929, -827, -318, -56, 389, -546, -958, -398, 463, -700, 461, 311, -787, -488, 877, 456, 166, 535, -995, -189, -715, 244, 40, 484, 212, -329, -351, 638, -69, -446, -292, 801, -822, 490, -486, -185, 790, 370, -340, 401, -656, 584, 561, -749, 269, -19, -294, -111, 975, 874, -73, 851, 231, -331, -684, 460, 765, -654, -76, 10, 733, 520, 521, 416, -958, -202, -186, -167, 175, 343, -50,\
+673, -763, -854, -977, -17, -853, -122, -25, 180, 149, 268, 874, -816, -745, 747, -303, -959, 390, 509, 18, -66, 275, -277, 9, 837, -124, 989, -542, -649, -845, 894, 926, 997, -847, -809, -579, -96, -372, 766, 238, -251, 503, 559, 276, -281, -102, -735, 815, 109, 175, -10, 128, 543, -558, -707, 949, 996, -422, -506, 252, 702, -930, 552, -961, 584, -79, -177, 341, -275, 503, -21, 677, -545, 8, -956, -795, -870, -254, 170, -502, -880, 106, 174, 459, 603, -600, -963, 164, -136, -641, -309,\
+-380, -707, -727, -10, 727, 952, 997, -731, -133, 269, 287, 855, 716, -650, 479, 299, -839, -308, -782, 769, 545, 663, -536, -115, 904, -986, -258, -562, 582, 664, 408, -525, -889, 471, -370, -534, -220, 310, 766, 931, -193, -897, -192, -74, -365, -256, -359, -328, 658, -691, -431, 406, 699, 425, 713, -584, -45, -588, 289, 658, -290, -880, -987, -444, 371, 904, -155, 81, -278, -708, -189, -78, 655, 342, -998, -647, -734, -218, 726, 619, 663, 744, 518, 60, -409, 561, -727, -961, -306,\
+-147, -550, 240, -218, -393, 267, 724, 791, -548, 480, 180, -631, 825, -170, 107, 227, -691, 905, -909, 359, 227, 287, 909, 632, -89, -522, 80, -429, 37, 561, -732, -474, 565, -798, -460, 188, 507, -511, -654, 212, -314, -376, -997, -114, -708, 512, -848, 781, 126, -956, -298, 354, -400, -121, 510, 445, 926, 27, -708, 676, 248, 834, 542, 236, -105, -153, 102, 128, 96, -348, -626, 598, 8, 978, -589, -461, -38, 381, -232, -817, 467, 356, -151, -460, 429, -408, 425, 618, -611, -247, 819,\
+963, -160, 1000, 141, -647, -875, 108, 790, -127, 463, -37, -195, -542, 12, 845, -384, 770, -129, 315, 826, -942, 430, 146, -170, -583, -903, -489, 497, -559, -401, -29, -129, -411, 166, 942, -646, -862, -404, 785, 777, -111, -481, -738, 490, 741, -398, 846, -178, -509, -661, 748, 297, -658, -567, 531, 427, -201, -41, -808, -668, 782, -860, -324, 249, 835, -234, 116, 542, -201, 328, 675, 480, -906, 188, 445, 63, -525, 811, 277, 133, 779, -680, 950, -477, -306, -64, 552, -890, -956, 169,\
+442, 44, -169, -243, -242, 423, -884, -757, -403, 739, -350, 383, 429, 153, -702, -725, 51, 310, 857, -56, 538, 46, -311, 132, -620, -297, -124, 534, 884, -629, -117, 506, -837, -100, -27, -381, -735, 262, 843, 703, 260, -457, 834, 469, 9, 950, 59, 127, -820, 518, 64, -783, 659, -608, -676, 802, 30, 589, 246, -369, 361, 347, 534, -376, 68, 941, 709, 264, 384, 481, 628, 199, -568, -342, -337, 853, -804, -858, -169, -270, 641, -344, 112, 530, -773, -349, -135, -367, -350, -756, -911, 180,\
+-660, 116, -478, -265, -581, 510, 520, -986, 935, 219, 522, 744, 47, -145, 917, 638, 301, 296, 858, -721, 511, -816, 328, 473, 441, 697, -260, -673, -379, 893, 458, 154, 86, 905, 590, 231, -717, -179, 79, 272, -439, -192, 178, -200, 51, 717, -256, -358, -626, -518, -314, -825, -325, 588, 675, -892, -798, 448, -518, 603, -23, 668, -655, 845, -314, 783, -347, -496, 921, 893, -163, -748, -906, 11, -143, -64, 300, 336, 882, 646, 533, 676, -98, -148, -607, -952, -481, -959, -874, 764, 537,\
+736, -347, 646, -843, 966, -916, -718, -391, -648, 740, 755, 919, -608, 388, -655, 68, 201, 675, -855, 7, -503, 881, 760, 669, 831, 721, -564, -445, 217, 331, 970, 521, 486, -254, 25, -259, 336, -831, 252, -995, 908, -412, -240, 123, -478, 366, 264, -504, -843, 632, -288, 896, 301, 423, 185, 318, 380, 457, -450, -162, -313, 673, -963, 570, 433, -548, 107, -39, -142, -98, -884, -3, 599, -486, -926, 923, -82, 686, 290, 99, -382, -789, 16, 495, 570, 284, 474, -504, -201, -178, -1, 592, 52,\
+827, -540, -151, -991, 130, 353, -420, -467, -661, 417, -690, 942, 936, 814, -566, -251, -298, 341, -139, 786, 129, 525, -861, 680, 955, -245, -50, 331, 412, -38, -66, 611, -558, 392, -629, -471, -68, -535, 744, 495, 87, 558, 695, 260, -308, 215, -464, 239, -50, 193, -540, 184, -8, -194, 148, 898, -557, -21, 884, 644, -785, -689, -281, -737, 267, 50, 206, 292, 265, 380, -511, 310, 53, 375, -497, -40, 312, -606, -395, 142, 422, 662, -584, 72, 144, 40, -679, -593, 581, 689, -829, 442, 822,\
+977, -832, -134, -248, -207, 248, 29, 259, 189, 592, -834, -866, 102, 0, 340, 25, -354, -239, 420, -730, -992, -925, -314, 420, 914, 607, -296, -415, -30, 813, 866, 153, -90, 150, -81, 636, -392, -222, -835, 482, -631, -962, -413, -727, 280, 686, -382, 157, -404, -511, -432, 455, 58, 108, -408, 290, -829, -252, 113, 550, -935, 925, 422, 38, 789, 361, 487, -460, -769, -963, -285, 206, -799, -488, -233, 416, 143, -456, 753, 520, 599, 621, -168, 178, -841, 51, 952, 374, 166, -300, -576, 844,\
+-656, 90, 780, 371, 730, -896, -895, -386, -662, 467, -61, 130, -362, -675, -113, 135, -761, -55, 408, 822, 675, -347, 725, 114, 952, -510, -972, 390, -413, -277, -52, 315, -80, 401, -712, 147, -202, 84, 214, -178, 970, -571, -210, 525, -887, -863, 504, 192, 837, -594, 203, -876, -209, 305, -826, 377, 103, -928, -803, -956, 949, -868, -547, 824, -994, 516, 93, -524, -866, -890, -988, -501, 15, -6, 413, -825, 304, -818, -223, 525, 176, 610, 828, 391, 940, 540, -831, 650, 438, 589, 941, 57,\
+523, 126, 221, 860, -282, -262, -226, 764, 743, -640, 390, 384, -434, 608, -983, 566, -446, 618, 456, -176, -278, 215, 871, -180, 444, -931, -200, -781, 404, 881, 780, -782, 517, -739, -548, -811, 201, -95, -249, -228, 491, -299, 700, 964, -550, 108, 334, -653, 245, -293, -552, 350, -685, -415, -818, 216, -194, -255, 295, 249, 408, 351, 287, 379, 682, 231, -693, 902, -902, 574, 937, -708, -402, -460, 827, -268, 791, 343, -780, -150, -738, 920, -430, -88, -361, -588, -727, -47, -297, 662,\
+-840, -637, -635, 916, -857, 938, 132, -553, 391, -522, 640, 626, 690, 833, 867, -555, 577, 226, 686, -44, 0, -965, 651, -1, 909, 595, -646, 740, -821, -648, -962, 927, -193, 159, 490, 594, -189, 707, -884, 759, -278, -160, -566, -340, 19, 862, -440, 445, -598, 341, 664, -311, 309, -159, 19, -672, 705, -646, 976, 247, 686, -830, -27, -667, 81, 399, -423, -567, 945, 38, 51, 740, 621, 204, -199, -908, -593, 424, 250, -561, 695, 9, 520, 878, 120, -109, 42, -375, -635, -711, -687, 383, -278,\
+36, 970, 925, 864, 836, 309, 117, 89, 654, -387, 346, -53, 617, -164, -624, 184, -45, 852, 498, -513, 794, -682, -576, 13, -147, 285, -776, -886, -96, 483, 994, -188, 346, -629, -848, 738, 51, 128, -898, -753, -906, 270, -203, -577, 48, -243, -210, 666, 353, 636, -954, 862, 560, -944, -877, -137, 440, -945, -316, 274, -211, -435, 615, -635, -468, 744, 948, -589, 525, 757, -191, -431, 42, 451, -160, -827, -991, 324, 697, 342, -610, 894, -787, -384, 872, 734, 878, 70, -260, 57, 397, -518,\
+629, -510, -94, 207, 214, -625, 106, -882, -575, 908, -650, 723, -154, 45, 108, -69, -565, 927, -68, -351, 707, -282, 429, -889, -596, 848, 578, -492, 41, -822, -992, 168, -286, -780, 970, 597, -293, -12, 367, 708, -415, 194, -86, -390, 224, 69, -368, -674, 1000, -672, 356, -202, -169, 826, 476, -285, 29, -448, 545, 186, 319, 67, 705, 412, 225, -212, -351, -391, -783, -9, 875, -59, -159, -123, -151, -296, 871, -638, 359, 909, -945, 345, -16, -562, -363, -183, -625, -115, -571, -329, 514,\
+99, 263, 463, -39, 597, -652, -349, 246, 77, -127, -563, -879, -30, 756, 777, -865, 675, -813, -501, 871, -406, -627, 834, -609, -205, -812, 643, -204, 291, -251, -184, -584, -541, 410, -573, -600, 908, -871, -687, 296, -713, -139, -778, -790, 347, -52, -400, 407, -653, 670, 39, -856, 904, 433, 392, 590, -271, -144, -863, 443, 353, 468, -544, 486, -930, 458, -596, -890, 163, 822, 768, 980, -783, -792, 126, 386, 367, -264, 603, -61, 728, 160, -4, -837, 832, 591, 436, 518, 796, -622, -867,\
+-669, -947, 253, 100, -792, 841, 413, 833, -249, -550, 282, -825, 936, -348, 898, -451, -283, 818, -237, 630, 216, -499, -637, -511, 767, -396, 221, 958, -586, -920, 401, -313, -580, -145, -270, 118, 497, 426, -975, 480, -445, -150, -721, -929, 439, -893, 902, 960, -525, -793, 924, 563, 683, -727, -86, 309, 432, -762, -345, 371, -617, 149, -215, -228, 505, 593, -20, -292, 704, -999, 149, -104, 819, -414, -443, 517, -599, -5, 145, -24, -993, -283, 904, 174, -112, -276, -860, 44, -257,\
+-931, -821, -667, 540, 421, 485, 531, 407, 833, 431, -415, 878, 503, -901, 639, -608, 896, 860, 927, 424, 113, -808, -323, 729, 382, -922, 548, -791, -379, 207, 203, 559, 537, 137, 999, -913, -240, 942, 249, 616, 775, -4, 915, 855, -987, -234, -384, 948, -310, -542, 125, -289, -599, 967, -492, -349, -552, 562, -926, 632, -164, 217, -165, -496, 847, 684, -884, 457, -748, -745, -38, 93, 961, 934, 588, 366, -130, 851, -803, -811, -211, 428, 183, -469, 888, 596, -475, -899, -681, 508, 184,\
+921, 863, -610, -416, -119, -966, -686, 210, 733, 715, -889, -925, -434, -566, -455, 596, -514, 983, 755, -194, -802, -313, 91, -541, 808, -834, 243, -377, 256, 966, -402, -773, -308, -605, 266, 866, 118, -425, -531, 498, 666, 813, -267, 830, 69, -869, -496, 735, 28, 488, -645, -493, -689, 170, -940, 532, 844, -658, -617, 408, -200, 764, -665, 568, 342, 621, 908, 471, 280, 859, 709, 898, 81, -547, 406, 514, -595, 43, -824, -696, -746, -429, -59, -263, -813, 233, 279, -125, 687, -418,\
+-530, 409, 614, 803, -407, 78, -676, -39, -887, -141, -292, 270, -343, 400, 907, 588, 668, 899, 973, 103, -101, -11, 397, -16, 165, 705, -410, -585, 316, 391, -346, -336, 957, -118, -538, -441, -845, 121, 591, -359, -188, -362, -208, 27, -925, -157, -495, -177, -580, 9, 531, -752, 94, 107, 820, 769, -500, 852, 617, 145, 355, 34, -463, -265, -709, -111, -855, -405, 560, 470, 3, -177, -164, -249, 450, 662, 841, -689, -509, 987, -33, 769, 234, -2, 203, 780, 744, -895, 497, -432, -406, -264,\
+-71, 124, 778, -897, 495, 127, -76, 52, -768, 205, 464, -992, 801, -83, -806, 545, -316, 146, 772, 786, 289, -936, 145, -30, -722, -455, 270, 444, 427, -482, 383, -861, 36, 630, -404, 83, 864, 743, -351, -846, 315, -837, 357, -195, 450, -715, 227, -942, 740, -519, 476, 716, 713, 169, 492, -112, -49, -931, 866, 95, -725, 198, -50, -17, -660, 356, -142, -781, 53, 431, 720, 143, -416, 446, -497, 490, -96, 157, 239, 487, -337, -224, -445, 813, 92, -22, 603, 424, 952, -632, -367, 898, -927,\
+884, -277, -187, -777, 537, -575, -313, 347, -33, 800, 672, -919, -541, 5, -270, -94, -265, -793, -183, -761, -516, -608, -218, 57, -889, -912, 508, 93, -90, 34, 530, 201, 999, -37, -186, -62, -980, 239, 902, 983, -287, -634, 524, -772, 470, -961, 32, 162, 315, -411, 400, -235, -283, -787, -703, 869, 792, 543, -274, 239, 733, -439, 306, 349, 579, -200, -201, -824, 384, -246, 133, -508, 770, -102, 957, -825, 740, 748, -376, 183, -426, 46, 668, -886, -43, -174, 672, -419, 390, 927, 1000,\
+318, 886, 47, 908, -540, -825, -5, 314, -999, 354, -603, 966, -633, -689, 985, 534, -290, 167, -652, -797, -612, -79, 488, 622, -464, -950, 595, 897, 704, -238, -395, 125, 831, -180, 226, -379, 310, 564, 56, -978, 895, -61, 686, -251, 434, -417, 161, -512, 752, 528, -589, -425, 66, -925, -157, 1000, 96, 256, -239, -784, -882, -464, -909, 663, -177, -678, -441, 669, -564, -201, -121, -743, 187, -107, -768, -682, 355, 161, 411, 984, -954, 166, -842, -755, 267, -709, 372, -699, -272, -850,\
+403, -839, 949, 622, -62, 51, 917, 70, 528, -558, -632, 832, 276, 61, -445, -195, 960, 846, -474, 764, 879, -411, 948, -62, -592, -123, -96, -551, -555, -724, 849, 250, -808, -732, 797, -839, -554, 306, -919, 888, 484, -728, 152, -122, -287, 16, -345, -396, -268, -963, -500, 433, 343, 418, -480, 828, 594, 821, -9, 933, -230, 707, -847, -610, -748, -234, 688, 935, 713, 865, -743, 293, -143, -20, 928, -906, -762, 528, 722, 412, -70, 622, -245, 539, -686, 730, -866, -705, 28, -916, -623,\
+-768, -614, -915, -123, -183, 680, -223, 515, -37, -235, -5, 260, 347, -239, -322, -861, -848, -936, 945, 721, -580, -639, 780, -153, -26, 685, 177, 587, 307, -915, 435, 658, 539, -229, -719, -171, -858, 162, 734, -539, -437, 246, 639, 765, -477, -342, -209, -284, -779, -414, -452, 914, 338, -83, 759, 567, 266, -485, 14, 225, 347, -432, -242, 997, -365, -764, 119, -641, -416, -388, -436, -388, -54, -649, -571, -920, -477, 714, -363, 836, 369, 702, 869, 503, -287, -679, 46, -666, -202,\
+-602, 71, -259, 967, 601, -571, -830, -993, -271, 281, -494, 482, -180, 572, 587, -651, -566, -448, -228, 511, -924, 832, -52, -712, 402, -644, -533, -865, 269, 965, 56, 675, 179, -338, -272, 614, 602, -283, 303, -70, 909, -942, 117, 839, 468, 813, -765, 884, -697, -813, 352, 374, -705, -295, 633, 211, -754, 597, -941, -142, -393, -469, -653, 688, 996, 911, 214, 431, 453, -141, 874, -81, -258, -735, -3, -110, -338, -929, -182, -306, -104, -840, -588, -759, -157, -801, 848, -698, 627, 914,\
+-33, -353, 425, 150, -798, 553, 934, -778, -196, -132, 808, 745, -894, 144, 213, 662, 273, -79, 454, -60, -467, 48, -15, -807, 69, -930, 749, 559, -867, -103, 258, -677, 750, -303, 846, -227, -936, 744, -770, 770, -434, 594, -477, 589, -612, 535, 357, -623, 683, 369, 905, 980, -410, -663, 762, -888, -563, -845, 843, 353, -491, 996, -255, -336, -132, 695, -823, 289, -143, 365, 916, 877, 245, -530, -848, -804, -118, -108, 847, 620, -355, 499, 881, 92, -640, 542, 38, 626, -260, -34, -378,\
+598, 890, 305, -118, 711, -385, 600, -570, 27, -129, -893, 354, 459, 374, 816, 470, 356, 661, 877, 735, -286, -780, 620, 943, -169, -888, 978, 441, -667, -399, 662, 249, 137, 598, -863, -453, 722, -815, -251, -995, -294, -707, 901, 763, 977, 137, 431, -994, 905, 593, 694, 444, -626, -816, 252, 282, 616, 841, 360, -932, 817, -908, 50, 394, -120, -786, -338, 499, -982, -95, -454, 838, -312, 320, -127, -653, 53, 16, 988, -968, -151, -369, -836, 293, -271, 483, 18, 724, -204, -965, 245, 310,\
+987, 552, -835, -912, -861, 254, 560, 124, 145, 798, 178, 476, 138, -311, 151, -907, -886, -592, 728, -43, -489, 873, -422, -439, -489, 375, -703, -459, 338, 418, -25, 332, -454, 730, -604, -800, 37, -172, -197, -568, -563, -332, 228, -182, 994, -123, 444, -567, 98, 78, 0, -504, -150, 88, -936, 199, -651, -776, 192, 46, 526, -727, -991, 534, -659, -738, 256, -894, 965, -76, 816, 435, -418, 800, 838, 67, -733, 570, 112, -514, -416\r\
+";
+                thread_local! {
+                    static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
+                        build_test_term(&LINE, 5, 50);
+                }
+                TEST_TERM_AND_POINT.with(|(term, point)| {
+                    assert_eq!(
+                        find_from_grid_point_bench(term, *point)
+                            .map(|(path, ..)| path)
+                            .unwrap_or_default(),
+                        "392",
+                        "Hyperlink should have been found"
+                    );
+                });
+            }
+
+            #[perf]
+            // https://github.com/zed-industries/zed/issues/44510
+            pub fn issue_44510_hyperlink_benchmark() {
+                const LINE: &str = "..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
+...............................................E.\r\
+";
+                thread_local! {
+                    static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
+                        build_test_term(&LINE, 5, 50);
+                }
+                TEST_TERM_AND_POINT.with(|(term, point)| {
+                    assert_eq!(
+                        find_from_grid_point_bench(term, *point)
+                            .map(|(path, ..)| path)
+                            .unwrap_or_default(),
+                        LINE.trim_end_matches(['.', '\r', '\n']),
                         "Hyperlink should have been found"
                     );
                 });

crates/terminal_view/src/terminal_element.rs 🔗

@@ -151,7 +151,14 @@ impl BatchedTextRun {
                 std::slice::from_ref(&self.style),
                 Some(dimensions.cell_width),
             )
-            .paint(pos, dimensions.line_height, window, cx);
+            .paint(
+                pos,
+                dimensions.line_height,
+                gpui::TextAlign::Left,
+                None,
+                window,
+                cx,
+            );
     }
 }
 
@@ -632,7 +639,7 @@ impl TerminalElement {
     ) -> impl Fn(&E, &mut Window, &mut App) {
         move |event, window, cx| {
             if steal_focus {
-                window.focus(&focus_handle);
+                window.focus(&focus_handle, cx);
             } else if !focus_handle.is_focused(window) {
                 return;
             }
@@ -661,7 +668,7 @@ impl TerminalElement {
             let terminal_view = terminal_view.clone();
 
             move |e, window, cx| {
-                window.focus(&focus);
+                window.focus(&focus, cx);
 
                 let scroll_top = terminal_view.read(cx).scroll_top;
                 terminal.update(cx, |terminal, cx| {
@@ -1326,8 +1333,14 @@ impl Element for TerminalElement {
                                     }],
                                     None
                                 );
-                                shaped_line
-                                    .paint(ime_position, layout.dimensions.line_height, window, cx)
+                                shaped_line.paint(
+                                    ime_position,
+                                    layout.dimensions.line_height,
+                                    gpui::TextAlign::Left,
+                                    None,
+                                    window,
+                                    cx,
+                                )
                                     .log_err();
                             }
 

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -351,7 +351,7 @@ impl TerminalPanel {
                 } else if let Some(focus_on_pane) =
                     focus_on_pane.as_ref().or_else(|| self.center.panes().pop())
                 {
-                    focus_on_pane.focus_handle(cx).focus(window);
+                    focus_on_pane.focus_handle(cx).focus(window, cx);
                 }
             }
             pane::Event::ZoomIn => {
@@ -397,7 +397,7 @@ impl TerminalPanel {
                                     .center
                                     .split(&pane, &new_pane, direction, cx)
                                     .log_err();
-                                window.focus(&new_pane.focus_handle(cx));
+                                window.focus(&new_pane.focus_handle(cx), cx);
                             })
                             .ok();
                     })
@@ -419,7 +419,7 @@ impl TerminalPanel {
                         pane.add_item(item, true, true, None, window, cx);
                     });
                     self.center.split(&pane, &new_pane, direction, cx).log_err();
-                    window.focus(&new_pane.focus_handle(cx));
+                    window.focus(&new_pane.focus_handle(cx), cx);
                 }
             }
             pane::Event::Focus => {
@@ -790,8 +790,7 @@ impl TerminalPanel {
                 }
 
                 pane.update(cx, |pane, cx| {
-                    let focus = pane.has_focus(window, cx)
-                        || matches!(reveal_strategy, RevealStrategy::Always);
+                    let focus = matches!(reveal_strategy, RevealStrategy::Always);
                     pane.add_item(terminal_view, true, focus, None, window, cx);
                 });
 
@@ -853,8 +852,7 @@ impl TerminalPanel {
                         }
 
                         pane.update(cx, |pane, cx| {
-                            let focus = pane.has_focus(window, cx)
-                                || matches!(reveal_strategy, RevealStrategy::Always);
+                            let focus = matches!(reveal_strategy, RevealStrategy::Always);
                             pane.add_item(terminal_view, true, focus, None, window, cx);
                         });
 
@@ -941,7 +939,6 @@ impl TerminalPanel {
         cx: &mut Context<Self>,
     ) -> Task<Result<WeakEntity<Terminal>>> {
         let reveal = spawn_task.reveal;
-        let reveal_target = spawn_task.reveal_target;
         let task_workspace = self.workspace.clone();
         cx.spawn_in(window, async move |terminal_panel, cx| {
             let project = terminal_panel.update(cx, |this, cx| {
@@ -957,6 +954,14 @@ impl TerminalPanel {
                 terminal_to_replace.set_terminal(new_terminal.clone(), window, cx);
             })?;
 
+            let reveal_target = terminal_panel.update(cx, |panel, _| {
+                if panel.center.panes().iter().any(|p| **p == task_pane) {
+                    RevealTarget::Dock
+                } else {
+                    RevealTarget::Center
+                }
+            })?;
+
             match reveal {
                 RevealStrategy::Always => match reveal_target {
                     RevealTarget::Center => {
@@ -998,7 +1003,7 @@ impl TerminalPanel {
                 RevealStrategy::NoFocus => match reveal_target {
                     RevealTarget::Center => {
                         task_workspace.update_in(cx, |workspace, window, cx| {
-                            workspace.active_pane().focus_handle(cx).focus(window);
+                            workspace.active_pane().focus_handle(cx).focus(window, cx);
                         })?;
                     }
                     RevealTarget::Dock => {
@@ -1053,7 +1058,7 @@ impl TerminalPanel {
             .center
             .find_pane_in_direction(&self.active_pane, direction, cx)
         {
-            window.focus(&pane.focus_handle(cx));
+            window.focus(&pane.focus_handle(cx), cx);
         } else {
             self.workspace
                 .update(cx, |workspace, cx| {
@@ -1171,64 +1176,67 @@ pub fn new_terminal_pane(
                         let source = tab.pane.clone();
                         let item_id_to_move = item.item_id();
 
-                        let Ok(new_split_pane) = pane
-                            .drag_split_direction()
-                            .map(|split_direction| {
-                                drop_closure_terminal_panel.update(cx, |terminal_panel, cx| {
-                                    let is_zoomed = if terminal_panel.active_pane == this_pane {
-                                        pane.is_zoomed()
-                                    } else {
-                                        terminal_panel.active_pane.read(cx).is_zoomed()
-                                    };
-                                    let new_pane = new_terminal_pane(
-                                        workspace.clone(),
-                                        project.clone(),
-                                        is_zoomed,
-                                        window,
-                                        cx,
-                                    );
-                                    terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
-                                    terminal_panel.center.split(
-                                        &this_pane,
-                                        &new_pane,
-                                        split_direction,
-                                        cx,
-                                    )?;
-                                    anyhow::Ok(new_pane)
-                                })
-                            })
-                            .transpose()
-                        else {
-                            return ControlFlow::Break(());
+                        // If no split direction, let the regular pane drop handler take care of it
+                        let Some(split_direction) = pane.drag_split_direction() else {
+                            return ControlFlow::Continue(());
                         };
 
-                        match new_split_pane.transpose() {
-                            // Source pane may be the one currently updated, so defer the move.
-                            Ok(Some(new_pane)) => cx
-                                .spawn_in(window, async move |_, cx| {
-                                    cx.update(|window, cx| {
-                                        move_item(
-                                            &source,
+                        // Gather data synchronously before deferring
+                        let is_zoomed = drop_closure_terminal_panel
+                            .upgrade()
+                            .map(|terminal_panel| {
+                                let terminal_panel = terminal_panel.read(cx);
+                                if terminal_panel.active_pane == this_pane {
+                                    pane.is_zoomed()
+                                } else {
+                                    terminal_panel.active_pane.read(cx).is_zoomed()
+                                }
+                            })
+                            .unwrap_or(false);
+
+                        let workspace = workspace.clone();
+                        let terminal_panel = drop_closure_terminal_panel.clone();
+
+                        // Defer the split operation to avoid re-entrancy panic.
+                        // The pane may be the one currently being updated, so we cannot
+                        // call mark_positions (via split) synchronously.
+                        cx.spawn_in(window, async move |_, cx| {
+                            cx.update(|window, cx| {
+                                let Ok(new_pane) =
+                                    terminal_panel.update(cx, |terminal_panel, cx| {
+                                        let new_pane = new_terminal_pane(
+                                            workspace, project, is_zoomed, window, cx,
+                                        );
+                                        terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
+                                        terminal_panel.center.split(
+                                            &this_pane,
                                             &new_pane,
-                                            item_id_to_move,
-                                            new_pane.read(cx).active_item_index(),
-                                            true,
-                                            window,
+                                            split_direction,
                                             cx,
-                                        );
+                                        )?;
+                                        anyhow::Ok(new_pane)
                                     })
-                                    .ok();
-                                })
-                                .detach(),
-                            // If we drop into existing pane or current pane,
-                            // regular pane drop handler will take care of it,
-                            // using the right tab index for the operation.
-                            Ok(None) => return ControlFlow::Continue(()),
-                            err @ Err(_) => {
-                                err.log_err();
-                                return ControlFlow::Break(());
-                            }
-                        };
+                                else {
+                                    return;
+                                };
+
+                                let Some(new_pane) = new_pane.log_err() else {
+                                    return;
+                                };
+
+                                move_item(
+                                    &source,
+                                    &new_pane,
+                                    item_id_to_move,
+                                    new_pane.read(cx).active_item_index(),
+                                    true,
+                                    window,
+                                    cx,
+                                );
+                            })
+                            .ok();
+                        })
+                        .detach();
                     } else if let Some(project_path) = item.project_path(cx)
                         && let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx)
                     {
@@ -1297,7 +1305,7 @@ fn add_paths_to_terminal(
         .active_item()
         .and_then(|item| item.downcast::<TerminalView>())
     {
-        window.focus(&terminal_view.focus_handle(cx));
+        window.focus(&terminal_view.focus_handle(cx), cx);
         let mut new_text = paths.iter().map(|path| format!(" {path:?}")).join("");
         new_text.push(' ');
         terminal_view.update(cx, |terminal_view, cx| {
@@ -1451,7 +1459,7 @@ impl Render for TerminalPanel {
                             .position(|pane| **pane == terminal_panel.active_pane)
                         {
                             let next_ix = (ix + 1) % panes.len();
-                            window.focus(&panes[next_ix].focus_handle(cx));
+                            window.focus(&panes[next_ix].focus_handle(cx), cx);
                         }
                     }),
                 )
@@ -1463,7 +1471,7 @@ impl Render for TerminalPanel {
                             .position(|pane| **pane == terminal_panel.active_pane)
                         {
                             let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1);
-                            window.focus(&panes[prev_ix].focus_handle(cx));
+                            window.focus(&panes[prev_ix].focus_handle(cx), cx);
                         }
                     },
                 ))
@@ -1471,7 +1479,7 @@ impl Render for TerminalPanel {
                     cx.listener(|terminal_panel, action: &ActivatePane, window, cx| {
                         let panes = terminal_panel.center.panes();
                         if let Some(&pane) = panes.get(action.0) {
-                            window.focus(&pane.read(cx).focus_handle(cx));
+                            window.focus(&pane.read(cx).focus_handle(cx), cx);
                         } else {
                             let future =
                                 terminal_panel.new_pane_with_cloned_active_terminal(window, cx);
@@ -1490,7 +1498,7 @@ impl Render for TerminalPanel {
                                                 )
                                                 .log_err();
                                             let new_pane = new_pane.read(cx);
-                                            window.focus(&new_pane.focus_handle(cx));
+                                            window.focus(&new_pane.focus_handle(cx), cx);
                                         },
                                     );
                                 }

crates/terminal_view/src/terminal_scrollbar.rs 🔗

@@ -50,28 +50,24 @@ impl ScrollableHandle for TerminalScrollHandle {
         let state = self.state.borrow();
         size(
             Pixels::ZERO,
-            state
-                .total_lines
-                .checked_sub(state.viewport_lines)
-                .unwrap_or(0) as f32
-                * state.line_height,
+            state.total_lines.saturating_sub(state.viewport_lines) as f32 * state.line_height,
         )
     }
 
     fn offset(&self) -> Point<Pixels> {
         let state = self.state.borrow();
-        let scroll_offset = state.total_lines - state.viewport_lines - state.display_offset;
-        Point::new(
-            Pixels::ZERO,
-            -(scroll_offset as f32 * self.state.borrow().line_height),
-        )
+        let scroll_offset = state
+            .total_lines
+            .saturating_sub(state.viewport_lines)
+            .saturating_sub(state.display_offset);
+        Point::new(Pixels::ZERO, -(scroll_offset as f32 * state.line_height))
     }
 
     fn set_offset(&self, point: Point<Pixels>) {
         let state = self.state.borrow();
         let offset_delta = (point.y / state.line_height).round() as i32;
 
-        let max_offset = state.total_lines - state.viewport_lines;
+        let max_offset = state.total_lines.saturating_sub(state.viewport_lines);
         let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32);
 
         self.future_display_offset

crates/terminal_view/src/terminal_view.rs 🔗

@@ -8,8 +8,8 @@ mod terminal_slash_command;
 use assistant_slash_command::SlashCommandRegistry;
 use editor::{EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
 use gpui::{
-    Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
-    KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
+    Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle,
+    Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
     ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
 };
 use persistence::TERMINAL_DB;
@@ -409,7 +409,7 @@ impl TerminalView {
                 )
         });
 
-        window.focus(&context_menu.focus_handle(cx));
+        window.focus(&context_menu.focus_handle(cx), cx);
         let subscription = cx.subscribe_in(
             &context_menu,
             window,
@@ -687,12 +687,32 @@ impl TerminalView {
 
     ///Attempt to paste the clipboard into the terminal
     fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
-        if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) {
+        let Some(clipboard) = cx.read_from_clipboard() else {
+            return;
+        };
+
+        if clipboard.entries().iter().any(|entry| match entry {
+            ClipboardEntry::Image(image) => !image.bytes.is_empty(),
+            _ => false,
+        }) {
+            self.forward_ctrl_v(cx);
+            return;
+        }
+
+        if let Some(text) = clipboard.text() {
             self.terminal
-                .update(cx, |terminal, _cx| terminal.paste(&clipboard_string));
+                .update(cx, |terminal, _cx| terminal.paste(&text));
         }
     }
 
+    /// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
+    /// and attach images using their native workflows.
+    fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
+        self.terminal.update(cx, |term, _| {
+            term.input(vec![0x16]);
+        });
+    }
+
     fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
         self.clear_bell(cx);
         self.terminal.update(cx, |term, _| {

crates/title_bar/build.rs 🔗

@@ -0,0 +1,28 @@
+#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")]
+
+fn main() {
+    println!("cargo::rustc-check-cfg=cfg(macos_sdk_26)");
+
+    #[cfg(target_os = "macos")]
+    {
+        use std::process::Command;
+
+        let output = Command::new("xcrun")
+            .args(["--sdk", "macosx", "--show-sdk-version"])
+            .output()
+            .unwrap();
+
+        let sdk_version = String::from_utf8(output.stdout).unwrap();
+        let major_version: Option<u32> = sdk_version
+            .trim()
+            .split('.')
+            .next()
+            .and_then(|v| v.parse().ok());
+
+        if let Some(major) = major_version
+            && major >= 26
+        {
+            println!("cargo:rustc-cfg=macos_sdk_26");
+        }
+    }
+}

crates/title_bar/src/application_menu.rs 🔗

@@ -1,12 +1,7 @@
-use gpui::{Entity, OwnedMenu, OwnedMenuItem};
+use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, actions};
 use settings::Settings;
 
-#[cfg(not(target_os = "macos"))]
-use gpui::{Action, actions};
-
-#[cfg(not(target_os = "macos"))]
 use schemars::JsonSchema;
-#[cfg(not(target_os = "macos"))]
 use serde::Deserialize;
 
 use smallvec::SmallVec;
@@ -14,18 +9,23 @@ use ui::{ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
 
 use crate::title_bar_settings::TitleBarSettings;
 
-#[cfg(not(target_os = "macos"))]
 actions!(
     app_menu,
     [
-        /// Navigates to the menu item on the right.
+        /// Activates the menu on the right in the client-side application menu.
+        ///
+        /// Does not apply to platform menu bars (e.g. on macOS).
         ActivateMenuRight,
-        /// Navigates to the menu item on the left.
+        /// Activates the menu on the left in the client-side application menu.
+        ///
+        /// Does not apply to platform menu bars (e.g. on macOS).
         ActivateMenuLeft
     ]
 );
 
-#[cfg(not(target_os = "macos"))]
+/// Opens the named menu in the client-side application menu.
+///
+/// Does not apply to platform menu bars (e.g. on macOS).
 #[derive(Clone, Deserialize, JsonSchema, PartialEq, Default, Action)]
 #[action(namespace = app_menu)]
 pub struct OpenApplicationMenu(String);

crates/title_bar/src/platforms/platform_mac.rs 🔗

@@ -1,6 +1,10 @@
-/// Use pixels here instead of a rem-based size because the macOS traffic
-/// lights are a static size, and don't scale with the rest of the UI.
-///
-/// Magic number: There is one extra pixel of padding on the left side due to
-/// the 1px border around the window on macOS apps.
+// Use pixels here instead of a rem-based size because the macOS traffic
+// lights are a static size, and don't scale with the rest of the UI.
+//
+// Magic number: There is one extra pixel of padding on the left side due to
+// the 1px border around the window on macOS apps.
+#[cfg(macos_sdk_26)]
+pub const TRAFFIC_LIGHT_PADDING: f32 = 78.;
+
+#[cfg(not(macos_sdk_26))]
 pub const TRAFFIC_LIGHT_PADDING: f32 = 71.;

crates/title_bar/src/title_bar.rs 🔗

@@ -30,18 +30,20 @@ use gpui::{
     Subscription, WeakEntity, Window, actions, div,
 };
 use onboarding_banner::OnboardingBanner;
-use project::{Project, WorktreeSettings, git_store::GitStoreEvent};
+use project::{
+    Project, WorktreeSettings, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
+};
 use remote::RemoteConnectionOptions;
 use settings::{Settings, SettingsLocation};
 use std::sync::Arc;
 use theme::ActiveTheme;
 use title_bar_settings::TitleBarSettings;
 use ui::{
-    Avatar, Button, ButtonLike, ButtonStyle, Chip, ContextMenu, Icon, IconName, IconSize,
-    IconWithIndicator, Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, h_flex, prelude::*,
+    Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu,
+    PopoverMenuHandle, TintColor, Tooltip, prelude::*,
 };
 use util::{ResultExt, rel_path::RelPath};
-use workspace::{Workspace, notifications::NotifyResultExt};
+use workspace::{ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt};
 use zed_actions::{OpenRecent, OpenRemote};
 
 pub use onboarding_banner::restore_banner;
@@ -163,11 +165,12 @@ impl Render for TitleBar {
                             title_bar
                                 .when(title_bar_settings.show_project_items, |title_bar| {
                                     title_bar
-                                        .children(self.render_project_host(cx))
-                                        .child(self.render_project_name(cx))
+                                        .children(self.render_restricted_mode(cx))
+                                        .children(self.render_project_host(window, cx))
+                                        .child(self.render_project_name(window, cx))
                                 })
                                 .when(title_bar_settings.show_branch_name, |title_bar| {
-                                    title_bar.children(self.render_project_repo(cx))
+                                    title_bar.children(self.render_project_repo(window, cx))
                                 })
                         })
                 })
@@ -291,7 +294,12 @@ impl TitleBar {
                 _ => {}
             }),
         );
-        subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
+        subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify()));
+        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+            subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| {
+                cx.notify();
+            }));
+        }
 
         let banner = cx.new(|cx| {
             OnboardingBanner::new(
@@ -317,7 +325,7 @@ impl TitleBar {
             client,
             _subscriptions: subscriptions,
             banner,
-            screen_share_popover_handle: Default::default(),
+            screen_share_popover_handle: PopoverMenuHandle::default(),
         }
     }
 
@@ -342,7 +350,14 @@ impl TitleBar {
             .next()
     }
 
-    fn render_remote_project_connection(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+    fn render_remote_project_connection(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
+        let workspace = self.workspace.clone();
+        let is_picker_open = self.is_picker_open(window, cx);
+
         let options = self.project.read(cx).remote_connection_options(cx)?;
         let host: SharedString = options.display_name().into();
 
@@ -387,7 +402,7 @@ impl TitleBar {
         let meta = SharedString::from(meta);
 
         Some(
-            ButtonLike::new("ssh-server-icon")
+            ButtonLike::new("remote_project")
                 .child(
                     h_flex()
                         .gap_2()
@@ -402,34 +417,93 @@ impl TitleBar {
                         )
                         .child(Label::new(nickname).size(LabelSize::Small).truncate()),
                 )
-                .tooltip(move |_window, cx| {
-                    Tooltip::with_meta(
-                        tooltip_title,
-                        Some(&OpenRemote {
-                            from_existing_connection: false,
-                            create_new_window: false,
-                        }),
-                        meta.clone(),
-                        cx,
-                    )
+                .when(!is_picker_open, |this| {
+                    this.tooltip(move |_window, cx| {
+                        Tooltip::with_meta(
+                            tooltip_title,
+                            Some(&OpenRemote {
+                                from_existing_connection: false,
+                                create_new_window: false,
+                            }),
+                            meta.clone(),
+                            cx,
+                        )
+                    })
                 })
-                .on_click(|_, window, cx| {
-                    window.dispatch_action(
-                        OpenRemote {
-                            from_existing_connection: false,
-                            create_new_window: false,
-                        }
-                        .boxed_clone(),
-                        cx,
-                    );
+                .on_click(move |event, window, cx| {
+                    let position = event.position();
+                    let _ = workspace.update(cx, |this, cx| {
+                        this.set_next_modal_placement(workspace::ModalPlacement::Anchored {
+                            position,
+                        });
+
+                        window.dispatch_action(
+                            OpenRemote {
+                                from_existing_connection: false,
+                                create_new_window: false,
+                            }
+                            .boxed_clone(),
+                            cx,
+                        );
+                    });
                 })
                 .into_any_element(),
         )
     }
 
-    pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+    pub fn render_restricted_mode(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+        let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx)
+            .map(|trusted_worktrees| {
+                trusted_worktrees
+                    .read(cx)
+                    .has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx)
+            })
+            .unwrap_or(false);
+        if !has_restricted_worktrees {
+            return None;
+        }
+
+        let button = Button::new("restricted_mode_trigger", "Restricted Mode")
+            .style(ButtonStyle::Tinted(TintColor::Warning))
+            .label_size(LabelSize::Small)
+            .color(Color::Warning)
+            .icon(IconName::Warning)
+            .icon_color(Color::Warning)
+            .icon_size(IconSize::Small)
+            .icon_position(IconPosition::Start)
+            .tooltip(|_, cx| {
+                Tooltip::with_meta(
+                    "You're in Restricted Mode",
+                    Some(&ToggleWorktreeSecurity),
+                    "Mark this project as trusted and unlock all features",
+                    cx,
+                )
+            })
+            .on_click({
+                cx.listener(move |this, _, window, cx| {
+                    this.workspace
+                        .update(cx, |workspace, cx| {
+                            workspace.show_worktree_trust_security_modal(true, window, cx)
+                        })
+                        .log_err();
+                })
+            });
+
+        if cfg!(macos_sdk_26) {
+            // Make up for Tahoe's traffic light buttons having less spacing around them
+            Some(div().child(button).ml_0p5().into_any_element())
+        } else {
+            Some(button.into_any_element())
+        }
+    }
+
+    pub fn render_project_host(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
         if self.project.read(cx).is_via_remote_server() {
-            return self.render_remote_project_connection(cx);
+            return self.render_remote_project_connection(window, cx);
         }
 
         if self.project.read(cx).is_disconnected(cx) {
@@ -437,7 +511,6 @@ impl TitleBar {
                 Button::new("disconnected", "Disconnected")
                     .disabled(true)
                     .color(Color::Disabled)
-                    .style(ButtonStyle::Subtle)
                     .label_size(LabelSize::Small)
                     .into_any_element(),
             );
@@ -450,15 +523,19 @@ impl TitleBar {
             .read(cx)
             .participant_indices()
             .get(&host_user.id)?;
+
         Some(
             Button::new("project_owner_trigger", host_user.github_login.clone())
                 .color(Color::Player(participant_index.0))
-                .style(ButtonStyle::Subtle)
                 .label_size(LabelSize::Small)
-                .tooltip(Tooltip::text(format!(
-                    "{} is sharing this project. Click to follow.",
-                    host_user.github_login
-                )))
+                .tooltip(move |_, cx| {
+                    let tooltip_title = format!(
+                        "{} is sharing this project. Click to follow.",
+                        host_user.github_login
+                    );
+
+                    Tooltip::with_meta(tooltip_title, None, "Click to Follow", cx)
+                })
                 .on_click({
                     let host_peer_id = host.peer_id;
                     cx.listener(move |this, _, window, cx| {
@@ -473,29 +550,42 @@ impl TitleBar {
         )
     }
 
-    pub fn render_project_name(&self, cx: &mut Context<Self>) -> impl IntoElement {
+    pub fn render_project_name(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let workspace = self.workspace.clone();
+        let is_picker_open = self.is_picker_open(window, cx);
+
         let name = self.project_name(cx);
         let is_project_selected = name.is_some();
         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)
-            .when(!is_project_selected, |b| b.color(Color::Muted))
-            .style(ButtonStyle::Subtle)
             .label_size(LabelSize::Small)
-            .tooltip(move |_window, cx| {
-                Tooltip::for_action(
-                    "Recent Projects",
-                    &zed_actions::OpenRecent {
-                        create_new_window: false,
-                    },
-                    cx,
-                )
+            .when(!is_project_selected, |s| s.color(Color::Muted))
+            .when(!is_picker_open, |this| {
+                this.tooltip(move |_window, cx| {
+                    Tooltip::for_action(
+                        "Recent Projects",
+                        &zed_actions::OpenRecent {
+                            create_new_window: false,
+                        },
+                        cx,
+                    )
+                })
             })
-            .on_click(cx.listener(move |_, _, window, cx| {
+            .on_click(move |event, window, cx| {
+                let position = event.position();
+                let _ = workspace.update(cx, |this, _cx| {
+                    this.set_next_modal_placement(workspace::ModalPlacement::Anchored { position })
+                });
+
                 window.dispatch_action(
                     OpenRecent {
                         create_new_window: false,
@@ -503,84 +593,102 @@ impl TitleBar {
                     .boxed_clone(),
                     cx,
                 );
-            }))
+            })
     }
 
-    pub fn render_project_repo(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
-        let settings = TitleBarSettings::get_global(cx);
+    pub fn render_project_repo(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<impl IntoElement> {
         let repository = self.project.read(cx).active_repository(cx)?;
         let repository_count = self.project.read(cx).repositories(cx).len();
         let workspace = self.workspace.upgrade()?;
-        let repo = repository.read(cx);
-        let branch_name = repo
-            .branch
-            .as_ref()
-            .map(|branch| branch.name())
-            .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH))
-            .or_else(|| {
-                repo.head_commit.as_ref().map(|commit| {
-                    commit
-                        .sha
-                        .chars()
-                        .take(MAX_SHORT_SHA_LENGTH)
-                        .collect::<String>()
-                })
-            })?;
-        let project_name = self.project_name(cx);
-        let repo_name = repo
-            .work_directory_abs_path
-            .file_name()
-            .and_then(|name| name.to_str())
-            .map(SharedString::new);
-        let show_repo_name =
-            repository_count > 1 && repo.branch.is_some() && repo_name != project_name;
-        let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) {
-            format!("{repo_name}/{branch_name}")
-        } else {
-            branch_name
+
+        let (branch_name, icon_info) = {
+            let repo = repository.read(cx);
+            let branch_name = repo
+                .branch
+                .as_ref()
+                .map(|branch| branch.name())
+                .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH))
+                .or_else(|| {
+                    repo.head_commit.as_ref().map(|commit| {
+                        commit
+                            .sha
+                            .chars()
+                            .take(MAX_SHORT_SHA_LENGTH)
+                            .collect::<String>()
+                    })
+                });
+
+            let branch_name = branch_name?;
+
+            let project_name = self.project_name(cx);
+            let repo_name = repo
+                .work_directory_abs_path
+                .file_name()
+                .and_then(|name| name.to_str())
+                .map(SharedString::new);
+            let show_repo_name =
+                repository_count > 1 && repo.branch.is_some() && repo_name != project_name;
+            let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) {
+                format!("{repo_name}/{branch_name}")
+            } else {
+                branch_name
+            };
+
+            let status = repo.status_summary();
+            let tracked = status.index + status.worktree;
+            let icon_info = if status.conflict > 0 {
+                (IconName::Warning, Color::VersionControlConflict)
+            } else if tracked.modified > 0 {
+                (IconName::SquareDot, Color::VersionControlModified)
+            } else if tracked.added > 0 || status.untracked > 0 {
+                (IconName::SquarePlus, Color::VersionControlAdded)
+            } else if tracked.deleted > 0 {
+                (IconName::SquareMinus, Color::VersionControlDeleted)
+            } else {
+                (IconName::GitBranch, Color::Muted)
+            };
+
+            (branch_name, icon_info)
         };
 
+        let is_picker_open = self.is_picker_open(window, cx);
+        let settings = TitleBarSettings::get_global(cx);
+
         Some(
             Button::new("project_branch_trigger", branch_name)
-                .color(Color::Muted)
-                .style(ButtonStyle::Subtle)
                 .label_size(LabelSize::Small)
-                .tooltip(move |_window, cx| {
-                    Tooltip::with_meta(
-                        "Recent Branches",
-                        Some(&zed_actions::git::Branch),
-                        "Local branches only",
-                        cx,
-                    )
-                })
-                .on_click(move |_, window, cx| {
-                    let _ = workspace.update(cx, |this, cx| {
-                        window.focus(&this.active_pane().focus_handle(cx));
-                        window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
-                    });
+                .color(Color::Muted)
+                .when(!is_picker_open, |this| {
+                    this.tooltip(move |_window, cx| {
+                        Tooltip::with_meta(
+                            "Recent Branches",
+                            Some(&zed_actions::git::Branch),
+                            "Local branches only",
+                            cx,
+                        )
+                    })
                 })
                 .when(settings.show_branch_icon, |branch_button| {
-                    let (icon, icon_color) = {
-                        let status = repo.status_summary();
-                        let tracked = status.index + status.worktree;
-                        if status.conflict > 0 {
-                            (IconName::Warning, Color::VersionControlConflict)
-                        } else if tracked.modified > 0 {
-                            (IconName::SquareDot, Color::VersionControlModified)
-                        } else if tracked.added > 0 || status.untracked > 0 {
-                            (IconName::SquarePlus, Color::VersionControlAdded)
-                        } else if tracked.deleted > 0 {
-                            (IconName::SquareMinus, Color::VersionControlDeleted)
-                        } else {
-                            (IconName::GitBranch, Color::Muted)
-                        }
-                    };
-
+                    let (icon, icon_color) = icon_info;
                     branch_button
                         .icon(icon)
                         .icon_position(IconPosition::Start)
                         .icon_color(icon_color)
                         .icon_size(IconSize::Indicator)
+                })
+                .on_click(move |event, window, cx| {
+                    let position = event.position();
+                    let _ = workspace.update(cx, |this, cx| {
+                        this.set_next_modal_placement(workspace::ModalPlacement::Anchored {
+                            position,
+                        });
+                        window.focus(&this.active_pane().focus_handle(cx), cx);
+                        window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
+                    });
                 }),
         )
     }
@@ -672,7 +780,7 @@ impl TitleBar {
 
     pub fn render_sign_in_button(&mut self, _: &mut Context<Self>) -> Button {
         let client = self.client.clone();
-        Button::new("sign_in", "Sign in")
+        Button::new("sign_in", "Sign In")
             .label_size(LabelSize::Small)
             .on_click(move |_, window, cx| {
                 let client = client.clone();
@@ -794,4 +902,10 @@ impl TitleBar {
             })
             .anchor(gpui::Corner::TopRight)
     }
+
+    fn is_picker_open(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
+        self.workspace
+            .update(cx, |workspace, cx| workspace.has_active_modal(window, cx))
+            .unwrap_or(false)
+    }
 }

crates/toolchain_selector/src/active_toolchain.rs 🔗

@@ -198,10 +198,17 @@ impl ActiveToolchain {
                     .or_else(|| toolchains.toolchains.first())
                     .cloned();
                 if let Some(toolchain) = &default_choice {
+                    let worktree_root_path = project
+                        .read_with(cx, |this, cx| {
+                            this.worktree_for_id(worktree_id, cx)
+                                .map(|worktree| worktree.read(cx).abs_path())
+                        })
+                        .ok()
+                        .flatten()?;
                     workspace::WORKSPACE_DB
                         .set_toolchain(
                             workspace_id,
-                            worktree_id,
+                            worktree_root_path,
                             relative_path.clone(),
                             toolchain.clone(),
                         )

crates/toolchain_selector/src/toolchain_selector.rs 🔗

@@ -1,6 +1,7 @@
 mod active_toolchain;
 
 pub use active_toolchain::ActiveToolchain;
+use anyhow::Context as _;
 use convert_case::Casing as _;
 use editor::Editor;
 use file_finder::OpenPathDelegate;
@@ -62,6 +63,7 @@ struct AddToolchainState {
     language_name: LanguageName,
     root_path: ProjectPath,
     weak: WeakEntity<ToolchainSelector>,
+    worktree_root_path: Arc<Path>,
 }
 
 struct ScopePickerState {
@@ -99,12 +101,17 @@ impl AddToolchainState {
         root_path: ProjectPath,
         window: &mut Window,
         cx: &mut Context<ToolchainSelector>,
-    ) -> Entity<Self> {
+    ) -> anyhow::Result<Entity<Self>> {
         let weak = cx.weak_entity();
-
-        cx.new(|cx| {
+        let worktree_root_path = project
+            .read(cx)
+            .worktree_for_id(root_path.worktree_id, cx)
+            .map(|worktree| worktree.read(cx).abs_path())
+            .context("Could not find worktree")?;
+        Ok(cx.new(|cx| {
             let (lister, rx) = Self::create_path_browser_delegate(project.clone(), cx);
             let picker = cx.new(|cx| Picker::uniform_list(lister, window, cx));
+
             Self {
                 state: AddState::Path {
                     _subscription: cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
@@ -118,8 +125,9 @@ impl AddToolchainState {
                 language_name,
                 root_path,
                 weak,
+                worktree_root_path,
             }
-        })
+        }))
     }
 
     fn create_path_browser_delegate(
@@ -225,7 +233,7 @@ impl AddToolchainState {
                                 );
                             });
                             *input_state = Self::wait_for_path(rx, window, cx);
-                            this.focus_handle(cx).focus(window);
+                            this.focus_handle(cx).focus(window, cx);
                         }
                     });
                     return Err(anyhow::anyhow!("Failed to resolve toolchain"));
@@ -237,7 +245,15 @@ impl AddToolchainState {
                 // Suggest a default scope based on the applicability.
                 let scope = if let Some(project_path) = resolved_toolchain_path {
                     if !root_path.path.as_ref().is_empty() && project_path.starts_with(&root_path) {
-                        ToolchainScope::Subproject(root_path.worktree_id, root_path.path)
+                        let worktree_root_path = project
+                            .read_with(cx, |this, cx| {
+                                this.worktree_for_id(root_path.worktree_id, cx)
+                                    .map(|worktree| worktree.read(cx).abs_path())
+                            })
+                            .ok()
+                            .flatten()
+                            .context("Could not find a worktree with a given worktree ID")?;
+                        ToolchainScope::Subproject(worktree_root_path, root_path.path)
                     } else {
                         ToolchainScope::Project
                     }
@@ -260,7 +276,7 @@ impl AddToolchainState {
                         toolchain,
                         scope_picker,
                     };
-                    this.focus_handle(cx).focus(window);
+                    this.focus_handle(cx).focus(window, cx);
                 });
 
                 Result::<_, anyhow::Error>::Ok(())
@@ -333,7 +349,7 @@ impl AddToolchainState {
         });
         _ = self.weak.update(cx, |this, cx| {
             this.state = State::Search((this.create_search_state)(window, cx));
-            this.focus_handle(cx).focus(window);
+            this.focus_handle(cx).focus(window, cx);
             cx.notify();
         });
     }
@@ -383,7 +399,7 @@ impl Render for AddToolchainState {
                     &weak,
                     |this: &mut ToolchainSelector, _: &menu::Cancel, window, cx| {
                         this.state = State::Search((this.create_search_state)(window, cx));
-                        this.state.focus_handle(cx).focus(window);
+                        this.state.focus_handle(cx).focus(window, cx);
                         cx.notify();
                     },
                 ))
@@ -400,7 +416,7 @@ impl Render for AddToolchainState {
                         ToolchainScope::Global,
                         ToolchainScope::Project,
                         ToolchainScope::Subproject(
-                            self.root_path.worktree_id,
+                            self.worktree_root_path.clone(),
                             self.root_path.path.clone(),
                         ),
                     ];
@@ -693,7 +709,7 @@ impl ToolchainSelector {
         cx: &mut Context<Self>,
     ) {
         if matches!(self.state, State::Search(_)) {
-            self.state = State::AddToolchain(AddToolchainState::new(
+            let Ok(state) = AddToolchainState::new(
                 self.project.clone(),
                 self.language_name.clone(),
                 ProjectPath {
@@ -702,8 +718,11 @@ impl ToolchainSelector {
                 },
                 window,
                 cx,
-            ));
-            self.state.focus_handle(cx).focus(window);
+            ) else {
+                return;
+            };
+            self.state = State::AddToolchain(state);
+            self.state.focus_handle(cx).focus(window, cx);
             cx.notify();
         }
     }
@@ -899,11 +918,17 @@ impl PickerDelegate for ToolchainSelectorDelegate {
             {
                 let workspace = self.workspace.clone();
                 let worktree_id = self.worktree_id;
+                let worktree_abs_path_root = self.worktree_abs_path_root.clone();
                 let path = self.relative_path.clone();
                 let relative_path = self.relative_path.clone();
                 cx.spawn_in(window, async move |_, cx| {
                     workspace::WORKSPACE_DB
-                        .set_toolchain(workspace_id, worktree_id, relative_path, toolchain.clone())
+                        .set_toolchain(
+                            workspace_id,
+                            worktree_abs_path_root,
+                            relative_path,
+                            toolchain.clone(),
+                        )
                         .await
                         .log_err();
                     workspace

crates/ui/src/components.rs 🔗

@@ -17,7 +17,6 @@ mod icon;
 mod image;
 mod indent_guides;
 mod indicator;
-mod inline_code;
 mod keybinding;
 mod keybinding_hint;
 mod label;
@@ -64,7 +63,6 @@ pub use icon::*;
 pub use image::*;
 pub use indent_guides::*;
 pub use indicator::*;
-pub use inline_code::*;
 pub use keybinding::*;
 pub use keybinding_hint::*;
 pub use label::*;

crates/ui/src/components/callout.rs 🔗

@@ -121,7 +121,7 @@ impl RenderOnce for Callout {
             Severity::Info => (
                 IconName::Info,
                 Color::Muted,
-                cx.theme().colors().panel_background.opacity(0.),
+                cx.theme().status().info_background.opacity(0.1),
             ),
             Severity::Success => (
                 IconName::Check,

crates/ui/src/components/context_menu.rs 🔗

@@ -562,7 +562,7 @@ impl ContextMenu {
             action: Some(action.boxed_clone()),
             handler: Rc::new(move |context, window, cx| {
                 if let Some(context) = &context {
-                    window.focus(context);
+                    window.focus(context, cx);
                 }
                 window.dispatch_action(action.boxed_clone(), cx);
             }),
@@ -594,7 +594,7 @@ impl ContextMenu {
             action: Some(action.boxed_clone()),
             handler: Rc::new(move |context, window, cx| {
                 if let Some(context) = &context {
-                    window.focus(context);
+                    window.focus(context, cx);
                 }
                 window.dispatch_action(action.boxed_clone(), cx);
             }),
@@ -893,39 +893,57 @@ impl ContextMenu {
                 entry_render,
                 handler,
                 selectable,
+                documentation_aside,
                 ..
             } => {
                 let handler = handler.clone();
                 let menu = cx.entity().downgrade();
                 let selectable = *selectable;
-                ListItem::new(ix)
-                    .inset(true)
-                    .toggle_state(if selectable {
-                        Some(ix) == self.selected_index
-                    } else {
-                        false
+
+                div()
+                    .id(("context-menu-child", ix))
+                    .when_some(documentation_aside.clone(), |this, documentation_aside| {
+                        this.occlude()
+                            .on_hover(cx.listener(move |menu, hovered, _, cx| {
+                            if *hovered {
+                                menu.documentation_aside = Some((ix, documentation_aside.clone()));
+                            } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix)
+                            {
+                                menu.documentation_aside = None;
+                            }
+                            cx.notify();
+                        }))
                     })
-                    .selectable(selectable)
-                    .when(selectable, |item| {
-                        item.on_click({
-                            let context = self.action_context.clone();
-                            let keep_open_on_confirm = self.keep_open_on_confirm;
-                            move |_, window, cx| {
-                                handler(context.as_ref(), window, cx);
-                                menu.update(cx, |menu, cx| {
-                                    menu.clicked = true;
-
-                                    if keep_open_on_confirm {
-                                        menu.rebuild(window, cx);
-                                    } else {
-                                        cx.emit(DismissEvent);
+                    .child(
+                        ListItem::new(ix)
+                            .inset(true)
+                            .toggle_state(if selectable {
+                                Some(ix) == self.selected_index
+                            } else {
+                                false
+                            })
+                            .selectable(selectable)
+                            .when(selectable, |item| {
+                                item.on_click({
+                                    let context = self.action_context.clone();
+                                    let keep_open_on_confirm = self.keep_open_on_confirm;
+                                    move |_, window, cx| {
+                                        handler(context.as_ref(), window, cx);
+                                        menu.update(cx, |menu, cx| {
+                                            menu.clicked = true;
+
+                                            if keep_open_on_confirm {
+                                                menu.rebuild(window, cx);
+                                            } else {
+                                                cx.emit(DismissEvent);
+                                            }
+                                        })
+                                        .ok();
                                     }
                                 })
-                                .ok();
-                            }
-                        })
-                    })
-                    .child(entry_render(window, cx))
+                            })
+                            .child(entry_render(window, cx)),
+                    )
                     .into_any_element()
             }
         }

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/ui/src/components/icon.rs 🔗

@@ -126,17 +126,6 @@ enum IconSource {
     ExternalSvg(SharedString),
 }
 
-impl IconSource {
-    fn from_path(path: impl Into<SharedString>) -> Self {
-        let path = path.into();
-        if path.starts_with("icons/") {
-            Self::Embedded(path)
-        } else {
-            Self::External(Arc::from(PathBuf::from(path.as_ref())))
-        }
-    }
-}
-
 #[derive(IntoElement, RegisterComponent)]
 pub struct Icon {
     source: IconSource,
@@ -155,9 +144,18 @@ impl Icon {
         }
     }
 
+    /// Create an icon from a path. Uses a heuristic to determine if it's embedded or external:
+    /// - Paths starting with "icons/" are treated as embedded SVGs
+    /// - Other paths are treated as external raster images (from icon themes)
     pub fn from_path(path: impl Into<SharedString>) -> Self {
+        let path = path.into();
+        let source = if path.starts_with("icons/") {
+            IconSource::Embedded(path)
+        } else {
+            IconSource::External(Arc::from(PathBuf::from(path.as_ref())))
+        };
         Self {
-            source: IconSource::from_path(path),
+            source,
             color: Color::default(),
             size: IconSize::default().rems(),
             transformation: Transformation::default(),

crates/ui/src/components/inline_code.rs 🔗

@@ -1,64 +0,0 @@
-use crate::prelude::*;
-use gpui::{AnyElement, IntoElement, ParentElement, Styled};
-
-/// InlineCode mimics the way inline code is rendered when wrapped in backticks in Markdown.
-///
-/// # Usage Example
-///
-/// ```
-/// use ui::InlineCode;
-///
-/// let InlineCode = InlineCode::new("<div>hey</div>");
-/// ```
-#[derive(IntoElement, RegisterComponent)]
-pub struct InlineCode {
-    label: SharedString,
-    label_size: LabelSize,
-}
-
-impl InlineCode {
-    pub fn new(label: impl Into<SharedString>) -> Self {
-        Self {
-            label: label.into(),
-            label_size: LabelSize::Default,
-        }
-    }
-
-    /// Sets the size of the label.
-    pub fn label_size(mut self, size: LabelSize) -> Self {
-        self.label_size = size;
-        self
-    }
-}
-
-impl RenderOnce for InlineCode {
-    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
-        h_flex()
-            .min_w_0()
-            .px_0p5()
-            .overflow_hidden()
-            .bg(cx.theme().colors().text.opacity(0.05))
-            .child(Label::new(self.label).size(self.label_size).buffer_font(cx))
-    }
-}
-
-impl Component for InlineCode {
-    fn scope() -> ComponentScope {
-        ComponentScope::DataDisplay
-    }
-
-    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
-        Some(
-            v_flex()
-                .gap_6()
-                .child(
-                    example_group(vec![single_example(
-                        "Simple",
-                        InlineCode::new("zed.dev").into_any_element(),
-                    )])
-                    .vertical(),
-                )
-                .into_any_element(),
-        )
-    }
-}

crates/ui/src/components/label/label.rs 🔗

@@ -56,6 +56,12 @@ impl Label {
     pub fn set_text(&mut self, text: impl Into<SharedString>) {
         self.label = text.into();
     }
+
+    /// Truncates the label from the start, keeping the end visible.
+    pub fn truncate_start(mut self) -> Self {
+        self.base = self.base.truncate_start();
+        self
+    }
 }
 
 // Style methods.
@@ -256,7 +262,8 @@ impl Component for Label {
                         "Special Cases",
                         vec![
                             single_example("Single Line", Label::new("Line 1\nLine 2\nLine 3").single_line().into_any_element()),
-                            single_example("Text Ellipsis", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()),
+                            single_example("Regular Truncation", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()),
+                            single_example("Start Truncation", div().max_w_24().child(Label::new("zed/crates/ui/src/components/label/truncate/label/label.rs").truncate_start()).into_any_element()),
                         ],
                     ),
                 ])

crates/ui/src/components/label/label_like.rs 🔗

@@ -56,7 +56,7 @@ pub trait LabelCommon {
     /// Sets the alpha property of the label, overwriting the alpha value of the color.
     fn alpha(self, alpha: f32) -> Self;
 
-    /// Truncates overflowing text with an ellipsis (`…`) if needed.
+    /// Truncates overflowing text with an ellipsis (`…`) at the end if needed.
     fn truncate(self) -> Self;
 
     /// Sets the label to render as a single line.
@@ -88,6 +88,7 @@ pub struct LabelLike {
     underline: bool,
     single_line: bool,
     truncate: bool,
+    truncate_start: bool,
 }
 
 impl Default for LabelLike {
@@ -113,6 +114,7 @@ impl LabelLike {
             underline: false,
             single_line: false,
             truncate: false,
+            truncate_start: false,
         }
     }
 }
@@ -126,6 +128,12 @@ impl LabelLike {
     gpui::margin_style_methods!({
         visibility: pub
     });
+
+    /// Truncates overflowing text with an ellipsis (`…`) at the start if needed.
+    pub fn truncate_start(mut self) -> Self {
+        self.truncate_start = true;
+        self
+    }
 }
 
 impl LabelCommon for LabelLike {
@@ -169,7 +177,7 @@ impl LabelCommon for LabelLike {
         self
     }
 
-    /// Truncates overflowing text with an ellipsis (`…`) if needed.
+    /// Truncates overflowing text with an ellipsis (`…`) at the end if needed.
     fn truncate(mut self) -> Self {
         self.truncate = true;
         self
@@ -233,7 +241,16 @@ impl RenderOnce for LabelLike {
             .when(self.strikethrough, |this| this.line_through())
             .when(self.single_line, |this| this.whitespace_nowrap())
             .when(self.truncate, |this| {
-                this.overflow_x_hidden().text_ellipsis()
+                this.min_w_0()
+                    .overflow_x_hidden()
+                    .whitespace_nowrap()
+                    .text_ellipsis()
+            })
+            .when(self.truncate_start, |this| {
+                this.min_w_0()
+                    .overflow_x_hidden()
+                    .whitespace_nowrap()
+                    .text_ellipsis_start()
             })
             .text_color(color)
             .font_weight(

crates/ui/src/components/navigable.rs 🔗

@@ -75,7 +75,7 @@ impl RenderOnce for Navigable {
                         })
                         .unwrap_or(0);
                     if let Some(entry) = children.get(target) {
-                        entry.focus_handle.focus(window);
+                        entry.focus_handle.focus(window, cx);
                         if let Some(anchor) = &entry.scroll_anchor {
                             anchor.scroll_to(window, cx);
                         }
@@ -89,7 +89,7 @@ impl RenderOnce for Navigable {
                         .and_then(|index| index.checked_sub(1))
                         .or(children.len().checked_sub(1));
                     if let Some(entry) = target.and_then(|target| children.get(target)) {
-                        entry.focus_handle.focus(window);
+                        entry.focus_handle.focus(window, cx);
                         if let Some(anchor) = &entry.scroll_anchor {
                             anchor.scroll_to(window, cx);
                         }

crates/ui/src/components/notification/alert_modal.rs 🔗

@@ -1,73 +1,161 @@
 use crate::component_prelude::*;
 use crate::prelude::*;
+use crate::{Checkbox, ListBulletItem, ToggleState};
+use gpui::Action;
+use gpui::FocusHandle;
 use gpui::IntoElement;
+use gpui::Stateful;
 use smallvec::{SmallVec, smallvec};
+use theme::ActiveTheme;
+
+type ActionHandler = Box<dyn FnOnce(Stateful<Div>) -> Stateful<Div>>;
 
 #[derive(IntoElement, RegisterComponent)]
 pub struct AlertModal {
     id: ElementId,
+    header: Option<AnyElement>,
     children: SmallVec<[AnyElement; 2]>,
-    title: SharedString,
-    primary_action: SharedString,
-    dismiss_label: SharedString,
+    footer: Option<AnyElement>,
+    title: Option<SharedString>,
+    primary_action: Option<SharedString>,
+    dismiss_label: Option<SharedString>,
+    width: Option<DefiniteLength>,
+    key_context: Option<String>,
+    action_handlers: Vec<ActionHandler>,
+    focus_handle: Option<FocusHandle>,
 }
 
 impl AlertModal {
-    pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
+    pub fn new(id: impl Into<ElementId>) -> Self {
         Self {
             id: id.into(),
+            header: None,
             children: smallvec![],
-            title: title.into(),
-            primary_action: "Ok".into(),
-            dismiss_label: "Cancel".into(),
+            footer: None,
+            title: None,
+            primary_action: None,
+            dismiss_label: None,
+            width: None,
+            key_context: None,
+            action_handlers: Vec::new(),
+            focus_handle: None,
         }
     }
 
+    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
+        self.title = Some(title.into());
+        self
+    }
+
+    pub fn header(mut self, header: impl IntoElement) -> Self {
+        self.header = Some(header.into_any_element());
+        self
+    }
+
+    pub fn footer(mut self, footer: impl IntoElement) -> Self {
+        self.footer = Some(footer.into_any_element());
+        self
+    }
+
     pub fn primary_action(mut self, primary_action: impl Into<SharedString>) -> Self {
-        self.primary_action = primary_action.into();
+        self.primary_action = Some(primary_action.into());
         self
     }
 
     pub fn dismiss_label(mut self, dismiss_label: impl Into<SharedString>) -> Self {
-        self.dismiss_label = dismiss_label.into();
+        self.dismiss_label = Some(dismiss_label.into());
+        self
+    }
+
+    pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
+        self.width = Some(width.into());
+        self
+    }
+
+    pub fn key_context(mut self, key_context: impl Into<String>) -> Self {
+        self.key_context = Some(key_context.into());
+        self
+    }
+
+    pub fn on_action<A: Action>(
+        mut self,
+        listener: impl Fn(&A, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.action_handlers
+            .push(Box::new(move |div| div.on_action(listener)));
+        self
+    }
+
+    pub fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self {
+        self.focus_handle = Some(focus_handle.clone());
         self
     }
 }
 
 impl RenderOnce for AlertModal {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
-        v_flex()
+        let width = self.width.unwrap_or_else(|| px(440.).into());
+        let has_default_footer = self.primary_action.is_some() || self.dismiss_label.is_some();
+
+        let mut modal = v_flex()
+            .when_some(self.key_context, |this, key_context| {
+                this.key_context(key_context.as_str())
+            })
+            .when_some(self.focus_handle, |this, focus_handle| {
+                this.track_focus(&focus_handle)
+            })
             .id(self.id)
             .elevation_3(cx)
-            .w(px(440.))
-            .p_5()
-            .child(
+            .w(width)
+            .bg(cx.theme().colors().elevated_surface_background)
+            .overflow_hidden();
+
+        for handler in self.action_handlers {
+            modal = handler(modal);
+        }
+
+        if let Some(header) = self.header {
+            modal = modal.child(header);
+        } else if let Some(title) = self.title {
+            modal = modal.child(
+                v_flex()
+                    .pt_3()
+                    .pr_3()
+                    .pl_3()
+                    .pb_1()
+                    .child(Headline::new(title).size(HeadlineSize::Small)),
+            );
+        }
+
+        if !self.children.is_empty() {
+            modal = modal.child(
                 v_flex()
+                    .p_3()
                     .text_ui(cx)
                     .text_color(Color::Muted.color(cx))
                     .gap_1()
-                    .child(Headline::new(self.title).size(HeadlineSize::Small))
                     .children(self.children),
-            )
-            .child(
+            );
+        }
+
+        if let Some(footer) = self.footer {
+            modal = modal.child(footer);
+        } else if has_default_footer {
+            let primary_action = self.primary_action.unwrap_or_else(|| "Ok".into());
+            let dismiss_label = self.dismiss_label.unwrap_or_else(|| "Cancel".into());
+
+            modal = modal.child(
                 h_flex()
-                    .h(rems(1.75))
+                    .p_3()
                     .items_center()
-                    .child(div().flex_1())
-                    .child(
-                        h_flex()
-                            .items_center()
-                            .gap_1()
-                            .child(
-                                Button::new(self.dismiss_label.clone(), self.dismiss_label.clone())
-                                    .color(Color::Muted),
-                            )
-                            .child(Button::new(
-                                self.primary_action.clone(),
-                                self.primary_action,
-                            )),
-                    ),
-            )
+                    .justify_end()
+                    .gap_1()
+                    .child(Button::new(dismiss_label.clone(), dismiss_label).color(Color::Muted))
+                    .child(Button::new(primary_action.clone(), primary_action)),
+            );
+        }
+
+        modal
     }
 }
 
@@ -90,24 +178,75 @@ impl Component for AlertModal {
         Some("A modal dialog that presents an alert message with primary and dismiss actions.")
     }
 
-    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
         Some(
             v_flex()
                 .gap_6()
                 .p_4()
-                .children(vec![example_group(
-                    vec![
-                        single_example(
-                            "Basic Alert",
-                            AlertModal::new("simple-modal", "Do you want to leave the current call?")
-                                .child("The current window will be closed, and connections to any shared projects will be terminated."
-                                )
-                                .primary_action("Leave Call")
-                                .into_any_element(),
-                        )
-                    ],
-                )])
-                .into_any_element()
+                .children(vec![
+                    example_group(vec![single_example(
+                        "Basic Alert",
+                        AlertModal::new("simple-modal")
+                            .title("Do you want to leave the current call?")
+                            .child(
+                                "The current window will be closed, and connections to any shared projects will be terminated."
+                            )
+                            .primary_action("Leave Call")
+                            .dismiss_label("Cancel")
+                            .into_any_element(),
+                    )]),
+                    example_group(vec![single_example(
+                        "Custom Header",
+                        AlertModal::new("custom-header-modal")
+                            .header(
+                                v_flex()
+                                    .p_3()
+                                    .bg(cx.theme().colors().background)
+                                    .gap_1()
+                                    .child(
+                                        h_flex()
+                                            .gap_1()
+                                            .child(Icon::new(IconName::Warning).color(Color::Warning))
+                                            .child(Headline::new("Unrecognized Workspace").size(HeadlineSize::Small))
+                                    )
+                                    .child(
+                                        h_flex()
+                                            .pl(IconSize::default().rems() + rems(0.5))
+                                            .child(Label::new("~/projects/my-project").color(Color::Muted))
+                                    )
+                            )
+                            .child(
+                                "Untrusted workspaces are opened in Restricted Mode to protect your system.
+Review .zed/settings.json for any extensions or commands configured by this project.",
+                            )
+                            .child(
+                                v_flex()
+                                    .mt_1()
+                                    .child(Label::new("Restricted mode prevents:").color(Color::Muted))
+                                    .child(ListBulletItem::new("Project settings from being applied"))
+                                    .child(ListBulletItem::new("Language servers from running"))
+                                    .child(ListBulletItem::new("MCP integrations from installing"))
+                            )
+                            .footer(
+                                h_flex()
+                                    .p_3()
+                                    .justify_between()
+                                    .child(
+                                        Checkbox::new("trust-parent", ToggleState::Unselected)
+                                            .label("Trust all projects in parent directory")
+                                    )
+                                    .child(
+                                        h_flex()
+                                            .gap_1()
+                                            .child(Button::new("restricted", "Stay in Restricted Mode").color(Color::Muted))
+                                            .child(Button::new("trust", "Trust and Continue").style(ButtonStyle::Filled))
+                                    )
+                            )
+                            .width(rems(40.))
+                            .into_any_element(),
+                    )]),
+                ])
+                .into_any_element(),
         )
     }
 }

crates/ui/src/components/popover_menu.rs 🔗

@@ -281,13 +281,25 @@ fn show_menu<M: ManagedView>(
             if modal.focus_handle(cx).contains_focused(window, cx)
                 && let Some(previous_focus_handle) = previous_focus_handle.as_ref()
             {
-                window.focus(previous_focus_handle);
+                window.focus(previous_focus_handle, cx);
             }
             *menu2.borrow_mut() = None;
             window.refresh();
         })
         .detach();
-    window.focus(&new_menu.focus_handle(cx));
+
+    // Since menus are rendered in a deferred fashion, their focus handles are
+    // not linked in the dispatch tree until after the deferred draw callback
+    // runs. We need to wait for that to happen before focusing it, so that
+    // calling `contains_focused` on the parent's focus handle returns `true`
+    // when the menu is focused. This prevents the pane's tab bar buttons from
+    // flickering when opening popover menus.
+    let focus_handle = new_menu.focus_handle(cx);
+    window.on_next_frame(move |window, _cx| {
+        window.on_next_frame(move |window, cx| {
+            window.focus(&focus_handle, cx);
+        });
+    });
     *menu.borrow_mut() = Some(new_menu);
     window.refresh();
 

crates/ui/src/components/right_click_menu.rs 🔗

@@ -253,13 +253,25 @@ impl<M: ManagedView> Element for RightClickMenu<M> {
                                     && let Some(previous_focus_handle) =
                                         previous_focus_handle.as_ref()
                                 {
-                                    window.focus(previous_focus_handle);
+                                    window.focus(previous_focus_handle, cx);
                                 }
                                 *menu2.borrow_mut() = None;
                                 window.refresh();
                             })
                             .detach();
-                        window.focus(&new_menu.focus_handle(cx));
+
+                        // Since menus are rendered in a deferred fashion, their focus handles are
+                        // not linked in the dispatch tree until after the deferred draw callback
+                        // runs. We need to wait for that to happen before focusing it, so that
+                        // calling `contains_focused` on the parent's focus handle returns `true`
+                        // when the menu is focused. This prevents the pane's tab bar buttons from
+                        // flickering when opening menus.
+                        let focus_handle = new_menu.focus_handle(cx);
+                        window.on_next_frame(move |window, _cx| {
+                            window.on_next_frame(move |window, cx| {
+                                window.focus(&focus_handle, cx);
+                            });
+                        });
                         *menu.borrow_mut() = Some(new_menu);
                         *position.borrow_mut() = if let Some(child_bounds) = child_bounds {
                             if let Some(attach) = attach {

crates/ui_input/src/number_field.rs 🔗

@@ -5,8 +5,11 @@ use std::{
     str::FromStr,
 };
 
-use editor::{Editor, EditorStyle};
-use gpui::{ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers};
+use editor::Editor;
+use gpui::{
+    ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers, TextAlign,
+    TextStyleRefinement,
+};
 
 use settings::{CenteredPaddingSettings, CodeFade, DelayMs, InactiveOpacity, MinimumContrast};
 use ui::prelude::*;
@@ -309,6 +312,11 @@ impl<T: NumberFieldType> NumberField<T> {
         self
     }
 
+    pub fn mode(self, mode: NumberFieldMode, cx: &mut App) -> Self {
+        self.mode.write(cx, mode);
+        self
+    }
+
     pub fn on_reset(
         mut self,
         on_reset: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -451,9 +459,11 @@ impl<T: NumberFieldType> RenderOnce for NumberField<T> {
                                         |window, cx| {
                                             let previous_focus_handle = window.focused(cx);
                                             let mut editor = Editor::single_line(window, cx);
-                                            let mut style = EditorStyle::default();
-                                            style.text.text_align = gpui::TextAlign::Right;
-                                            editor.set_style(style, window, cx);
+
+                                            editor.set_text_style_refinement(TextStyleRefinement {
+                                                text_align: Some(TextAlign::Center),
+                                                ..Default::default()
+                                            });
 
                                             editor.set_text(format!("{}", self.value), window, cx);
                                             cx.on_focus_out(&editor.focus_handle(cx), window, {
@@ -476,7 +486,7 @@ impl<T: NumberFieldType> RenderOnce for NumberField<T> {
                                                         if let Some(previous) =
                                                             previous_focus_handle.as_ref()
                                                         {
-                                                            window.focus(previous);
+                                                            window.focus(previous, cx);
                                                         }
                                                         on_change(&new_value, window, cx);
                                                     };
@@ -485,7 +495,7 @@ impl<T: NumberFieldType> RenderOnce for NumberField<T> {
                                             })
                                             .detach();
 
-                                            window.focus(&editor.focus_handle(cx));
+                                            window.focus(&editor.focus_handle(cx), cx);
 
                                             editor
                                         }
@@ -555,22 +565,36 @@ impl Component for NumberField<usize> {
         Some(
             v_flex()
                 .gap_6()
-                .children(vec![single_example(
-                    "Default Numeric Stepper",
-                    NumberField::new(
-                        "numeric-stepper-component-preview",
-                        *stepper_example.read(cx),
-                        window,
-                        cx,
-                    )
-                    .on_change({
-                        let stepper_example = stepper_example.clone();
-                        move |value, _, cx| stepper_example.write(cx, *value)
-                    })
-                    .min(1.0)
-                    .max(100.0)
-                    .into_any_element(),
-                )])
+                .children(vec![
+                    single_example(
+                        "Default Number Field",
+                        NumberField::new("number-field", *stepper_example.read(cx), window, cx)
+                            .on_change({
+                                let stepper_example = stepper_example.clone();
+                                move |value, _, cx| stepper_example.write(cx, *value)
+                            })
+                            .min(1.0)
+                            .max(100.0)
+                            .into_any_element(),
+                    ),
+                    single_example(
+                        "Read-Only Number Field",
+                        NumberField::new(
+                            "editable-number-field",
+                            *stepper_example.read(cx),
+                            window,
+                            cx,
+                        )
+                        .on_change({
+                            let stepper_example = stepper_example.clone();
+                            move |value, _, cx| stepper_example.write(cx, *value)
+                        })
+                        .min(1.0)
+                        .max(100.0)
+                        .mode(NumberFieldMode::Edit, cx)
+                        .into_any_element(),
+                    ),
+                ])
                 .into_any_element(),
         )
     }

crates/util/src/redact.rs 🔗

@@ -1,3 +1,9 @@
+use std::sync::LazyLock;
+
+static REDACT_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
+    regex::Regex::new(r#"([A-Z_][A-Z0-9_]*)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)"#).unwrap()
+});
+
 /// Whether a given environment variable name should have its value redacted
 pub fn should_redact(env_var_name: &str) -> bool {
     const REDACTED_SUFFIXES: &[&str] = &[
@@ -13,3 +19,31 @@ pub fn should_redact(env_var_name: &str) -> bool {
         .iter()
         .any(|suffix| env_var_name.ends_with(suffix))
 }
+
+/// Redact a string which could include a command with environment variables
+pub fn redact_command(command: &str) -> String {
+    REDACT_REGEX
+        .replace_all(command, |caps: &regex::Captures| {
+            let var_name = &caps[1];
+            let value = &caps[2];
+            if should_redact(var_name) {
+                format!(r#"{}="[REDACTED]""#, var_name)
+            } else {
+                format!("{}={}", var_name, value)
+            }
+        })
+        .to_string()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_redact_string_with_multiple_env_vars() {
+        let input = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="sk-ant-api03-WOOOO" COMMAND_MODE="unix2003" GEMINI_API_KEY="AIGEMINIFACE" HOME="/Users/foo""#;
+        let result = redact_command(input);
+        let expected = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="[REDACTED]" COMMAND_MODE="unix2003" GEMINI_API_KEY="[REDACTED]" HOME="/Users/foo""#;
+        assert_eq!(result, expected);
+    }
+}

crates/vim/src/command.rs 🔗

@@ -230,6 +230,14 @@ struct VimEdit {
     pub filename: String,
 }
 
+/// Pastes the specified file's contents.
+#[derive(Clone, PartialEq, Action)]
+#[action(namespace = vim, no_json, no_register)]
+struct VimRead {
+    pub range: Option<CommandRange>,
+    pub filename: String,
+}
+
 #[derive(Clone, PartialEq, Action)]
 #[action(namespace = vim, no_json, no_register)]
 struct VimNorm {
@@ -330,10 +338,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
                 let Some(range) = range.buffer_range(vim, editor, window, cx).ok() else {
                     return;
                 };
-                let Some((line_ending, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
+                let Some((line_ending, encoding, has_bom, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
                     Some(multi.as_singleton()?.update(cx, |buffer, _| {
                         (
                             buffer.line_ending(),
+                            buffer.encoding(),
+                            buffer.has_bom(),
                             buffer.as_rope().slice_rows(range.start.0..range.end.0 + 1),
                             range.start.0 == 0 && range.end.0 + 1 >= buffer.row_count(),
                         )
@@ -429,7 +439,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
                                     return;
                                 };
                                 worktree
-                                    .write_file(path.into_arc(), text.clone(), line_ending, cx)
+                                    .write_file(path.into_arc(), text.clone(), line_ending, encoding, has_bom, cx)
                                     .detach_and_prompt_err("Failed to write lines", window, cx, |_, _, _| None);
                             });
                         })
@@ -641,6 +651,107 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
         });
     });
 
+    Vim::action(editor, cx, |vim, action: &VimRead, window, cx| {
+        vim.update_editor(cx, |vim, editor, cx| {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let end = if let Some(range) = action.range.clone() {
+                let Some(multi_range) = range.buffer_range(vim, editor, window, cx).log_err()
+                else {
+                    return;
+                };
+
+                match &range.start {
+                    // inserting text above the first line uses the command ":0r {name}"
+                    Position::Line { row: 0, offset: 0 } if range.end.is_none() => {
+                        snapshot.clip_point(Point::new(0, 0), Bias::Right)
+                    }
+                    _ => snapshot.clip_point(Point::new(multi_range.end.0 + 1, 0), Bias::Right),
+                }
+            } else {
+                let end_row = editor
+                    .selections
+                    .newest::<Point>(&editor.display_snapshot(cx))
+                    .range()
+                    .end
+                    .row;
+                snapshot.clip_point(Point::new(end_row + 1, 0), Bias::Right)
+            };
+            let is_end_of_file = end == snapshot.max_point();
+            let edit_range = snapshot.anchor_before(end)..snapshot.anchor_before(end);
+
+            let mut text = if is_end_of_file {
+                String::from('\n')
+            } else {
+                String::new()
+            };
+
+            let mut task = None;
+            if action.filename.is_empty() {
+                text.push_str(
+                    &editor
+                        .buffer()
+                        .read(cx)
+                        .as_singleton()
+                        .map(|buffer| buffer.read(cx).text())
+                        .unwrap_or_default(),
+                );
+            } else {
+                if let Some(project) = editor.project().cloned() {
+                    project.update(cx, |project, cx| {
+                        let Some(worktree) = project.visible_worktrees(cx).next() else {
+                            return;
+                        };
+                        let path_style = worktree.read(cx).path_style();
+                        let Some(path) =
+                            RelPath::new(Path::new(&action.filename), path_style).log_err()
+                        else {
+                            return;
+                        };
+                        task =
+                            Some(worktree.update(cx, |worktree, cx| worktree.load_file(&path, cx)));
+                    });
+                } else {
+                    return;
+                }
+            };
+
+            cx.spawn_in(window, async move |editor, cx| {
+                if let Some(task) = task {
+                    text.push_str(
+                        &task
+                            .await
+                            .log_err()
+                            .map(|loaded_file| loaded_file.text)
+                            .unwrap_or_default(),
+                    );
+                }
+
+                if !text.is_empty() && !is_end_of_file {
+                    text.push('\n');
+                }
+
+                let _ = editor.update_in(cx, |editor, window, cx| {
+                    editor.transact(window, cx, |editor, window, cx| {
+                        editor.edit([(edit_range.clone(), text)], cx);
+                        let snapshot = editor.buffer().read(cx).snapshot(cx);
+                        editor.change_selections(Default::default(), window, cx, |s| {
+                            let point = if is_end_of_file {
+                                Point::new(
+                                    edit_range.start.to_point(&snapshot).row.saturating_add(1),
+                                    0,
+                                )
+                            } else {
+                                Point::new(edit_range.start.to_point(&snapshot).row, 0)
+                            };
+                            s.select_ranges([point..point]);
+                        })
+                    });
+                });
+            })
+            .detach();
+        });
+    });
+
     Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
         let keystrokes = action
             .command
@@ -1336,6 +1447,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
         VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
             .bang(editor::actions::ReloadFile)
             .filename(|_, filename| Some(VimEdit { filename }.boxed_clone())),
+        VimCommand::new(
+            ("r", "ead"),
+            VimRead {
+                range: None,
+                filename: "".into(),
+            },
+        )
+        .filename(|_, filename| {
+            Some(
+                VimRead {
+                    range: None,
+                    filename,
+                }
+                .boxed_clone(),
+            )
+        })
+        .range(|action, range| {
+            let mut action: VimRead = action.as_any().downcast_ref::<VimRead>().unwrap().clone();
+            action.range.replace(range.clone());
+            Some(Box::new(action))
+        }),
         VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| {
             Some(
                 VimSplit {
@@ -2573,6 +2705,76 @@ mod test {
         assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
     }
 
+    #[gpui::test]
+    async fn test_command_read(cx: &mut TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
+        let path = Path::new(path!("/root/dir/other.rs"));
+        fs.as_fake().insert_file(path, "1\n2\n3".into()).await;
+
+        cx.workspace(|workspace, _, cx| {
+            assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
+        });
+
+        // File without trailing newline
+        cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal);
+
+        cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal);
+
+        cx.set_state("one\nˇtwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal);
+
+        cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal);
+
+        // Empty filename
+        cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": r");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal);
+
+        // File with trailing newline
+        fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await;
+        cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
+
+        cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal);
+
+        cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal);
+
+        cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
+
+        // Empty file
+        fs.as_fake().insert_file(path, "".into()).await;
+        cx.set_state("ˇone\ntwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\nˇtwo\nthree", Mode::Normal);
+    }
+
     #[gpui::test]
     async fn test_command_quit(cx: &mut TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;

crates/vim/src/motion.rs 🔗

@@ -1,5 +1,5 @@
 use editor::{
-    Anchor, Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, RowExt, ToOffset, ToPoint,
+    Anchor, Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, RowExt, ToOffset,
     display_map::{DisplayRow, DisplaySnapshot, FoldPoint, ToDisplayPoint},
     movement::{
         self, FindRange, TextLayoutDetails, find_boundary, find_preceding_boundary_display_point,
@@ -2262,7 +2262,6 @@ fn go_to_line(map: &DisplaySnapshot, display_point: DisplayPoint, line: usize) -
             .offset_to_point(excerpt.map_offset_from_buffer(BufferOffset(offset)));
         return map.clip_point(map.point_to_display_point(point, Bias::Left), Bias::Left);
     }
-    let mut last_position = None;
     for (excerpt, buffer, range) in map.buffer_snapshot().excerpts() {
         let excerpt_range = language::ToOffset::to_offset(&range.context.start, buffer)
             ..language::ToOffset::to_offset(&range.context.end, buffer);
@@ -2273,14 +2272,9 @@ fn go_to_line(map: &DisplaySnapshot, display_point: DisplayPoint, line: usize) -
         } else if offset <= excerpt_range.start {
             let anchor = Anchor::in_buffer(excerpt, range.context.start);
             return anchor.to_display_point(map);
-        } else {
-            last_position = Some(Anchor::in_buffer(excerpt, range.context.end));
         }
     }
 
-    let mut last_point = last_position.unwrap().to_point(&map.buffer_snapshot());
-    last_point.column = point.column;
-
     map.clip_point(
         map.point_to_display_point(
             map.buffer_snapshot().clip_point(point, Bias::Left),

crates/vim/src/object.rs 🔗

@@ -911,7 +911,7 @@ pub fn surrounding_html_tag(
     while let Some(cur_node) = last_child_node {
         if cur_node.child_count() >= 2 {
             let first_child = cur_node.child(0);
-            let last_child = cur_node.child(cur_node.child_count() - 1);
+            let last_child = cur_node.child(cur_node.child_count() as u32 - 1);
             if let (Some(first_child), Some(last_child)) = (first_child, last_child) {
                 let open_tag = open_tag(buffer.chars_for_range(first_child.byte_range()));
                 let close_tag = close_tag(buffer.chars_for_range(last_child.byte_range()));
@@ -2807,9 +2807,8 @@ mod test {
 
         for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES {
             cx.set_state(initial_state, Mode::Normal);
-
+            cx.buffer(|buffer, _| buffer.parsing_idle()).await;
             cx.simulate_keystrokes(keystrokes);
-
             cx.assert_state(expected_state, *expected_mode);
         }
 
@@ -2830,9 +2829,8 @@ mod test {
 
         for (keystrokes, initial_state, mode) in INVALID_CASES {
             cx.set_state(initial_state, Mode::Normal);
-
+            cx.buffer(|buffer, _| buffer.parsing_idle()).await;
             cx.simulate_keystrokes(keystrokes);
-
             cx.assert_state(initial_state, *mode);
         }
     }
@@ -3185,9 +3183,8 @@ mod test {
 
         for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES {
             cx.set_state(initial_state, Mode::Normal);
-
+            cx.buffer(|buffer, _| buffer.parsing_idle()).await;
             cx.simulate_keystrokes(keystrokes);
-
             cx.assert_state(expected_state, *expected_mode);
         }
 
@@ -3208,9 +3205,8 @@ mod test {
 
         for (keystrokes, initial_state, mode) in INVALID_CASES {
             cx.set_state(initial_state, Mode::Normal);
-
+            cx.buffer(|buffer, _| buffer.parsing_idle()).await;
             cx.simulate_keystrokes(keystrokes);
-
             cx.assert_state(initial_state, *mode);
         }
     }
@@ -3411,4 +3407,390 @@ mod test {
             .assert_eq("    ˇf = (x: unknown) => {");
         cx.shared_clipboard().await.assert_eq("const ");
     }
+
+    #[gpui::test]
+    async fn test_arrow_function_text_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new_typescript(cx).await;
+
+        cx.set_state(
+            indoc! {"
+                const foo = () => {
+                    return ˇ1;
+                };
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                «const foo = () => {
+                    return 1;
+                };ˇ»
+            "},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {"
+                arr.map(() => {
+                    return ˇ1;
+                });
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                arr.map(«() => {
+                    return 1;
+                }ˇ»);
+            "},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {"
+                const foo = () => {
+                    return ˇ1;
+                };
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v i f");
+        cx.assert_state(
+            indoc! {"
+                const foo = () => {
+                    «return 1;ˇ»
+                };
+            "},
+            Mode::Visual,
+        );
+
+        cx.set_state(
+            indoc! {"
+                (() => {
+                    console.log(ˇ1);
+                })();
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                («() => {
+                    console.log(1);
+                }ˇ»)();
+            "},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {"
+                const foo = () => {
+                    return ˇ1;
+                };
+                export { foo };
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                «const foo = () => {
+                    return 1;
+                };ˇ»
+                export { foo };
+            "},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {"
+                let bar = () => {
+                    return ˇ2;
+                };
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                «let bar = () => {
+                    return 2;
+                };ˇ»
+            "},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {"
+                var baz = () => {
+                    return ˇ3;
+                };
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                «var baz = () => {
+                    return 3;
+                };ˇ»
+            "},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {"
+                const add = (a, b) => a + ˇb;
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                «const add = (a, b) => a + b;ˇ»
+            "},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {"
+                const add = ˇ(a, b) => a + b;
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                «const add = (a, b) => a + b;ˇ»
+            "},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {"
+                const add = (a, b) => a + bˇ;
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                «const add = (a, b) => a + b;ˇ»
+            "},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {"
+                const add = (a, b) =ˇ> a + b;
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {"
+                «const add = (a, b) => a + b;ˇ»
+            "},
+            Mode::VisualLine,
+        );
+    }
+
+    #[gpui::test]
+    async fn test_arrow_function_in_jsx(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new_tsx(cx).await;
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() => {
+                        alert("Hello world!");
+                        console.log(ˇ"clicked");
+                      }}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={«() => {
+                        alert("Hello world!");
+                        console.log("clicked");
+                      }ˇ»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() => console.log("clickˇed")}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={«() => console.log("clicked")ˇ»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={ˇ() => console.log("clicked")}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={«() => console.log("clicked")ˇ»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() => console.log("clicked"ˇ)}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={«() => console.log("clicked")ˇ»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() =ˇ> console.log("clicked")}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={«() => console.log("clicked")ˇ»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() => {
+                        console.log("cliˇcked");
+                      }}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={«() => {
+                        console.log("clicked");
+                      }ˇ»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() => fˇoo()}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={«() => foo()ˇ»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+    }
 }

crates/vim/src/vim.rs 🔗

@@ -1943,6 +1943,7 @@ impl Vim {
             editor.set_collapse_matches(collapse_matches);
             editor.set_input_enabled(vim.editor_input_enabled());
             editor.set_autoindent(vim.should_autoindent());
+            editor.set_cursor_offset_on_selection(vim.mode.is_visual());
             editor
                 .selections
                 .set_line_mode(matches!(vim.mode, Mode::VisualLine));

crates/vim/src/visual.rs 🔗

@@ -522,12 +522,16 @@ impl Vim {
                                             selection.start = original_point.to_display_point(map)
                                         }
                                     } else {
-                                        selection.end = movement::saturating_right(
-                                            map,
-                                            original_point.to_display_point(map),
-                                        );
-                                        if original_point.column > 0 {
-                                            selection.reversed = true
+                                        let original_display_point =
+                                            original_point.to_display_point(map);
+                                        if selection.end <= original_display_point {
+                                            selection.end = movement::saturating_right(
+                                                map,
+                                                original_display_point,
+                                            );
+                                            if original_point.column > 0 {
+                                                selection.reversed = true
+                                            }
                                         }
                                     }
                                 }

crates/which_key/Cargo.toml 🔗

@@ -0,0 +1,23 @@
+[package]
+name = "which_key"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/which_key.rs"
+doctest = false
+
+[dependencies]
+command_palette.workspace = true
+gpui.workspace = true
+serde.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true

crates/which_key/src/which_key.rs 🔗

@@ -0,0 +1,98 @@
+//! Which-key support for Zed.
+
+mod which_key_modal;
+mod which_key_settings;
+
+use gpui::{App, Keystroke};
+use settings::Settings;
+use std::{sync::LazyLock, time::Duration};
+use util::ResultExt;
+use which_key_modal::WhichKeyModal;
+use which_key_settings::WhichKeySettings;
+use workspace::Workspace;
+
+pub fn init(cx: &mut App) {
+    WhichKeySettings::register(cx);
+
+    cx.observe_new(|_: &mut Workspace, window, cx| {
+        let Some(window) = window else {
+            return;
+        };
+        let mut timer = None;
+        cx.observe_pending_input(window, move |workspace, window, cx| {
+            if window.pending_input_keystrokes().is_none() {
+                if let Some(modal) = workspace.active_modal::<WhichKeyModal>(cx) {
+                    modal.update(cx, |modal, cx| modal.dismiss(cx));
+                };
+                timer.take();
+                return;
+            }
+
+            let which_key_settings = WhichKeySettings::get_global(cx);
+            if !which_key_settings.enabled {
+                return;
+            }
+
+            let delay_ms = which_key_settings.delay_ms;
+
+            timer.replace(cx.spawn_in(window, async move |workspace_handle, cx| {
+                cx.background_executor()
+                    .timer(Duration::from_millis(delay_ms))
+                    .await;
+                workspace_handle
+                    .update_in(cx, |workspace, window, cx| {
+                        if workspace.active_modal::<WhichKeyModal>(cx).is_some() {
+                            return;
+                        };
+
+                        workspace.toggle_modal(window, cx, |window, cx| {
+                            WhichKeyModal::new(workspace_handle.clone(), window, cx)
+                        });
+                    })
+                    .log_err();
+            }));
+        })
+        .detach();
+    })
+    .detach();
+}
+
+// Hard-coded list of keystrokes to filter out from which-key display
+pub static FILTERED_KEYSTROKES: LazyLock<Vec<Vec<Keystroke>>> = LazyLock::new(|| {
+    [
+        // Modifiers on normal vim commands
+        "g h",
+        "g j",
+        "g k",
+        "g l",
+        "g $",
+        "g ^",
+        // Duplicate keys with "ctrl" held, e.g. "ctrl-w ctrl-a" is duplicate of "ctrl-w a"
+        "ctrl-w ctrl-a",
+        "ctrl-w ctrl-c",
+        "ctrl-w ctrl-h",
+        "ctrl-w ctrl-j",
+        "ctrl-w ctrl-k",
+        "ctrl-w ctrl-l",
+        "ctrl-w ctrl-n",
+        "ctrl-w ctrl-o",
+        "ctrl-w ctrl-p",
+        "ctrl-w ctrl-q",
+        "ctrl-w ctrl-s",
+        "ctrl-w ctrl-v",
+        "ctrl-w ctrl-w",
+        "ctrl-w ctrl-]",
+        "ctrl-w ctrl-shift-w",
+        "ctrl-w ctrl-g t",
+        "ctrl-w ctrl-g shift-t",
+    ]
+    .iter()
+    .filter_map(|s| {
+        let keystrokes: Result<Vec<_>, _> = s
+            .split(' ')
+            .map(|keystroke_str| Keystroke::parse(keystroke_str))
+            .collect();
+        keystrokes.ok()
+    })
+    .collect()
+});

crates/which_key/src/which_key_modal.rs 🔗

@@ -0,0 +1,308 @@
+//! Modal implementation for the which-key display.
+
+use gpui::prelude::FluentBuilder;
+use gpui::{
+    App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, FontWeight, Keystroke,
+    ScrollHandle, Subscription, WeakEntity, Window,
+};
+use settings::Settings;
+use std::collections::HashMap;
+use theme::ThemeSettings;
+use ui::{
+    Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*,
+    text_for_keystrokes,
+};
+use workspace::{ModalView, Workspace};
+
+use crate::FILTERED_KEYSTROKES;
+
+pub struct WhichKeyModal {
+    _workspace: WeakEntity<Workspace>,
+    focus_handle: FocusHandle,
+    scroll_handle: ScrollHandle,
+    bindings: Vec<(SharedString, SharedString)>,
+    pending_keys: SharedString,
+    _pending_input_subscription: Subscription,
+    _focus_out_subscription: Subscription,
+}
+
+impl WhichKeyModal {
+    pub fn new(
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        // Keep focus where it currently is
+        let focus_handle = window.focused(cx).unwrap_or(cx.focus_handle());
+
+        let handle = cx.weak_entity();
+        let mut this = Self {
+            _workspace: workspace,
+            focus_handle: focus_handle.clone(),
+            scroll_handle: ScrollHandle::new(),
+            bindings: Vec::new(),
+            pending_keys: SharedString::new_static(""),
+            _pending_input_subscription: cx.observe_pending_input(
+                window,
+                |this: &mut Self, window, cx| {
+                    this.update_pending_keys(window, cx);
+                },
+            ),
+            _focus_out_subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| {
+                handle.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+            }),
+        };
+        this.update_pending_keys(window, cx);
+        this
+    }
+
+    pub fn dismiss(&self, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent)
+    }
+
+    fn update_pending_keys(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(pending_keys) = window.pending_input_keystrokes() else {
+            cx.emit(DismissEvent);
+            return;
+        };
+        let bindings = window.possible_bindings_for_input(pending_keys);
+
+        let mut binding_data = bindings
+            .iter()
+            .map(|binding| {
+                // Map to keystrokes
+                (
+                    binding
+                        .keystrokes()
+                        .iter()
+                        .map(|k| k.inner().to_owned())
+                        .collect::<Vec<_>>(),
+                    binding.action(),
+                )
+            })
+            .filter(|(keystrokes, _action)| {
+                // Check if this binding matches any filtered keystroke pattern
+                !FILTERED_KEYSTROKES.iter().any(|filtered| {
+                    keystrokes.len() >= filtered.len()
+                        && keystrokes[..filtered.len()] == filtered[..]
+                })
+            })
+            .map(|(keystrokes, action)| {
+                // Map to remaining keystrokes and action name
+                let remaining_keystrokes = keystrokes[pending_keys.len()..].to_vec();
+                let action_name: SharedString =
+                    command_palette::humanize_action_name(action.name()).into();
+                (remaining_keystrokes, action_name)
+            })
+            .collect();
+
+        binding_data = group_bindings(binding_data);
+
+        // Sort bindings from shortest to longest, with groups last
+        // Using stable sort to preserve relative order of equal elements
+        binding_data.sort_by(|(keystrokes_a, action_a), (keystrokes_b, action_b)| {
+            // Groups (actions starting with "+") should go last
+            let is_group_a = action_a.starts_with('+');
+            let is_group_b = action_b.starts_with('+');
+
+            // First, separate groups from non-groups
+            let group_cmp = is_group_a.cmp(&is_group_b);
+            if group_cmp != std::cmp::Ordering::Equal {
+                return group_cmp;
+            }
+
+            // Then sort by keystroke count
+            let keystroke_cmp = keystrokes_a.len().cmp(&keystrokes_b.len());
+            if keystroke_cmp != std::cmp::Ordering::Equal {
+                return keystroke_cmp;
+            }
+
+            // Finally sort by text length, then lexicographically for full stability
+            let text_a = text_for_keystrokes(keystrokes_a, cx);
+            let text_b = text_for_keystrokes(keystrokes_b, cx);
+            let text_len_cmp = text_a.len().cmp(&text_b.len());
+            if text_len_cmp != std::cmp::Ordering::Equal {
+                return text_len_cmp;
+            }
+            text_a.cmp(&text_b)
+        });
+        binding_data.dedup();
+        self.pending_keys = text_for_keystrokes(&pending_keys, cx).into();
+        self.bindings = binding_data
+            .into_iter()
+            .map(|(keystrokes, action)| (text_for_keystrokes(&keystrokes, cx).into(), action))
+            .collect();
+    }
+}
+
+impl Render for WhichKeyModal {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let has_rows = !self.bindings.is_empty();
+        let viewport_size = window.viewport_size();
+
+        let max_panel_width = px((f32::from(viewport_size.width) * 0.5).min(480.0));
+        let max_content_height = px(f32::from(viewport_size.height) * 0.4);
+
+        // Push above status bar when visible
+        let status_height = self
+            ._workspace
+            .upgrade()
+            .and_then(|workspace| {
+                workspace.read_with(cx, |workspace, cx| {
+                    if workspace.status_bar_visible(cx) {
+                        Some(
+                            DynamicSpacing::Base04.px(cx) * 2.0
+                                + ThemeSettings::get_global(cx).ui_font_size(cx),
+                        )
+                    } else {
+                        None
+                    }
+                })
+            })
+            .unwrap_or(px(0.));
+
+        let margin_bottom = px(16.);
+        let bottom_offset = margin_bottom + status_height;
+
+        // Title section
+        let title_section = {
+            let mut column = v_flex().gap(px(0.)).child(
+                div()
+                    .child(
+                        Label::new(self.pending_keys.clone())
+                            .size(LabelSize::Default)
+                            .weight(FontWeight::MEDIUM)
+                            .color(Color::Accent),
+                    )
+                    .mb(px(2.)),
+            );
+
+            if has_rows {
+                column = column.child(
+                    div()
+                        .child(Divider::horizontal().color(DividerColor::BorderFaded))
+                        .mb(px(2.)),
+                );
+            }
+
+            column
+        };
+
+        let content = h_flex()
+            .items_start()
+            .id("which-key-content")
+            .gap(px(8.))
+            .overflow_y_scroll()
+            .track_scroll(&self.scroll_handle)
+            .h_full()
+            .max_h(max_content_height)
+            .child(
+                // Keystrokes column
+                v_flex()
+                    .gap(px(4.))
+                    .flex_shrink_0()
+                    .children(self.bindings.iter().map(|(keystrokes, _)| {
+                        div()
+                            .child(
+                                Label::new(keystrokes.clone())
+                                    .size(LabelSize::Default)
+                                    .color(Color::Accent),
+                            )
+                            .text_align(gpui::TextAlign::Right)
+                    })),
+            )
+            .child(
+                // Actions column
+                v_flex()
+                    .gap(px(4.))
+                    .flex_1()
+                    .min_w_0()
+                    .children(self.bindings.iter().map(|(_, action_name)| {
+                        let is_group = action_name.starts_with('+');
+                        let label_color = if is_group {
+                            Color::Success
+                        } else {
+                            Color::Default
+                        };
+
+                        div().child(
+                            Label::new(action_name.clone())
+                                .size(LabelSize::Default)
+                                .color(label_color)
+                                .single_line()
+                                .truncate(),
+                        )
+                    })),
+            );
+
+        div()
+            .id("which-key-buffer-panel-scroll")
+            .occlude()
+            .absolute()
+            .bottom(bottom_offset)
+            .right(px(16.))
+            .min_w(px(220.))
+            .max_w(max_panel_width)
+            .elevation_3(cx)
+            .px(px(12.))
+            .child(v_flex().child(title_section).when(has_rows, |el| {
+                el.child(
+                    div()
+                        .max_h(max_content_height)
+                        .child(content)
+                        .vertical_scrollbar_for(&self.scroll_handle, window, cx),
+                )
+            }))
+    }
+}
+
+impl EventEmitter<DismissEvent> for WhichKeyModal {}
+
+impl Focusable for WhichKeyModal {
+    fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl ModalView for WhichKeyModal {
+    fn render_bare(&self) -> bool {
+        true
+    }
+}
+
+fn group_bindings(
+    binding_data: Vec<(Vec<Keystroke>, SharedString)>,
+) -> Vec<(Vec<Keystroke>, SharedString)> {
+    let mut groups: HashMap<Option<Keystroke>, Vec<(Vec<Keystroke>, SharedString)>> =
+        HashMap::new();
+
+    // Group bindings by their first keystroke
+    for (remaining_keystrokes, action_name) in binding_data {
+        let first_key = remaining_keystrokes.first().cloned();
+        groups
+            .entry(first_key)
+            .or_default()
+            .push((remaining_keystrokes, action_name));
+    }
+
+    let mut result = Vec::new();
+
+    for (first_key, mut group_bindings) in groups {
+        // Remove duplicates within each group
+        group_bindings.dedup_by_key(|(keystrokes, _)| keystrokes.clone());
+
+        if let Some(first_key) = first_key
+            && group_bindings.len() > 1
+        {
+            // This is a group - create a single entry with just the first keystroke
+            let first_keystroke = vec![first_key];
+            let count = group_bindings.len();
+            result.push((first_keystroke, format!("+{} keybinds", count).into()));
+        } else {
+            // Not a group or empty keystrokes - add all bindings as-is
+            result.append(&mut group_bindings);
+        }
+    }
+
+    result
+}

crates/which_key/src/which_key_settings.rs 🔗

@@ -0,0 +1,18 @@
+use settings::{RegisterSetting, Settings, SettingsContent, WhichKeySettingsContent};
+
+#[derive(Debug, Clone, Copy, RegisterSetting)]
+pub struct WhichKeySettings {
+    pub enabled: bool,
+    pub delay_ms: u64,
+}
+
+impl Settings for WhichKeySettings {
+    fn from_settings(content: &SettingsContent) -> Self {
+        let which_key: &WhichKeySettingsContent = content.which_key.as_ref().unwrap();
+
+        Self {
+            enabled: which_key.enabled.unwrap(),
+            delay_ms: which_key.delay_ms.unwrap(),
+        }
+    }
+}

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/dock.rs 🔗

@@ -1,5 +1,4 @@
 use crate::persistence::model::DockData;
-use crate::utility_pane::utility_slot_for_dock_position;
 use crate::{DraggedDock, Event, ModalLayer, Pane};
 use crate::{Workspace, status_bar::StatusItemView};
 use anyhow::Context as _;
@@ -350,7 +349,7 @@ impl Dock {
             let focus_subscription =
                 cx.on_focus(&focus_handle, window, |dock: &mut Dock, window, cx| {
                     if let Some(active_entry) = dock.active_panel_entry() {
-                        active_entry.panel.panel_focus_handle(cx).focus(window)
+                        active_entry.panel.panel_focus_handle(cx).focus(window, cx)
                     }
                 });
             let zoom_subscription = cx.subscribe(&workspace, |dock, workspace, e: &Event, cx| {
@@ -593,7 +592,7 @@ impl Dock {
                         this.set_panel_zoomed(&panel.to_any(), true, window, cx);
                         if !PanelHandle::panel_focus_handle(panel, cx).contains_focused(window, cx)
                         {
-                            window.focus(&panel.focus_handle(cx));
+                            window.focus(&panel.focus_handle(cx), cx);
                         }
                         workspace
                             .update(cx, |workspace, cx| {
@@ -625,7 +624,7 @@ impl Dock {
                         {
                             this.set_open(true, window, cx);
                             this.activate_panel(ix, window, cx);
-                            window.focus(&panel.read(cx).focus_handle(cx));
+                            window.focus(&panel.read(cx).focus_handle(cx), cx);
                         }
                     }
                     PanelEvent::Close => {
@@ -705,7 +704,7 @@ impl Dock {
         panel: &Entity<T>,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) {
+    ) -> bool {
         if let Some(panel_ix) = self
             .panel_entries
             .iter()
@@ -724,15 +723,12 @@ impl Dock {
                 }
             }
 
-            let slot = utility_slot_for_dock_position(self.position);
-            if let Some(workspace) = self.workspace.upgrade() {
-                workspace.update(cx, |workspace, cx| {
-                    workspace.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx);
-                });
-            }
-
             self.panel_entries.remove(panel_ix);
             cx.notify();
+
+            true
+        } else {
+            false
         }
     }
 
@@ -1052,7 +1048,7 @@ impl Render for PanelButtons {
                                             name = name,
                                             toggle_state = !is_open
                                         );
-                                        window.focus(&focus_handle);
+                                        window.focus(&focus_handle, cx);
                                         window.dispatch_action(action.boxed_clone(), cx)
                                     }
                                 })

crates/workspace/src/item.rs 🔗

@@ -76,7 +76,13 @@ impl Settings for ItemSettings {
     fn from_settings(content: &settings::SettingsContent) -> Self {
         let tabs = content.tabs.as_ref().unwrap();
         Self {
-            git_status: tabs.git_status.unwrap(),
+            git_status: tabs.git_status.unwrap()
+                && content
+                    .git
+                    .unwrap()
+                    .enabled
+                    .unwrap()
+                    .is_git_status_enabled(),
             close_position: tabs.close_position.unwrap(),
             activate_on_close: tabs.activate_on_close.unwrap(),
             file_icons: tabs.file_icons.unwrap(),
@@ -883,8 +889,18 @@ 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.
+                        // Also skip autosave if focus moved to a modal (e.g., command palette),
+                        // since the user is still interacting with the workspace.
+                        let focus_handle = item.item_focus_handle(cx);
+                        if !focus_handle.contains_focused(window, cx)
+                            && !workspace.has_active_modal(window, cx)
+                        {
+                            Pane::autosave_item(&item, workspace.project.clone(), window, cx)
+                                .detach_and_log_err(cx);
+                        }
                     }
                 },
             )
@@ -1036,7 +1052,7 @@ impl<T: Item> ItemHandle for Entity<T> {
 
     fn relay_action(&self, action: Box<dyn Action>, window: &mut Window, cx: &mut App) {
         self.update(cx, |this, cx| {
-            this.focus_handle(cx).focus(window);
+            this.focus_handle(cx).focus(window, cx);
             window.dispatch_action(action, cx);
         })
     }

crates/workspace/src/modal_layer.rs 🔗

@@ -1,9 +1,18 @@
 use gpui::{
     AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable as _, ManagedView,
-    MouseButton, Subscription,
+    MouseButton, Pixels, Point, Subscription,
 };
 use ui::prelude::*;
 
+#[derive(Debug, Clone, Copy, Default)]
+pub enum ModalPlacement {
+    #[default]
+    Centered,
+    Anchored {
+        position: Point<Pixels>,
+    },
+}
+
 #[derive(Debug)]
 pub enum DismissDecision {
     Dismiss(bool),
@@ -22,12 +31,17 @@ pub trait ModalView: ManagedView {
     fn fade_out_background(&self) -> bool {
         false
     }
+
+    fn render_bare(&self) -> bool {
+        false
+    }
 }
 
 trait ModalViewHandle {
     fn on_before_dismiss(&mut self, window: &mut Window, cx: &mut App) -> DismissDecision;
     fn view(&self) -> AnyView;
     fn fade_out_background(&self, cx: &mut App) -> bool;
+    fn render_bare(&self, cx: &mut App) -> bool;
 }
 
 impl<V: ModalView> ModalViewHandle for Entity<V> {
@@ -42,6 +56,10 @@ impl<V: ModalView> ModalViewHandle for Entity<V> {
     fn fade_out_background(&self, cx: &mut App) -> bool {
         self.read(cx).fade_out_background()
     }
+
+    fn render_bare(&self, cx: &mut App) -> bool {
+        self.read(cx).render_bare()
+    }
 }
 
 pub struct ActiveModal {
@@ -49,6 +67,7 @@ pub struct ActiveModal {
     _subscriptions: [Subscription; 2],
     previous_focus_handle: Option<FocusHandle>,
     focus_handle: FocusHandle,
+    placement: ModalPlacement,
 }
 
 pub struct ModalLayer {
@@ -78,6 +97,19 @@ impl ModalLayer {
     where
         V: ModalView,
         B: FnOnce(&mut Window, &mut Context<V>) -> V,
+    {
+        self.toggle_modal_with_placement(window, cx, ModalPlacement::Centered, build_view);
+    }
+
+    pub fn toggle_modal_with_placement<V, B>(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+        placement: ModalPlacement,
+        build_view: B,
+    ) where
+        V: ModalView,
+        B: FnOnce(&mut Window, &mut Context<V>) -> V,
     {
         if let Some(active_modal) = &self.active_modal {
             let is_close = active_modal.modal.view().downcast::<V>().is_ok();
@@ -87,12 +119,17 @@ impl ModalLayer {
             }
         }
         let new_modal = cx.new(|cx| build_view(window, cx));
-        self.show_modal(new_modal, window, cx);
+        self.show_modal(new_modal, placement, window, cx);
         cx.emit(ModalOpenedEvent);
     }
 
-    fn show_modal<V>(&mut self, new_modal: Entity<V>, window: &mut Window, cx: &mut Context<Self>)
-    where
+    fn show_modal<V>(
+        &mut self,
+        new_modal: Entity<V>,
+        placement: ModalPlacement,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) where
         V: ModalView,
     {
         let focus_handle = cx.focus_handle();
@@ -114,9 +151,10 @@ impl ModalLayer {
             ],
             previous_focus_handle: window.focused(cx),
             focus_handle,
+            placement,
         });
         cx.defer_in(window, move |_, window, cx| {
-            window.focus(&new_modal.focus_handle(cx));
+            window.focus(&new_modal.focus_handle(cx), cx);
         });
         cx.notify();
     }
@@ -144,7 +182,7 @@ impl ModalLayer {
             if let Some(previous_focus) = active_modal.previous_focus_handle
                 && active_modal.focus_handle.contains_focused(window, cx)
             {
-                previous_focus.focus(window);
+                previous_focus.focus(window, cx);
             }
             cx.notify();
         }
@@ -167,19 +205,46 @@ impl ModalLayer {
 impl Render for ModalLayer {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let Some(active_modal) = &self.active_modal else {
-            return div();
+            return div().into_any_element();
         };
 
-        div()
+        if active_modal.modal.render_bare(cx) {
+            return active_modal.modal.view().into_any_element();
+        }
+
+        let content = h_flex()
             .occlude()
+            .child(active_modal.modal.view())
+            .on_mouse_down(MouseButton::Left, |_, _, cx| {
+                cx.stop_propagation();
+            });
+
+        let positioned = match active_modal.placement {
+            ModalPlacement::Centered => v_flex()
+                .h(px(0.0))
+                .top_20()
+                .items_center()
+                .track_focus(&active_modal.focus_handle)
+                .child(content)
+                .into_any_element(),
+            ModalPlacement::Anchored { position } => div()
+                .absolute()
+                .left(position.x)
+                .top(position.y - px(20.))
+                .track_focus(&active_modal.focus_handle)
+                .child(content)
+                .into_any_element(),
+        };
+
+        div()
             .absolute()
             .size_full()
-            .top_0()
-            .left_0()
-            .when(active_modal.modal.fade_out_background(cx), |el| {
+            .inset_0()
+            .occlude()
+            .when(active_modal.modal.fade_out_background(cx), |this| {
                 let mut background = cx.theme().colors().elevated_surface_background;
                 background.fade_out(0.2);
-                el.bg(background)
+                this.bg(background)
             })
             .on_mouse_down(
                 MouseButton::Left,
@@ -187,22 +252,7 @@ impl Render for ModalLayer {
                     this.hide_modal(window, cx);
                 }),
             )
-            .child(
-                v_flex()
-                    .h(px(0.0))
-                    .top_20()
-                    .flex()
-                    .flex_col()
-                    .items_center()
-                    .track_focus(&active_modal.focus_handle)
-                    .child(
-                        h_flex()
-                            .occlude()
-                            .child(active_modal.modal.view())
-                            .on_mouse_down(MouseButton::Left, |_, _, cx| {
-                                cx.stop_propagation();
-                            }),
-                    ),
-            )
+            .child(positioned)
+            .into_any_element()
     }
 }

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,
@@ -624,17 +625,21 @@ impl Pane {
                     self.last_focus_handle_by_item.get(&active_item.item_id())
                     && let Some(focus_handle) = weak_last_focus_handle.upgrade()
                 {
-                    focus_handle.focus(window);
+                    focus_handle.focus(window, cx);
                     return;
                 }
 
-                active_item.item_focus_handle(cx).focus(window);
+                active_item.item_focus_handle(cx).focus(window, cx);
             } else if let Some(focused) = window.focused(cx)
                 && !self.context_menu_focused(window, cx)
             {
                 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, cx);
+            }
         }
     }
 
@@ -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,
@@ -1822,6 +1846,7 @@ impl Pane {
             }
 
             for item_to_close in items_to_close {
+                let mut should_close = true;
                 let mut should_save = true;
                 if save_intent == SaveIntent::Close {
                     workspace.update(cx, |workspace, cx| {
@@ -1837,7 +1862,7 @@ impl Pane {
                     {
                         Ok(success) => {
                             if !success {
-                                break;
+                                should_close = false;
                             }
                         }
                         Err(err) => {
@@ -1856,23 +1881,25 @@ impl Pane {
                             })?;
                             match answer.await {
                                 Ok(0) => {}
-                                Ok(1..) | Err(_) => break,
+                                Ok(1..) | Err(_) => should_close = false,
                             }
                         }
                     }
                 }
 
                 // Remove the item from the pane.
-                pane.update_in(cx, |pane, window, cx| {
-                    pane.remove_item(
-                        item_to_close.item_id(),
-                        false,
-                        pane.close_pane_if_empty,
-                        window,
-                        cx,
-                    );
-                })
-                .ok();
+                if should_close {
+                    pane.update_in(cx, |pane, window, cx| {
+                        pane.remove_item(
+                            item_to_close.item_id(),
+                            false,
+                            pane.close_pane_if_empty,
+                            window,
+                            cx,
+                        );
+                    })
+                    .ok();
+                }
             }
 
             pane.update(cx, |_, cx| cx.notify()).ok();
@@ -1975,7 +2002,7 @@ impl Pane {
 
             let should_activate = activate_pane || self.has_focus(window, cx);
             if self.items.len() == 1 && should_activate {
-                self.focus_handle.focus(window);
+                self.focus_handle.focus(window, cx);
             } else {
                 self.activate_item(
                     index_to_activate,
@@ -2326,7 +2353,7 @@ impl Pane {
     pub fn focus_active_item(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(active_item) = self.active_item() {
             let focus_handle = active_item.item_focus_handle(cx);
-            window.focus(&focus_handle);
+            window.focus(&focus_handle, cx);
         }
     }
 
@@ -3900,6 +3927,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 +4069,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())
                             }
                         }
                     })
@@ -6583,6 +6617,60 @@ mod tests {
         cx.simulate_prompt_answer("Discard all");
         save.await.unwrap();
         assert_item_labels(&pane, [], cx);
+
+        add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| {
+            item.project_items
+                .push(TestProjectItem::new_dirty(1, "A.txt", cx))
+        });
+        add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| {
+            item.project_items
+                .push(TestProjectItem::new_dirty(2, "B.txt", cx))
+        });
+        add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| {
+            item.project_items
+                .push(TestProjectItem::new_dirty(3, "C.txt", cx))
+        });
+        assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
+
+        let close_task = pane.update_in(cx, |pane, window, cx| {
+            pane.close_all_items(
+                &CloseAllItems {
+                    save_intent: None,
+                    close_pinned: false,
+                },
+                window,
+                cx,
+            )
+        });
+
+        cx.executor().run_until_parked();
+        cx.simulate_prompt_answer("Discard all");
+        close_task.await.unwrap();
+        assert_item_labels(&pane, [], cx);
+
+        add_labeled_item(&pane, "Clean1", false, cx);
+        add_labeled_item(&pane, "Dirty", true, cx).update(cx, |item, cx| {
+            item.project_items
+                .push(TestProjectItem::new_dirty(1, "Dirty.txt", cx))
+        });
+        add_labeled_item(&pane, "Clean2", false, cx);
+        assert_item_labels(&pane, ["Clean1", "Dirty^", "Clean2*"], cx);
+
+        let close_task = pane.update_in(cx, |pane, window, cx| {
+            pane.close_all_items(
+                &CloseAllItems {
+                    save_intent: None,
+                    close_pinned: false,
+                },
+                window,
+                cx,
+            )
+        });
+
+        cx.executor().run_until_parked();
+        cx.simulate_prompt_answer("Cancel");
+        close_task.await.unwrap();
+        assert_item_labels(&pane, ["Dirty*^"], cx);
     }
 
     #[gpui::test]

crates/workspace/src/persistence.rs 🔗

@@ -9,20 +9,25 @@ use std::{
 };
 
 use anyhow::{Context as _, Result, bail};
-use collections::{HashMap, IndexSet};
+use collections::{HashMap, HashSet, IndexSet};
 use db::{
+    kvp::KEY_VALUE_STORE,
     query,
     sqlez::{connection::Connection, domain::Domain},
     sqlez_macros::sql,
 };
-use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size};
-use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
+use gpui::{Axis, Bounds, Entity, Task, WindowBounds, WindowId, point, size};
+use project::{
+    debugger::breakpoint_store::{BreakpointState, SourceBreakpoint},
+    trusted_worktrees::{PathTrust, RemoteHostLocation, find_worktree_in_store},
+    worktree_store::WorktreeStore,
+};
 
 use language::{LanguageName, Toolchain, ToolchainScope};
-use project::WorktreeId;
 use remote::{
     DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
 };
+use serde::{Deserialize, Serialize};
 use sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
     statement::Statement,
@@ -46,6 +51,11 @@ use model::{
 
 use self::model::{DockStructure, SerializedWorkspaceLocation};
 
+// https://www.sqlite.org/limits.html
+// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
+// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
+const MAX_QUERY_PLACEHOLDERS: usize = 32000;
+
 #[derive(Copy, Clone, Debug, PartialEq)]
 pub(crate) struct SerializedAxis(pub(crate) gpui::Axis);
 impl sqlez::bindable::StaticColumnCount for SerializedAxis {}
@@ -154,6 +164,124 @@ impl Column for SerializedWindowBounds {
     }
 }
 
+const DEFAULT_WINDOW_BOUNDS_KEY: &str = "default_window_bounds";
+
+pub fn read_default_window_bounds() -> Option<(Uuid, WindowBounds)> {
+    let json_str = KEY_VALUE_STORE
+        .read_kvp(DEFAULT_WINDOW_BOUNDS_KEY)
+        .log_err()
+        .flatten()?;
+
+    let (display_uuid, persisted) =
+        serde_json::from_str::<(Uuid, WindowBoundsJson)>(&json_str).ok()?;
+    Some((display_uuid, persisted.into()))
+}
+
+pub async fn write_default_window_bounds(
+    bounds: WindowBounds,
+    display_uuid: Uuid,
+) -> anyhow::Result<()> {
+    let persisted = WindowBoundsJson::from(bounds);
+    let json_str = serde_json::to_string(&(display_uuid, persisted))?;
+    KEY_VALUE_STORE
+        .write_kvp(DEFAULT_WINDOW_BOUNDS_KEY.to_string(), json_str)
+        .await?;
+    Ok(())
+}
+
+#[derive(Serialize, Deserialize)]
+pub enum WindowBoundsJson {
+    Windowed {
+        x: i32,
+        y: i32,
+        width: i32,
+        height: i32,
+    },
+    Maximized {
+        x: i32,
+        y: i32,
+        width: i32,
+        height: i32,
+    },
+    Fullscreen {
+        x: i32,
+        y: i32,
+        width: i32,
+        height: i32,
+    },
+}
+
+impl From<WindowBounds> for WindowBoundsJson {
+    fn from(b: WindowBounds) -> Self {
+        match b {
+            WindowBounds::Windowed(bounds) => {
+                let origin = bounds.origin;
+                let size = bounds.size;
+                WindowBoundsJson::Windowed {
+                    x: f32::from(origin.x).round() as i32,
+                    y: f32::from(origin.y).round() as i32,
+                    width: f32::from(size.width).round() as i32,
+                    height: f32::from(size.height).round() as i32,
+                }
+            }
+            WindowBounds::Maximized(bounds) => {
+                let origin = bounds.origin;
+                let size = bounds.size;
+                WindowBoundsJson::Maximized {
+                    x: f32::from(origin.x).round() as i32,
+                    y: f32::from(origin.y).round() as i32,
+                    width: f32::from(size.width).round() as i32,
+                    height: f32::from(size.height).round() as i32,
+                }
+            }
+            WindowBounds::Fullscreen(bounds) => {
+                let origin = bounds.origin;
+                let size = bounds.size;
+                WindowBoundsJson::Fullscreen {
+                    x: f32::from(origin.x).round() as i32,
+                    y: f32::from(origin.y).round() as i32,
+                    width: f32::from(size.width).round() as i32,
+                    height: f32::from(size.height).round() as i32,
+                }
+            }
+        }
+    }
+}
+
+impl From<WindowBoundsJson> for WindowBounds {
+    fn from(n: WindowBoundsJson) -> Self {
+        match n {
+            WindowBoundsJson::Windowed {
+                x,
+                y,
+                width,
+                height,
+            } => WindowBounds::Windowed(Bounds {
+                origin: point(px(x as f32), px(y as f32)),
+                size: size(px(width as f32), px(height as f32)),
+            }),
+            WindowBoundsJson::Maximized {
+                x,
+                y,
+                width,
+                height,
+            } => WindowBounds::Maximized(Bounds {
+                origin: point(px(x as f32), px(y as f32)),
+                size: size(px(width as f32), px(height as f32)),
+            }),
+            WindowBoundsJson::Fullscreen {
+                x,
+                y,
+                width,
+                height,
+            } => WindowBounds::Fullscreen(Bounds {
+                origin: point(px(x as f32), px(y as f32)),
+                size: size(px(width as f32), px(height as f32)),
+            }),
+        }
+    }
+}
+
 #[derive(Debug)]
 pub struct Breakpoint {
     pub position: u32,
@@ -708,6 +836,52 @@ impl Domain for WorkspaceDb {
             ALTER TABLE remote_connections ADD COLUMN name TEXT;
             ALTER TABLE remote_connections ADD COLUMN container_id TEXT;
         ),
+        sql!(
+            CREATE TABLE IF NOT EXISTS trusted_worktrees (
+                trust_id INTEGER PRIMARY KEY AUTOINCREMENT,
+                absolute_path TEXT,
+                user_name TEXT,
+                host_name TEXT
+            ) STRICT;
+        ),
+        sql!(CREATE TABLE toolchains2 (
+            workspace_id INTEGER,
+            worktree_root_path TEXT NOT NULL,
+            language_name TEXT NOT NULL,
+            name TEXT NOT NULL,
+            path TEXT NOT NULL,
+            raw_json TEXT NOT NULL,
+            relative_worktree_path TEXT NOT NULL,
+            PRIMARY KEY (workspace_id, worktree_root_path, language_name, relative_worktree_path)) STRICT;
+            INSERT OR REPLACE INTO toolchains2
+                // The `instr(paths, '\n') = 0` part allows us to find all
+                // workspaces that have a single worktree, as `\n` is used as a
+                // separator when serializing the workspace paths, so if no `\n` is
+                // found, we know we have a single worktree.
+                SELECT toolchains.workspace_id, paths, language_name, name, path, raw_json, relative_worktree_path FROM toolchains INNER JOIN workspaces ON toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0;
+            DROP TABLE toolchains;
+            ALTER TABLE toolchains2 RENAME TO toolchains;
+        ),
+        sql!(CREATE TABLE user_toolchains2 (
+            remote_connection_id INTEGER,
+            workspace_id INTEGER NOT NULL,
+            worktree_root_path TEXT NOT NULL,
+            relative_worktree_path TEXT NOT NULL,
+            language_name TEXT NOT NULL,
+            name TEXT NOT NULL,
+            path TEXT NOT NULL,
+            raw_json TEXT NOT NULL,
+
+            PRIMARY KEY (workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json)) STRICT;
+            INSERT OR REPLACE INTO user_toolchains2
+                // The `instr(paths, '\n') = 0` part allows us to find all
+                // workspaces that have a single worktree, as `\n` is used as a
+                // separator when serializing the workspace paths, so if no `\n` is
+                // found, we know we have a single worktree.
+                SELECT user_toolchains.remote_connection_id, user_toolchains.workspace_id, paths, relative_worktree_path, language_name, name, path, raw_json  FROM user_toolchains INNER JOIN workspaces ON user_toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0;
+            DROP TABLE user_toolchains;
+            ALTER TABLE user_toolchains2 RENAME TO user_toolchains;
+        ),
     ];
 
     // Allow recovering from bad migration that was initially shipped to nightly
@@ -893,11 +1067,11 @@ impl WorkspaceDb {
         workspace_id: WorkspaceId,
         remote_connection_id: Option<RemoteConnectionId>,
     ) -> BTreeMap<ToolchainScope, IndexSet<Toolchain>> {
-        type RowKind = (WorkspaceId, u64, String, String, String, String, String);
+        type RowKind = (WorkspaceId, String, String, String, String, String, String);
 
         let toolchains: Vec<RowKind> = self
             .select_bound(sql! {
-                SELECT workspace_id, worktree_id, relative_worktree_path,
+                SELECT workspace_id, worktree_root_path, relative_worktree_path,
                 language_name, name, path, raw_json
                 FROM user_toolchains WHERE remote_connection_id IS ?1 AND (
                       workspace_id IN (0, ?2)
@@ -911,7 +1085,7 @@ impl WorkspaceDb {
 
         for (
             _workspace_id,
-            worktree_id,
+            worktree_root_path,
             relative_worktree_path,
             language_name,
             name,
@@ -921,22 +1095,24 @@ impl WorkspaceDb {
         {
             // INTEGER's that are primary keys (like workspace ids, remote connection ids and such) start at 1, so we're safe to
             let scope = if _workspace_id == WorkspaceId(0) {
-                debug_assert_eq!(worktree_id, u64::MAX);
+                debug_assert_eq!(worktree_root_path, String::default());
                 debug_assert_eq!(relative_worktree_path, String::default());
                 ToolchainScope::Global
             } else {
                 debug_assert_eq!(workspace_id, _workspace_id);
                 debug_assert_eq!(
-                    worktree_id == u64::MAX,
+                    worktree_root_path == String::default(),
                     relative_worktree_path == String::default()
                 );
 
                 let Some(relative_path) = RelPath::unix(&relative_worktree_path).log_err() else {
                     continue;
                 };
-                if worktree_id != u64::MAX && relative_worktree_path != String::default() {
+                if worktree_root_path != String::default()
+                    && relative_worktree_path != String::default()
+                {
                     ToolchainScope::Subproject(
-                        WorktreeId::from_usize(worktree_id as usize),
+                        Arc::from(worktree_root_path.as_ref()),
                         relative_path.into(),
                     )
                 } else {
@@ -1022,13 +1198,13 @@ impl WorkspaceDb {
 
                 for (scope, toolchains) in workspace.user_toolchains {
                     for toolchain in toolchains {
-                        let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8));
-                        let (workspace_id, worktree_id, relative_worktree_path) = match scope {
-                            ToolchainScope::Subproject(worktree_id, ref path) => (Some(workspace.id), Some(worktree_id), Some(path.as_unix_str().to_owned())),
+                        let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8));
+                        let (workspace_id, worktree_root_path, relative_worktree_path) = match scope {
+                            ToolchainScope::Subproject(ref worktree_root_path, ref path) => (Some(workspace.id), Some(worktree_root_path.to_string_lossy().into_owned()), Some(path.as_unix_str().to_owned())),
                             ToolchainScope::Project => (Some(workspace.id), None, None),
                             ToolchainScope::Global => (None, None, None),
                         };
-                        let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_id.map_or(usize::MAX,|id| id.to_usize()), relative_worktree_path.unwrap_or_default(),
+                        let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_root_path.unwrap_or_default(), relative_worktree_path.unwrap_or_default(),
                         toolchain.language_name.as_ref().to_owned(), toolchain.name.to_string(), toolchain.path.to_string(), toolchain.as_json.to_string());
                         if let Err(err) = conn.exec_bound(query)?(args) {
                             log::error!("{err}");
@@ -1136,7 +1312,7 @@ impl WorkspaceDb {
         match options {
             RemoteConnectionOptions::Ssh(options) => {
                 kind = RemoteConnectionKind::Ssh;
-                host = Some(options.host);
+                host = Some(options.host.to_string());
                 port = options.port;
                 user = options.username;
             }
@@ -1349,7 +1525,7 @@ impl WorkspaceDb {
                 user: user,
             })),
             RemoteConnectionKind::Ssh => Some(RemoteConnectionOptions::Ssh(SshConnectionOptions {
-                host: host?,
+                host: host?.into(),
                 port,
                 username: user,
                 ..Default::default()
@@ -1364,24 +1540,6 @@ impl WorkspaceDb {
         }
     }
 
-    pub(crate) fn last_window(
-        &self,
-    ) -> anyhow::Result<(Option<Uuid>, Option<SerializedWindowBounds>)> {
-        let mut prepared_query =
-            self.select::<(Option<Uuid>, Option<SerializedWindowBounds>)>(sql!(
-                SELECT
-                display,
-                window_state, window_x, window_y, window_width, window_height
-                FROM workspaces
-                WHERE paths
-                IS NOT NULL
-                ORDER BY timestamp DESC
-                LIMIT 1
-            ))?;
-        let result = prepared_query()?;
-        Ok(result.into_iter().next().unwrap_or((None, None)))
-    }
-
     query! {
         pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> {
             DELETE FROM workspaces
@@ -1725,24 +1883,24 @@ impl WorkspaceDb {
     pub(crate) async fn toolchains(
         &self,
         workspace_id: WorkspaceId,
-    ) -> Result<Vec<(Toolchain, WorktreeId, Arc<RelPath>)>> {
+    ) -> Result<Vec<(Toolchain, Arc<Path>, Arc<RelPath>)>> {
         self.write(move |this| {
             let mut select = this
                 .select_bound(sql!(
                     SELECT
-                        name, path, worktree_id, relative_worktree_path, language_name, raw_json
+                        name, path, worktree_root_path, relative_worktree_path, language_name, raw_json
                     FROM toolchains
                     WHERE workspace_id = ?
                 ))
                 .context("select toolchains")?;
 
-            let toolchain: Vec<(String, String, u64, String, String, String)> =
+            let toolchain: Vec<(String, String, String, String, String, String)> =
                 select(workspace_id)?;
 
             Ok(toolchain
                 .into_iter()
                 .filter_map(
-                    |(name, path, worktree_id, relative_worktree_path, language, json)| {
+                    |(name, path, worktree_root_path, relative_worktree_path, language, json)| {
                         Some((
                             Toolchain {
                                 name: name.into(),
@@ -1750,7 +1908,7 @@ impl WorkspaceDb {
                                 language_name: LanguageName::new(&language),
                                 as_json: serde_json::Value::from_str(&json).ok()?,
                             },
-                            WorktreeId::from_proto(worktree_id),
+                           Arc::from(worktree_root_path.as_ref()),
                             RelPath::from_proto(&relative_worktree_path).log_err()?,
                         ))
                     },
@@ -1763,18 +1921,18 @@ impl WorkspaceDb {
     pub async fn set_toolchain(
         &self,
         workspace_id: WorkspaceId,
-        worktree_id: WorktreeId,
+        worktree_root_path: Arc<Path>,
         relative_worktree_path: Arc<RelPath>,
         toolchain: Toolchain,
     ) -> Result<()> {
         log::debug!(
-            "Setting toolchain for workspace, worktree: {worktree_id:?}, relative path: {relative_worktree_path:?}, toolchain: {}",
+            "Setting toolchain for workspace, worktree: {worktree_root_path:?}, relative path: {relative_worktree_path:?}, toolchain: {}",
             toolchain.name
         );
         self.write(move |conn| {
             let mut insert = conn
                 .exec_bound(sql!(
-                    INSERT INTO toolchains(workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?,  ?, ?)
+                    INSERT INTO toolchains(workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?,  ?, ?)
                     ON CONFLICT DO
                     UPDATE SET
                         name = ?5,
@@ -1785,7 +1943,7 @@ impl WorkspaceDb {
 
             insert((
                 workspace_id,
-                worktree_id.to_usize(),
+                worktree_root_path.to_string_lossy().into_owned(),
                 relative_worktree_path.as_unix_str(),
                 toolchain.language_name.as_ref(),
                 toolchain.name.as_ref(),
@@ -1796,6 +1954,135 @@ impl WorkspaceDb {
             Ok(())
         }).await
     }
+
+    pub(crate) async fn save_trusted_worktrees(
+        &self,
+        trusted_worktrees: HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>>,
+    ) -> anyhow::Result<()> {
+        use anyhow::Context as _;
+        use db::sqlez::statement::Statement;
+        use itertools::Itertools as _;
+
+        DB.clear_trusted_worktrees()
+            .await
+            .context("clearing previous trust state")?;
+
+        let trusted_worktrees = trusted_worktrees
+            .into_iter()
+            .flat_map(|(host, abs_paths)| {
+                abs_paths
+                    .into_iter()
+                    .map(move |abs_path| (Some(abs_path), host.clone()))
+            })
+            .collect::<Vec<_>>();
+        let mut first_worktree;
+        let mut last_worktree = 0_usize;
+        for (count, placeholders) in std::iter::once("(?, ?, ?)")
+            .cycle()
+            .take(trusted_worktrees.len())
+            .chunks(MAX_QUERY_PLACEHOLDERS / 3)
+            .into_iter()
+            .map(|chunk| {
+                let mut count = 0;
+                let placeholders = chunk
+                    .inspect(|_| {
+                        count += 1;
+                    })
+                    .join(", ");
+                (count, placeholders)
+            })
+            .collect::<Vec<_>>()
+        {
+            first_worktree = last_worktree;
+            last_worktree = last_worktree + count;
+            let query = format!(
+                r#"INSERT INTO trusted_worktrees(absolute_path, user_name, host_name)
+VALUES {placeholders};"#
+            );
+
+            let trusted_worktrees = trusted_worktrees[first_worktree..last_worktree].to_vec();
+            self.write(move |conn| {
+                let mut statement = Statement::prepare(conn, query)?;
+                let mut next_index = 1;
+                for (abs_path, host) in trusted_worktrees {
+                    let abs_path = abs_path.as_ref().map(|abs_path| abs_path.to_string_lossy());
+                    next_index = statement.bind(
+                        &abs_path.as_ref().map(|abs_path| abs_path.as_ref()),
+                        next_index,
+                    )?;
+                    next_index = statement.bind(
+                        &host
+                            .as_ref()
+                            .and_then(|host| Some(host.user_name.as_ref()?.as_str())),
+                        next_index,
+                    )?;
+                    next_index = statement.bind(
+                        &host.as_ref().map(|host| host.host_identifier.as_str()),
+                        next_index,
+                    )?;
+                }
+                statement.exec()
+            })
+            .await
+            .context("inserting new trusted state")?;
+        }
+        Ok(())
+    }
+
+    pub fn fetch_trusted_worktrees(
+        &self,
+        worktree_store: Option<Entity<WorktreeStore>>,
+        host: Option<RemoteHostLocation>,
+        cx: &App,
+    ) -> Result<HashMap<Option<RemoteHostLocation>, HashSet<PathTrust>>> {
+        let trusted_worktrees = DB.trusted_worktrees()?;
+        Ok(trusted_worktrees
+            .into_iter()
+            .filter_map(|(abs_path, user_name, host_name)| {
+                let db_host = match (user_name, host_name) {
+                    (_, None) => None,
+                    (None, Some(host_name)) => Some(RemoteHostLocation {
+                        user_name: None,
+                        host_identifier: SharedString::new(host_name),
+                    }),
+                    (Some(user_name), Some(host_name)) => Some(RemoteHostLocation {
+                        user_name: Some(SharedString::new(user_name)),
+                        host_identifier: SharedString::new(host_name),
+                    }),
+                };
+
+                let abs_path = abs_path?;
+                Some(if db_host != host {
+                    (db_host, PathTrust::AbsPath(abs_path))
+                } else if let Some(worktree_store) = &worktree_store {
+                    find_worktree_in_store(worktree_store.read(cx), &abs_path, cx)
+                        .map(PathTrust::Worktree)
+                        .map(|trusted_worktree| (host.clone(), trusted_worktree))
+                        .unwrap_or_else(|| (db_host.clone(), PathTrust::AbsPath(abs_path)))
+                } else {
+                    (db_host, PathTrust::AbsPath(abs_path))
+                })
+            })
+            .fold(HashMap::default(), |mut acc, (remote_host, path_trust)| {
+                acc.entry(remote_host)
+                    .or_insert_with(HashSet::default)
+                    .insert(path_trust);
+                acc
+            }))
+    }
+
+    query! {
+        fn trusted_worktrees() -> Result<Vec<(Option<PathBuf>, Option<String>, Option<String>)>> {
+            SELECT absolute_path, user_name, host_name
+            FROM trusted_worktrees
+        }
+    }
+
+    query! {
+        pub async fn clear_trusted_worktrees() -> Result<()> {
+            DELETE FROM trusted_worktrees
+        }
+    }
 }
 
 pub fn delete_unloaded_items(
@@ -2503,7 +2790,7 @@ mod tests {
 
         let connection_id = db
             .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
-                host: "my-host".to_string(),
+                host: "my-host".into(),
                 port: Some(1234),
                 ..Default::default()
             }))
@@ -2692,7 +2979,7 @@ mod tests {
         .into_iter()
         .map(|(host, user)| async {
             let options = RemoteConnectionOptions::Ssh(SshConnectionOptions {
-                host: host.to_string(),
+                host: host.into(),
                 username: Some(user.to_string()),
                 ..Default::default()
             });
@@ -2783,7 +3070,7 @@ mod tests {
 
         let connection_id = db
             .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
-                host: host.clone(),
+                host: host.clone().into(),
                 port,
                 username: user.clone(),
                 ..Default::default()
@@ -2794,7 +3081,7 @@ mod tests {
         // Test that calling the function again with the same parameters returns the same project
         let same_connection = db
             .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
-                host: host.clone(),
+                host: host.clone().into(),
                 port,
                 username: user.clone(),
                 ..Default::default()
@@ -2811,7 +3098,7 @@ mod tests {
 
         let different_connection = db
             .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
-                host: host2.clone(),
+                host: host2.clone().into(),
                 port: port2,
                 username: user2.clone(),
                 ..Default::default()
@@ -2830,7 +3117,7 @@ mod tests {
 
         let connection_id = db
             .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
-                host: host.clone(),
+                host: host.clone().into(),
                 port,
                 username: None,
                 ..Default::default()
@@ -2840,7 +3127,7 @@ mod tests {
 
         let same_connection_id = db
             .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
-                host: host.clone(),
+                host: host.clone().into(),
                 port,
                 username: user.clone(),
                 ..Default::default()
@@ -2870,7 +3157,7 @@ mod tests {
             ids.push(
                 db.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(
                     SshConnectionOptions {
-                        host: host.clone(),
+                        host: host.clone().into(),
                         port: *port,
                         username: user.clone(),
                         ..Default::default()
@@ -3048,4 +3335,53 @@ mod tests {
 
         assert_eq!(workspace.center_group, new_workspace.center_group);
     }
+
+    #[gpui::test]
+    async fn test_empty_workspace_window_bounds() {
+        zlog::init_test();
+
+        let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await;
+        let id = db.next_id().await.unwrap();
+
+        // Create a workspace with empty paths (empty workspace)
+        let empty_paths: &[&str] = &[];
+        let display_uuid = Uuid::new_v4();
+        let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds {
+            origin: point(px(100.0), px(200.0)),
+            size: size(px(800.0), px(600.0)),
+        }));
+
+        let workspace = SerializedWorkspace {
+            id,
+            paths: PathList::new(empty_paths),
+            location: SerializedWorkspaceLocation::Local,
+            center_group: Default::default(),
+            window_bounds: None,
+            display: None,
+            docks: Default::default(),
+            breakpoints: Default::default(),
+            centered_layout: false,
+            session_id: None,
+            window_id: None,
+            user_toolchains: Default::default(),
+        };
+
+        // Save the workspace (this creates the record with empty paths)
+        db.save_workspace(workspace.clone()).await;
+
+        // Save window bounds separately (as the actual code does via set_window_open_status)
+        db.set_window_open_status(id, window_bounds, display_uuid)
+            .await
+            .unwrap();
+
+        // Retrieve it using empty paths
+        let retrieved = db.workspace_for_roots(empty_paths).unwrap();
+
+        // Verify window bounds were persisted
+        assert_eq!(retrieved.id, id);
+        assert!(retrieved.window_bounds.is_some());
+        assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0);
+        assert!(retrieved.display.is_some());
+        assert_eq!(retrieved.display.unwrap(), display_uuid);
+    }
 }

crates/workspace/src/security_modal.rs 🔗

@@ -0,0 +1,334 @@
+//! A UI interface for managing the [`TrustedWorktrees`] data.
+
+use std::{
+    borrow::Cow,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use collections::{HashMap, HashSet};
+use gpui::{DismissEvent, EventEmitter, FocusHandle, Focusable, WeakEntity};
+
+use project::{
+    WorktreeId,
+    trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
+    worktree_store::WorktreeStore,
+};
+use smallvec::SmallVec;
+use theme::ActiveTheme;
+use ui::{
+    AlertModal, Checkbox, FluentBuilder, KeyBinding, ListBulletItem, ToggleState, prelude::*,
+};
+
+use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity};
+
+pub struct SecurityModal {
+    restricted_paths: HashMap<WorktreeId, RestrictedPath>,
+    home_dir: Option<PathBuf>,
+    trust_parents: bool,
+    worktree_store: WeakEntity<WorktreeStore>,
+    remote_host: Option<RemoteHostLocation>,
+    focus_handle: FocusHandle,
+    trusted: Option<bool>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct RestrictedPath {
+    abs_path: Arc<Path>,
+    is_file: bool,
+    host: Option<RemoteHostLocation>,
+}
+
+impl Focusable for SecurityModal {
+    fn focus_handle(&self, _: &ui::App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl EventEmitter<DismissEvent> for SecurityModal {}
+
+impl ModalView for SecurityModal {
+    fn fade_out_background(&self) -> bool {
+        true
+    }
+
+    fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context<Self>) -> DismissDecision {
+        match self.trusted {
+            Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"),
+            Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"),
+            None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"),
+        }
+        DismissDecision::Dismiss(true)
+    }
+}
+
+impl Render for SecurityModal {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        if self.restricted_paths.is_empty() {
+            self.dismiss(cx);
+            return v_flex().into_any_element();
+        }
+
+        let header_label = if self.restricted_paths.len() == 1 {
+            "Unrecognized Project"
+        } else {
+            "Unrecognized Projects"
+        };
+
+        let trust_label = self.build_trust_label();
+
+        AlertModal::new("security-modal")
+            .width(rems(40.))
+            .key_context("SecurityModal")
+            .track_focus(&self.focus_handle(cx))
+            .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| {
+                this.trust_and_dismiss(cx);
+            }))
+            .on_action(cx.listener(|security_modal, _: &ToggleWorktreeSecurity, _window, cx| {
+                security_modal.trusted = Some(false);
+                security_modal.dismiss(cx);
+            }))
+            .header(
+                v_flex()
+                    .p_3()
+                    .gap_1()
+                    .rounded_t_md()
+                    .bg(cx.theme().colors().editor_background.opacity(0.5))
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border_variant)
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .child(Icon::new(IconName::Warning).color(Color::Warning))
+                            .child(Label::new(header_label)),
+                    )
+                    .children(self.restricted_paths.values().filter_map(|restricted_path| {
+                        let abs_path = if restricted_path.is_file {
+                            restricted_path.abs_path.parent()
+                        } else {
+                            Some(restricted_path.abs_path.as_ref())
+                        }?;
+                        let label = match &restricted_path.host {
+                            Some(remote_host) => match &remote_host.user_name {
+                                Some(user_name) => format!(
+                                    "{} ({}@{})",
+                                    self.shorten_path(abs_path).display(),
+                                    user_name,
+                                    remote_host.host_identifier
+                                ),
+                                None => format!(
+                                    "{} ({})",
+                                    self.shorten_path(abs_path).display(),
+                                    remote_host.host_identifier
+                                ),
+                            },
+                            None => self.shorten_path(abs_path).display().to_string(),
+                        };
+                        Some(h_flex()
+                            .pl(IconSize::default().rems() + rems(0.5))
+                            .child(Label::new(label).color(Color::Muted)))
+                    })),
+            )
+            .child(
+                v_flex()
+                    .gap_2()
+                    .child(
+                        v_flex()
+                            .child(
+                                Label::new(
+                                    "Untrusted projects are opened in Restricted Mode to protect your system.",
+                                )
+                                .color(Color::Muted),
+                            )
+                            .child(
+                                Label::new(
+                                    "Review .zed/settings.json for any extensions or commands configured by this project.",
+                                )
+                                .color(Color::Muted),
+                            ),
+                    )
+                    .child(
+                        v_flex()
+                            .child(Label::new("Restricted Mode prevents:").color(Color::Muted))
+                            .child(ListBulletItem::new("Project settings from being applied"))
+                            .child(ListBulletItem::new("Language servers from running"))
+                            .child(ListBulletItem::new("MCP Server integrations from installing")),
+                    )
+                    .map(|this| match trust_label {
+                        Some(trust_label) => this.child(
+                            Checkbox::new("trust-parents", ToggleState::from(self.trust_parents))
+                                .label(trust_label)
+                                .on_click(cx.listener(
+                                    |security_modal, state: &ToggleState, _, cx| {
+                                        security_modal.trust_parents = state.selected();
+                                        cx.notify();
+                                        cx.stop_propagation();
+                                    },
+                                )),
+                        ),
+                        None => this,
+                    }),
+            )
+            .footer(
+                h_flex()
+                    .px_3()
+                    .pb_3()
+                    .gap_1()
+                    .justify_end()
+                    .child(
+                        Button::new("rm", "Stay in Restricted Mode")
+                            .key_binding(
+                                KeyBinding::for_action(
+                                    &ToggleWorktreeSecurity,
+                                    cx,
+                                )
+                                .map(|kb| kb.size(rems_from_px(12.))),
+                            )
+                            .on_click(cx.listener(move |security_modal, _, _, cx| {
+                                security_modal.trusted = Some(false);
+                                security_modal.dismiss(cx);
+                                cx.stop_propagation();
+                            })),
+                    )
+                    .child(
+                        Button::new("tc", "Trust and Continue")
+                            .style(ButtonStyle::Filled)
+                            .layer(ui::ElevationIndex::ModalSurface)
+                            .key_binding(
+                                KeyBinding::for_action(&menu::Confirm, cx)
+                                    .map(|kb| kb.size(rems_from_px(12.))),
+                            )
+                            .on_click(cx.listener(move |security_modal, _, _, cx| {
+                                security_modal.trust_and_dismiss(cx);
+                                cx.stop_propagation();
+                            })),
+                    ),
+            )
+            .into_any_element()
+    }
+}
+
+impl SecurityModal {
+    pub fn new(
+        worktree_store: WeakEntity<WorktreeStore>,
+        remote_host: Option<impl Into<RemoteHostLocation>>,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let mut this = Self {
+            worktree_store,
+            remote_host: remote_host.map(|host| host.into()),
+            restricted_paths: HashMap::default(),
+            focus_handle: cx.focus_handle(),
+            trust_parents: false,
+            home_dir: std::env::home_dir(),
+            trusted: None,
+        };
+        this.refresh_restricted_paths(cx);
+
+        this
+    }
+
+    fn build_trust_label(&self) -> Option<Cow<'static, str>> {
+        let mut has_restricted_files = false;
+        let available_parents = self
+            .restricted_paths
+            .values()
+            .filter(|restricted_path| {
+                has_restricted_files |= restricted_path.is_file;
+                !restricted_path.is_file
+            })
+            .filter_map(|restricted_path| restricted_path.abs_path.parent())
+            .collect::<SmallVec<[_; 2]>>();
+        match available_parents.len() {
+            0 => {
+                if has_restricted_files {
+                    Some(Cow::Borrowed("Trust all single files"))
+                } else {
+                    None
+                }
+            }
+            1 => Some(Cow::Owned(format!(
+                "Trust all projects in the {:} folder",
+                self.shorten_path(available_parents[0]).display()
+            ))),
+            _ => Some(Cow::Borrowed("Trust all projects in the parent folders")),
+        }
+    }
+
+    fn shorten_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
+        match &self.home_dir {
+            Some(home_dir) => path
+                .strip_prefix(home_dir)
+                .map(|stripped| Path::new("~").join(stripped))
+                .map(Cow::Owned)
+                .unwrap_or(Cow::Borrowed(path)),
+            None => Cow::Borrowed(path),
+        }
+    }
+
+    fn trust_and_dismiss(&mut self, cx: &mut Context<Self>) {
+        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+            trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                let mut paths_to_trust = self
+                    .restricted_paths
+                    .keys()
+                    .copied()
+                    .map(PathTrust::Worktree)
+                    .collect::<HashSet<_>>();
+                if self.trust_parents {
+                    paths_to_trust.extend(self.restricted_paths.values().filter_map(
+                        |restricted_paths| {
+                            if restricted_paths.is_file {
+                                None
+                            } else {
+                                let parent_abs_path =
+                                    restricted_paths.abs_path.parent()?.to_owned();
+                                Some(PathTrust::AbsPath(parent_abs_path))
+                            }
+                        },
+                    ));
+                }
+                trusted_worktrees.trust(paths_to_trust, self.remote_host.clone(), cx);
+            });
+        }
+
+        self.trusted = Some(true);
+        self.dismiss(cx);
+    }
+
+    pub fn dismiss(&mut self, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+
+    pub fn refresh_restricted_paths(&mut self, cx: &mut Context<Self>) {
+        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+            if let Some(worktree_store) = self.worktree_store.upgrade() {
+                let new_restricted_worktrees = trusted_worktrees
+                    .read(cx)
+                    .restricted_worktrees(worktree_store.read(cx), cx)
+                    .into_iter()
+                    .filter_map(|(worktree_id, abs_path)| {
+                        let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?;
+                        Some((
+                            worktree_id,
+                            RestrictedPath {
+                                abs_path,
+                                is_file: worktree.read(cx).is_single_file(),
+                                host: self.remote_host.clone(),
+                            },
+                        ))
+                    })
+                    .collect::<HashMap<_, _>>();
+
+                if self.restricted_paths != new_restricted_worktrees {
+                    self.trust_parents = false;
+                    self.restricted_paths = new_restricted_worktrees;
+                    cx.notify();
+                }
+            }
+        } else if !self.restricted_paths.is_empty() {
+            self.restricted_paths.clear();
+            cx.notify();
+        }
+    }
+}

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);
+        cx.notify();
+    }
+
+    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus_prev(cx);
+        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 🔗

@@ -9,6 +9,7 @@ pub mod pane_group;
 mod path_list;
 mod persistence;
 pub mod searchable;
+mod security_modal;
 pub mod shared_screen;
 mod status_bar;
 pub mod tasks;
@@ -16,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;
@@ -76,7 +78,9 @@ use project::{
     DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
     WorktreeSettings,
     debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
+    project_settings::ProjectSettings,
     toolchain_store::ToolchainStoreEvent,
+    trusted_worktrees::{TrustedWorktrees, TrustedWorktreesEvent},
 };
 use remote::{
     RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions,
@@ -85,7 +89,9 @@ use remote::{
 use schemars::JsonSchema;
 use serde::Deserialize;
 use session::AppSession;
-use settings::{CenteredPaddingSettings, Settings, SettingsLocation, update_settings_file};
+use settings::{
+    CenteredPaddingSettings, Settings, SettingsLocation, SettingsStore, update_settings_file,
+};
 use shared_screen::SharedScreen;
 use sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
@@ -129,13 +135,16 @@ pub use workspace_settings::{
 use zed_actions::{Spawn, feedback::FileBugReport};
 
 use crate::{
-    item::ItemBufferKind, notifications::NotificationId, utility_pane::UTILITY_PANE_MIN_WIDTH,
+    item::ItemBufferKind,
+    notifications::NotificationId,
+    utility_pane::{UTILITY_PANE_MIN_WIDTH, utility_slot_for_dock_position},
 };
 use crate::{
     persistence::{
         SerializedAxis,
         model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup},
     },
+    security_modal::SecurityModal,
     utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState},
 };
 
@@ -272,6 +281,16 @@ actions!(
         ToggleRightDock,
         /// Toggles zoom on the active pane.
         ToggleZoom,
+        /// Zooms in on the active pane.
+        ZoomIn,
+        /// Zooms out of the active pane.
+        ZoomOut,
+        /// If any worktrees are in restricted mode, shows a modal with possible actions.
+        /// If the modal is shown already, closes it without trusting any worktree.
+        ToggleWorktreeSecurity,
+        /// Clears all trusted worktrees, placing them in restricted mode on next open.
+        /// Requires restart to take effect on already opened projects.
+        ClearTrustedWorktrees,
         /// Stops following a collaborator.
         Unfollow,
         /// Restores the banner.
@@ -969,6 +988,7 @@ impl AppState {
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &mut App) -> Arc<Self> {
+        use fs::Fs;
         use node_runtime::NodeRuntime;
         use session::Session;
         use settings::SettingsStore;
@@ -979,6 +999,7 @@ impl AppState {
         }
 
         let fs = fs::FakeFs::new(cx.background_executor().clone());
+        <dyn Fs>::set_global(fs.clone(), cx);
         let languages = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
         let clock = Arc::new(clock::FakeSystemClock::new());
         let http_client = http_client::FakeHttpClient::with_404_response();
@@ -1167,6 +1188,7 @@ pub struct Workspace {
     _observe_current_user: Task<Result<()>>,
     _schedule_serialize_workspace: Option<Task<()>>,
     _schedule_serialize_ssh_paths: Option<Task<()>>,
+    _schedule_serialize_worktree_trust: Task<()>,
     pane_history_timestamp: Arc<AtomicUsize>,
     bounds: Bounds<Pixels>,
     pub centered_layout: bool,
@@ -1182,6 +1204,7 @@ pub struct Workspace {
     last_open_dock_positions: Vec<DockPosition>,
     removing: bool,
     utility_panes: UtilityPaneState,
+    next_modal_placement: Option<ModalPlacement>,
 }
 
 impl EventEmitter<Event> for Workspace {}
@@ -1212,6 +1235,41 @@ impl Workspace {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+            cx.subscribe(&trusted_worktrees, |workspace, worktrees_store, e, cx| {
+                if let TrustedWorktreesEvent::Trusted(..) = e {
+                    // Do not persist auto trusted worktrees
+                    if !ProjectSettings::get_global(cx).session.trust_all_worktrees {
+                        let new_trusted_worktrees =
+                            worktrees_store.update(cx, |worktrees_store, cx| {
+                                worktrees_store.trusted_paths_for_serialization(cx)
+                            });
+                        let timeout = cx.background_executor().timer(SERIALIZATION_THROTTLE_TIME);
+                        workspace._schedule_serialize_worktree_trust =
+                            cx.background_spawn(async move {
+                                timeout.await;
+                                persistence::DB
+                                    .save_trusted_worktrees(new_trusted_worktrees)
+                                    .await
+                                    .log_err();
+                            });
+                    }
+                }
+            })
+            .detach();
+
+            cx.observe_global::<SettingsStore>(|_, cx| {
+                if ProjectSettings::get_global(cx).session.trust_all_worktrees {
+                    if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+                        trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                            trusted_worktrees.auto_trust_all(cx);
+                        })
+                    }
+                }
+            })
+            .detach();
+        }
+
         cx.subscribe_in(&project, window, move |this, _, event, window, cx| {
             match event {
                 project::Event::RemoteIdChanged(_) => {
@@ -1222,11 +1280,25 @@ impl Workspace {
                     this.collaborator_left(*peer_id, window, cx);
                 }
 
-                project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => {
-                    this.update_window_title(window, cx);
-                    this.serialize_workspace(window, cx);
-                    // This event could be triggered by `AddFolderToProject` or `RemoveFromProject`.
-                    this.update_history(cx);
+                project::Event::WorktreeUpdatedEntries(worktree_id, _) => {
+                    if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+                        trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                            trusted_worktrees.can_trust(*worktree_id, cx);
+                        });
+                    }
+                }
+
+                project::Event::WorktreeRemoved(_) => {
+                    this.update_worktree_data(window, cx);
+                }
+
+                project::Event::WorktreeAdded(worktree_id) => {
+                    if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+                        trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+                            trusted_worktrees.can_trust(*worktree_id, cx);
+                        });
+                    }
+                    this.update_worktree_data(window, cx);
                 }
 
                 project::Event::DisconnectedFromHost => {
@@ -1323,7 +1395,7 @@ impl Workspace {
 
         cx.on_focus_lost(window, |this, window, cx| {
             let focus_handle = this.focus_handle(cx);
-            window.focus(&focus_handle);
+            window.focus(&focus_handle, cx);
         })
         .detach();
 
@@ -1347,7 +1419,7 @@ impl Workspace {
         cx.subscribe_in(&center_pane, window, Self::handle_pane_event)
             .detach();
 
-        window.focus(&center_pane.focus_handle(cx));
+        window.focus(&center_pane.focus_handle(cx), cx);
 
         cx.emit(Event::PaneAdded(center_pane.clone()));
 
@@ -1438,6 +1510,15 @@ impl Workspace {
                             && let Ok(display_uuid) = display.uuid()
                         {
                             let window_bounds = window.inner_window_bounds();
+                            let has_paths = !this.root_paths(cx).is_empty();
+                            if !has_paths {
+                                cx.background_executor()
+                                    .spawn(persistence::write_default_window_bounds(
+                                        window_bounds,
+                                        display_uuid,
+                                    ))
+                                    .detach_and_log_err(cx);
+                            }
                             if let Some(database_id) = workspace_id {
                                 cx.background_executor()
                                     .spawn(DB.set_window_open_status(
@@ -1446,6 +1527,13 @@ impl Workspace {
                                         display_uuid,
                                     ))
                                     .detach_and_log_err(cx);
+                            } else {
+                                cx.background_executor()
+                                    .spawn(persistence::write_default_window_bounds(
+                                        window_bounds,
+                                        display_uuid,
+                                    ))
+                                    .detach_and_log_err(cx);
                             }
                         }
                         this.bounds_save_task_queued.take();
@@ -1469,7 +1557,7 @@ impl Workspace {
             }),
         ];
 
-        cx.defer_in(window, |this, window, cx| {
+        cx.defer_in(window, move |this, window, cx| {
             this.update_window_title(window, cx);
             this.show_initial_notifications(cx);
         });
@@ -1512,6 +1600,7 @@ impl Workspace {
             _apply_leader_updates,
             _schedule_serialize_workspace: None,
             _schedule_serialize_ssh_paths: None,
+            _schedule_serialize_worktree_trust: Task::ready(()),
             leader_updates_tx,
             _subscriptions: subscriptions,
             pane_history_timestamp,
@@ -1532,6 +1621,7 @@ impl Workspace {
             last_open_dock_positions: Vec::new(),
             removing: false,
             utility_panes: UtilityPaneState::default(),
+            next_modal_placement: None,
         }
     }
 
@@ -1540,6 +1630,7 @@ impl Workspace {
         app_state: Arc<AppState>,
         requesting_window: Option<WindowHandle<Workspace>>,
         env: Option<HashMap<String, String>>,
+        init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
         cx: &mut App,
     ) -> Task<
         anyhow::Result<(
@@ -1554,6 +1645,7 @@ impl Workspace {
             app_state.languages.clone(),
             app_state.fs.clone(),
             env,
+            true,
             cx,
         );
 
@@ -1607,8 +1699,22 @@ impl Workspace {
 
             let toolchains = DB.toolchains(workspace_id).await?;
 
-            for (toolchain, worktree_id, path) in toolchains {
+            for (toolchain, worktree_path, path) in toolchains {
                 let toolchain_path = PathBuf::from(toolchain.path.clone().to_string());
+                let Some(worktree_id) = project_handle.read_with(cx, |this, cx| {
+                    this.find_worktree(&worktree_path, cx)
+                        .and_then(|(worktree, rel_path)| {
+                            if rel_path.is_empty() {
+                                Some(worktree.read(cx).id())
+                            } else {
+                                None
+                            }
+                        })
+                })?
+                else {
+                    // We did not find a worktree with a given path, but that's whatever.
+                    continue;
+                };
                 if !app_state.fs.is_file(toolchain_path.as_path()).await {
                     continue;
                 }
@@ -1646,6 +1752,12 @@ impl Workspace {
                         );
 
                         workspace.centered_layout = centered_layout;
+
+                        // Call init callback to add items before window renders
+                        if let Some(init) = init {
+                            init(&mut workspace, window, cx);
+                        }
+
                         workspace
                     });
                 })?;
@@ -1655,15 +1767,15 @@ impl Workspace {
 
                 let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
                     (Some(WindowBounds::Windowed(bounds)), None)
-                } else if let Some(workspace) = serialized_workspace.as_ref() {
+                } else if let Some(workspace) = serialized_workspace.as_ref()
+                    && let Some(display) = workspace.display
+                    && let Some(bounds) = workspace.window_bounds.as_ref()
+                {
                     // Reopening an existing workspace - restore its saved bounds
-                    if let (Some(display), Some(bounds)) =
-                        (workspace.display, workspace.window_bounds.as_ref())
-                    {
-                        (Some(bounds.0), Some(display))
-                    } else {
-                        (None, None)
-                    }
+                    (Some(bounds.0), Some(display))
+                } else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
+                    // New or empty workspace - use the last known window bounds
+                    (Some(bounds), Some(display))
                 } else {
                     // New window - let GPUI's default_bounds() handle cascading
                     (None, None)
@@ -1689,6 +1801,12 @@ impl Workspace {
                                 cx,
                             );
                             workspace.centered_layout = centered_layout;
+
+                            // Call init callback to add items before window renders
+                            if let Some(init) = init {
+                                init(&mut workspace, window, cx);
+                            }
+
                             workspace
                         })
                     }
@@ -1784,10 +1902,18 @@ impl Workspace {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let mut found_in_dock = None;
         for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] {
-            dock.update(cx, |dock, cx| {
-                dock.remove_panel(panel, window, cx);
-            })
+            let found = dock.update(cx, |dock, cx| dock.remove_panel(panel, window, cx));
+
+            if found {
+                found_in_dock = Some(dock.clone());
+            }
+        }
+        if let Some(found_in_dock) = found_in_dock {
+            let position = found_in_dock.read(cx).position();
+            let slot = utility_slot_for_dock_position(position);
+            self.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx);
         }
     }
 
@@ -1948,7 +2074,7 @@ impl Workspace {
     ) -> Task<Result<()>> {
         let to_load = if let Some(pane) = pane.upgrade() {
             pane.update(cx, |pane, cx| {
-                window.focus(&pane.focus_handle(cx));
+                window.focus(&pane.focus_handle(cx), cx);
                 loop {
                     // Retrieve the weak item handle from the history.
                     let entry = pane.nav_history_mut().pop(mode, cx)?;
@@ -2254,7 +2380,7 @@ impl Workspace {
             Task::ready(Ok(callback(self, window, cx)))
         } else {
             let env = self.project.read(cx).cli_environment(cx);
-            let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, cx);
+            let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx);
             cx.spawn_in(window, async move |_vh, cx| {
                 let (workspace, _) = task.await?;
                 workspace.update(cx, callback)
@@ -3067,7 +3193,7 @@ impl Workspace {
                     }
                 } else {
                     let focus_handle = &active_panel.panel_focus_handle(cx);
-                    window.focus(focus_handle);
+                    window.focus(focus_handle, cx);
                     reveal_dock = true;
                 }
             }
@@ -3079,7 +3205,7 @@ impl Workspace {
 
         if focus_center {
             self.active_pane
-                .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx)))
+                .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx))
         }
 
         cx.notify();
@@ -3247,7 +3373,7 @@ impl Workspace {
                     if let Some(panel) = panel.as_ref() {
                         if should_focus(&**panel, window, cx) {
                             dock.set_open(true, window, cx);
-                            panel.panel_focus_handle(cx).focus(window);
+                            panel.panel_focus_handle(cx).focus(window, cx);
                         } else {
                             focus_center = true;
                         }
@@ -3257,7 +3383,7 @@ impl Workspace {
 
                 if focus_center {
                     self.active_pane
-                        .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx)))
+                        .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx))
                 }
 
                 result_panel = panel;
@@ -3331,7 +3457,7 @@ impl Workspace {
 
         if focus_center {
             self.active_pane
-                .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx)))
+                .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx))
         }
 
         if self.zoomed_position != dock_to_reveal {
@@ -3362,7 +3488,7 @@ impl Workspace {
             .detach();
         self.panes.push(pane.clone());
 
-        window.focus(&pane.focus_handle(cx));
+        window.focus(&pane.focus_handle(cx), cx);
 
         cx.emit(Event::PaneAdded(pane.clone()));
         pane
@@ -3757,7 +3883,7 @@ impl Workspace {
     ) {
         let panes = self.center.panes();
         if let Some(pane) = panes.get(action.0).map(|p| (*p).clone()) {
-            window.focus(&pane.focus_handle(cx));
+            window.focus(&pane.focus_handle(cx), cx);
         } else {
             self.split_and_clone(self.active_pane.clone(), SplitDirection::Right, window, cx)
                 .detach();
@@ -3827,7 +3953,7 @@ impl Workspace {
         if let Some(ix) = panes.iter().position(|pane| **pane == self.active_pane) {
             let next_ix = (ix + 1) % panes.len();
             let next_pane = panes[next_ix].clone();
-            window.focus(&next_pane.focus_handle(cx));
+            window.focus(&next_pane.focus_handle(cx), cx);
         }
     }
 
@@ -3836,7 +3962,7 @@ impl Workspace {
         if let Some(ix) = panes.iter().position(|pane| **pane == self.active_pane) {
             let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1);
             let prev_pane = panes[prev_ix].clone();
-            window.focus(&prev_pane.focus_handle(cx));
+            window.focus(&prev_pane.focus_handle(cx), cx);
         }
     }
 
@@ -3932,7 +4058,7 @@ impl Workspace {
             Some(ActivateInDirectionTarget::Pane(pane)) => {
                 let pane = pane.read(cx);
                 if let Some(item) = pane.active_item() {
-                    item.item_focus_handle(cx).focus(window);
+                    item.item_focus_handle(cx).focus(window, cx);
                 } else {
                     log::error!(
                         "Could not find a focus target when in switching focus in {direction} direction for a pane",
@@ -3944,7 +4070,7 @@ impl Workspace {
                 window.defer(cx, move |window, cx| {
                     let dock = dock.read(cx);
                     if let Some(panel) = dock.active_panel() {
-                        panel.panel_focus_handle(cx).focus(window);
+                        panel.panel_focus_handle(cx).focus(window, cx);
                     } else {
                         log::error!("Could not find a focus target when in switching focus in {direction} direction for a {:?} dock", dock.position());
                     }
@@ -4113,7 +4239,7 @@ impl Workspace {
         cx: &mut Context<Self>,
     ) {
         self.active_pane = pane.clone();
-        self.active_item_path_changed(window, cx);
+        self.active_item_path_changed(true, window, cx);
         self.last_active_center_pane = Some(pane.downgrade());
     }
 
@@ -4170,7 +4296,7 @@ impl Workspace {
                 }
                 serialize_workspace = *focus_changed || pane != self.active_pane();
                 if pane == self.active_pane() {
-                    self.active_item_path_changed(window, cx);
+                    self.active_item_path_changed(*focus_changed, window, cx);
                     self.update_active_view_for_followers(window, cx);
                 } else if *local {
                     self.set_active_pane(pane, window, cx);
@@ -4186,7 +4312,7 @@ impl Workspace {
             }
             pane::Event::ChangeItemTitle => {
                 if *pane == self.active_pane {
-                    self.active_item_path_changed(window, cx);
+                    self.active_item_path_changed(false, window, cx);
                 }
                 serialize_workspace = false;
             }
@@ -4355,7 +4481,7 @@ impl Workspace {
 
             cx.notify();
         } else {
-            self.active_item_path_changed(window, cx);
+            self.active_item_path_changed(true, window, cx);
         }
         cx.emit(Event::PaneRemoved);
     }
@@ -4564,7 +4690,7 @@ impl Workspace {
 
         // if you're already following, find the right pane and focus it.
         if let Some(follower_state) = self.follower_states.get(&leader_id) {
-            window.focus(&follower_state.pane().focus_handle(cx));
+            window.focus(&follower_state.pane().focus_handle(cx), cx);
 
             return;
         }
@@ -4609,14 +4735,19 @@ impl Workspace {
         self.follower_states.contains_key(&id.into())
     }
 
-    fn active_item_path_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+    fn active_item_path_changed(
+        &mut self,
+        focus_changed: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         cx.emit(Event::ActiveItemChanged);
         let active_entry = self.active_project_path(cx);
         self.project.update(cx, |project, cx| {
             project.set_active_path(active_entry.clone(), cx)
         });
 
-        if let Some(project_path) = &active_entry {
+        if focus_changed && let Some(project_path) = &active_entry {
             let git_store_entity = self.project.read(cx).git_store().clone();
             git_store_entity.update(cx, |git_store, cx| {
                 git_store.set_active_repo_for_path(project_path, cx);
@@ -5376,12 +5507,12 @@ impl Workspace {
     ) {
         self.panes.retain(|p| p != pane);
         if let Some(focus_on) = focus_on {
-            focus_on.update(cx, |pane, cx| window.focus(&pane.focus_handle(cx)));
+            focus_on.update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx));
         } else if self.active_pane() == pane {
             self.panes
                 .last()
                 .unwrap()
-                .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx)));
+                .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx));
         }
         if self.last_active_center_pane == Some(pane.downgrade()) {
             self.last_active_center_pane = None;
@@ -5555,12 +5686,24 @@ impl Workspace {
                     persistence::DB.save_workspace(serialized_workspace).await;
                 })
             }
-            WorkspaceLocation::DetachFromSession => window.spawn(cx, async move |_| {
-                persistence::DB
-                    .set_session_id(database_id, None)
-                    .await
-                    .log_err();
-            }),
+            WorkspaceLocation::DetachFromSession => {
+                let window_bounds = SerializedWindowBounds(window.window_bounds());
+                let display = window.display(cx).and_then(|d| d.uuid().ok());
+                window.spawn(cx, async move |_| {
+                    persistence::DB
+                        .set_window_open_status(
+                            database_id,
+                            window_bounds,
+                            display.unwrap_or_default(),
+                        )
+                        .await
+                        .log_err();
+                    persistence::DB
+                        .set_session_id(database_id, None)
+                        .await
+                        .log_err();
+                })
+            }
             WorkspaceLocation::None => Task::ready(()),
         }
     }
@@ -5933,6 +6076,27 @@ impl Workspace {
                     }
                 },
             ))
+            .on_action(cx.listener(
+                |workspace: &mut Workspace, _: &ToggleWorktreeSecurity, window, cx| {
+                    workspace.show_worktree_trust_security_modal(true, window, cx);
+                },
+            ))
+            .on_action(
+                cx.listener(|_: &mut Workspace, _: &ClearTrustedWorktrees, _, cx| {
+                    if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
+                        trusted_worktrees.update(cx, |trusted_worktrees, _| {
+                            trusted_worktrees.clear_trusted_paths()
+                        });
+                        let clear_task = persistence::DB.clear_trusted_worktrees();
+                        cx.spawn(async move |_, cx| {
+                            if clear_task.await.log_err().is_some() {
+                                cx.update(|cx| reload(cx)).ok();
+                            }
+                        })
+                        .detach();
+                    }
+                }),
+            )
             .on_action(cx.listener(
                 |workspace: &mut Workspace, _: &ReopenClosedItem, window, cx| {
                     workspace.reopen_closed_item(window, cx).detach();
@@ -6118,7 +6282,7 @@ impl Workspace {
         let workspace = Self::new(Default::default(), project, app_state, window, cx);
         workspace
             .active_pane
-            .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx)));
+            .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx));
         workspace
     }
 
@@ -6164,12 +6328,25 @@ impl Workspace {
         self.modal_layer.read(cx).active_modal()
     }
 
+    pub fn is_modal_open<V: 'static>(&self, cx: &App) -> bool {
+        self.modal_layer.read(cx).active_modal::<V>().is_some()
+    }
+
+    pub fn set_next_modal_placement(&mut self, placement: ModalPlacement) {
+        self.next_modal_placement = Some(placement);
+    }
+
+    fn take_next_modal_placement(&mut self) -> ModalPlacement {
+        self.next_modal_placement.take().unwrap_or_default()
+    }
+
     pub fn toggle_modal<V: ModalView, B>(&mut self, window: &mut Window, cx: &mut App, build: B)
     where
         B: FnOnce(&mut Window, &mut Context<V>) -> V,
     {
+        let placement = self.take_next_modal_placement();
         self.modal_layer.update(cx, |modal_layer, cx| {
-            modal_layer.toggle_modal(window, cx, build)
+            modal_layer.toggle_modal_with_placement(window, cx, placement, build)
         })
     }
 
@@ -6413,6 +6590,48 @@ impl Workspace {
             file.project.all_languages.defaults.show_edit_predictions = Some(!show_edit_predictions)
         });
     }
+
+    pub fn show_worktree_trust_security_modal(
+        &mut self,
+        toggle: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(security_modal) = self.active_modal::<SecurityModal>(cx) {
+            if toggle {
+                security_modal.update(cx, |security_modal, cx| {
+                    security_modal.dismiss(cx);
+                })
+            } else {
+                security_modal.update(cx, |security_modal, cx| {
+                    security_modal.refresh_restricted_paths(cx);
+                });
+            }
+        } else {
+            let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx)
+                .map(|trusted_worktrees| {
+                    trusted_worktrees
+                        .read(cx)
+                        .has_restricted_worktrees(&self.project().read(cx).worktree_store(), cx)
+                })
+                .unwrap_or(false);
+            if has_restricted_worktrees {
+                let project = self.project().read(cx);
+                let remote_host = project.remote_connection_options(cx);
+                let worktree_store = project.worktree_store().downgrade();
+                self.toggle_modal(window, cx, |_, cx| {
+                    SecurityModal::new(worktree_store, remote_host, cx)
+                });
+            }
+        }
+    }
+
+    fn update_worktree_data(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) {
+        self.update_window_title(window, cx);
+        self.serialize_workspace(window, cx);
+        // This event could be triggered by `AddFolderToProject` or `RemoveFromProject`.
+        self.update_history(cx);
+    }
 }
 
 fn leader_border_for_pane(
@@ -7599,7 +7818,14 @@ pub fn join_channel(
             // no open workspaces, make one to show the error in (blergh)
             let (window_handle, _) = cx
                 .update(|cx| {
-                    Workspace::new_local(vec![], app_state.clone(), requesting_window, None, cx)
+                    Workspace::new_local(
+                        vec![],
+                        app_state.clone(),
+                        requesting_window,
+                        None,
+                        None,
+                        cx,
+                    )
                 })?
                 .await?;
 
@@ -7665,7 +7891,7 @@ pub async fn get_any_active_workspace(
     // find an existing workspace to focus and show call controls
     let active_window = activate_any_workspace_window(&mut cx);
     if active_window.is_none() {
-        cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, cx))?
+        cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, cx))?
             .await?;
     }
     activate_any_workspace_window(&mut cx).context("could not open zed")
@@ -7832,6 +8058,7 @@ pub fn open_paths(
                     app_state.clone(),
                     open_options.replace_window,
                     open_options.env,
+                    None,
                     cx,
                 )
             })?
@@ -7876,14 +8103,17 @@ pub fn open_new(
     cx: &mut App,
     init: impl FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + 'static + Send,
 ) -> Task<anyhow::Result<()>> {
-    let task = Workspace::new_local(Vec::new(), app_state, None, open_options.env, cx);
-    cx.spawn(async move |cx| {
-        let (workspace, opened_paths) = task.await?;
-        workspace.update(cx, |workspace, window, cx| {
-            if opened_paths.is_empty() {
-                init(workspace, window, cx)
-            }
-        })?;
+    let task = Workspace::new_local(
+        Vec::new(),
+        app_state,
+        None,
+        open_options.env,
+        Some(Box::new(init)),
+        cx,
+    );
+    cx.spawn(async move |_cx| {
+        let (_workspace, _opened_paths) = task.await?;
+        // Init callback is called synchronously during workspace creation
         Ok(())
     })
 }
@@ -7963,6 +8193,7 @@ pub fn open_remote_project_with_new_connection(
                 app_state.user_store.clone(),
                 app_state.languages.clone(),
                 app_state.fs.clone(),
+                true,
                 cx,
             )
         })?;
@@ -8015,9 +8246,22 @@ async fn open_remote_project_inner(
     cx: &mut AsyncApp,
 ) -> Result<Vec<Option<Box<dyn ItemHandle>>>> {
     let toolchains = DB.toolchains(workspace_id).await?;
-    for (toolchain, worktree_id, path) in toolchains {
+    for (toolchain, worktree_path, path) in toolchains {
         project
             .update(cx, |this, cx| {
+                let Some(worktree_id) =
+                    this.find_worktree(&worktree_path, cx)
+                        .and_then(|(worktree, rel_path)| {
+                            if rel_path.is_empty() {
+                                Some(worktree.read(cx).id())
+                            } else {
+                                None
+                            }
+                        })
+                else {
+                    return Task::ready(None);
+                };
+
                 this.activate_toolchain(ProjectPath { worktree_id, path }, toolchain, cx)
             })?
             .await;
@@ -8538,7 +8782,7 @@ fn move_all_items(
         // This automatically removes duplicate items in the pane
         to_pane.update(cx, |destination, cx| {
             destination.add_item(item_handle, true, true, None, window, cx);
-            window.focus(&destination.focus_handle(cx))
+            window.focus(&destination.focus_handle(cx), cx)
         });
     }
 }
@@ -8582,7 +8826,7 @@ pub fn move_item(
             cx,
         );
         if activate {
-            window.focus(&destination.focus_handle(cx))
+            window.focus(&destination.focus_handle(cx), cx)
         }
     });
 }
@@ -8684,14 +8928,13 @@ pub fn remote_workspace_position_from_db(
         } else {
             let restorable_bounds = serialized_workspace
                 .as_ref()
-                .and_then(|workspace| Some((workspace.display?, workspace.window_bounds?)))
-                .or_else(|| {
-                    let (display, window_bounds) = DB.last_window().log_err()?;
-                    Some((display?, window_bounds?))
-                });
+                .and_then(|workspace| {
+                    Some((workspace.display?, workspace.window_bounds.map(|b| b.0)?))
+                })
+                .or_else(|| persistence::read_default_window_bounds());
 
-            if let Some((serialized_display, serialized_status)) = restorable_bounds {
-                (Some(serialized_status.0), Some(serialized_display))
+            if let Some((serialized_display, serialized_bounds)) = restorable_bounds {
+                (Some(serialized_bounds), Some(serialized_display))
             } else {
                 (None, None)
             }
@@ -9181,7 +9424,7 @@ mod tests {
         let right_pane = right_pane.await.unwrap();
         cx.focus(&right_pane);
 
-        let mut close = right_pane.update_in(cx, |pane, window, cx| {
+        let close = right_pane.update_in(cx, |pane, window, cx| {
             pane.close_all_items(&CloseAllItems::default(), window, cx)
                 .unwrap()
         });
@@ -9193,9 +9436,16 @@ mod tests {
         assert!(!msg.contains("3.txt"));
         assert!(!msg.contains("4.txt"));
 
+        // With best-effort close, cancelling item 1 keeps it open but items 4
+        // and (3,4) still close since their entries exist in left pane.
         cx.simulate_prompt_answer("Cancel");
         close.await;
 
+        right_pane.read_with(cx, |pane, _| {
+            assert_eq!(pane.items_len(), 1);
+        });
+
+        // Remove item 3 from left pane, making (2,3) the only item with entry 3.
         left_pane
             .update_in(cx, |left_pane, window, cx| {
                 left_pane.close_item_by_id(
@@ -9208,26 +9458,25 @@ mod tests {
             .await
             .unwrap();
 
-        close = right_pane.update_in(cx, |pane, window, cx| {
+        let close = left_pane.update_in(cx, |pane, window, cx| {
             pane.close_all_items(&CloseAllItems::default(), window, cx)
                 .unwrap()
         });
         cx.executor().run_until_parked();
 
         let details = cx.pending_prompt().unwrap().1;
-        assert!(details.contains("1.txt"));
-        assert!(!details.contains("2.txt"));
+        assert!(details.contains("0.txt"));
         assert!(details.contains("3.txt"));
-        // ideally this assertion could be made, but today we can only
-        // save whole items not project items, so the orphaned item 3 causes
-        // 4 to be saved too.
-        // assert!(!details.contains("4.txt"));
+        assert!(details.contains("4.txt"));
+        // Ideally 2.txt wouldn't appear since entry 2 still exists in item 2.
+        // But we can only save whole items, so saving (2,3) for entry 3 includes 2.
+        // assert!(!details.contains("2.txt"));
 
         cx.simulate_prompt_answer("Save all");
-
         cx.executor().run_until_parked();
         close.await;
-        right_pane.read_with(cx, |pane, _| {
+
+        left_pane.read_with(cx, |pane, _| {
             assert_eq!(pane.items_len(), 0);
         });
     }
@@ -9594,6 +9843,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/worktree/Cargo.toml 🔗

@@ -25,8 +25,10 @@ test-support = [
 [dependencies]
 anyhow.workspace = true
 async-lock.workspace = true
+chardetng.workspace = true
 clock.workspace = true
 collections.workspace = true
+encoding_rs.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true

crates/worktree/src/ignore.rs 🔗

@@ -13,6 +13,10 @@ pub enum IgnoreStackEntry {
     Global {
         ignore: Arc<Gitignore>,
     },
+    RepoExclude {
+        ignore: Arc<Gitignore>,
+        parent: Arc<IgnoreStackEntry>,
+    },
     Some {
         abs_base_path: Arc<Path>,
         ignore: Arc<Gitignore>,
@@ -21,6 +25,12 @@ pub enum IgnoreStackEntry {
     All,
 }
 
+#[derive(Debug)]
+pub enum IgnoreKind {
+    Gitignore(Arc<Path>),
+    RepoExclude,
+}
+
 impl IgnoreStack {
     pub fn none() -> Self {
         Self {
@@ -43,13 +53,19 @@ impl IgnoreStack {
         }
     }
 
-    pub fn append(self, abs_base_path: Arc<Path>, ignore: Arc<Gitignore>) -> Self {
+    pub fn append(self, kind: IgnoreKind, ignore: Arc<Gitignore>) -> Self {
         let top = match self.top.as_ref() {
             IgnoreStackEntry::All => self.top.clone(),
-            _ => Arc::new(IgnoreStackEntry::Some {
-                abs_base_path,
-                ignore,
-                parent: self.top.clone(),
+            _ => Arc::new(match kind {
+                IgnoreKind::Gitignore(abs_base_path) => IgnoreStackEntry::Some {
+                    abs_base_path,
+                    ignore,
+                    parent: self.top.clone(),
+                },
+                IgnoreKind::RepoExclude => IgnoreStackEntry::RepoExclude {
+                    ignore,
+                    parent: self.top.clone(),
+                },
             }),
         };
         Self {
@@ -84,6 +100,17 @@ impl IgnoreStack {
                     ignore::Match::Whitelist(_) => false,
                 }
             }
+            IgnoreStackEntry::RepoExclude { ignore, parent } => {
+                match ignore.matched(abs_path, is_dir) {
+                    ignore::Match::None => IgnoreStack {
+                        repo_root: self.repo_root.clone(),
+                        top: parent.clone(),
+                    }
+                    .is_abs_path_ignored(abs_path, is_dir),
+                    ignore::Match::Ignore(_) => true,
+                    ignore::Match::Whitelist(_) => false,
+                }
+            }
             IgnoreStackEntry::Some {
                 abs_base_path,
                 ignore,

crates/worktree/src/worktree.rs 🔗

@@ -5,8 +5,10 @@ mod worktree_tests;
 
 use ::ignore::gitignore::{Gitignore, GitignoreBuilder};
 use anyhow::{Context as _, Result, anyhow};
+use chardetng::EncodingDetector;
 use clock::ReplicaId;
 use collections::{HashMap, HashSet, VecDeque};
+use encoding_rs::Encoding;
 use fs::{Fs, MTime, PathEvent, RemoveOptions, Watcher, copy_recursive, read_dir_items};
 use futures::{
     FutureExt as _, Stream, StreamExt,
@@ -19,7 +21,8 @@ use futures::{
 };
 use fuzzy::CharBag;
 use git::{
-    COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, status::GitSummary,
+    COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, REPO_EXCLUDE,
+    status::GitSummary,
 };
 use gpui::{
     App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority,
@@ -71,6 +74,8 @@ use util::{
 };
 pub use worktree_settings::WorktreeSettings;
 
+use crate::ignore::IgnoreKind;
+
 pub const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
 
 /// A set of local or remote files that are being opened as part of a project.
@@ -102,6 +107,8 @@ pub enum CreatedEntry {
 pub struct LoadedFile {
     pub file: Arc<File>,
     pub text: String,
+    pub encoding: &'static Encoding,
+    pub has_bom: bool,
 }
 
 pub struct LoadedBinaryFile {
@@ -233,6 +240,9 @@ impl Default for WorkDirectory {
 pub struct LocalSnapshot {
     snapshot: Snapshot,
     global_gitignore: Option<Arc<Gitignore>>,
+    /// Exclude files for all git repositories in the worktree, indexed by their absolute path.
+    /// The boolean indicates whether the gitignore needs to be updated.
+    repo_exclude_by_work_dir_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
     /// All of the gitignore files in the worktree, indexed by their absolute path.
     /// The boolean indicates whether the gitignore needs to be updated.
     ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
@@ -393,6 +403,7 @@ impl Worktree {
             let mut snapshot = LocalSnapshot {
                 ignores_by_parent_abs_path: Default::default(),
                 global_gitignore: Default::default(),
+                repo_exclude_by_work_dir_abs_path: Default::default(),
                 git_repositories: Default::default(),
                 snapshot: Snapshot::new(
                     cx.entity_id().as_u64(),
@@ -734,10 +745,14 @@ impl Worktree {
         path: Arc<RelPath>,
         text: Rope,
         line_ending: LineEnding,
+        encoding: &'static Encoding,
+        has_bom: bool,
         cx: &Context<Worktree>,
     ) -> Task<Result<Arc<File>>> {
         match self {
-            Worktree::Local(this) => this.write_file(path, text, line_ending, cx),
+            Worktree::Local(this) => {
+                this.write_file(path, text, line_ending, encoding, has_bom, cx)
+            }
             Worktree::Remote(_) => {
                 Task::ready(Err(anyhow!("remote worktree can't yet write files")))
             }
@@ -1344,7 +1359,9 @@ impl LocalWorktree {
                     anyhow::bail!("File is too large to load");
                 }
             }
-            let text = fs.load(&abs_path).await?;
+
+            let content = fs.load_bytes(&abs_path).await?;
+            let (text, encoding, has_bom) = decode_byte(content)?;
 
             let worktree = this.upgrade().context("worktree was dropped")?;
             let file = match entry.await? {
@@ -1372,7 +1389,12 @@ impl LocalWorktree {
                 }
             };
 
-            Ok(LoadedFile { file, text })
+            Ok(LoadedFile {
+                file,
+                text,
+                encoding,
+                has_bom,
+            })
         })
     }
 
@@ -1455,6 +1477,8 @@ impl LocalWorktree {
         path: Arc<RelPath>,
         text: Rope,
         line_ending: LineEnding,
+        encoding: &'static Encoding,
+        has_bom: bool,
         cx: &Context<Worktree>,
     ) -> Task<Result<Arc<File>>> {
         let fs = self.fs.clone();
@@ -1464,7 +1488,68 @@ impl LocalWorktree {
         let write = cx.background_spawn({
             let fs = fs.clone();
             let abs_path = abs_path.clone();
-            async move { fs.save(&abs_path, &text, line_ending).await }
+            async move {
+                // For UTF-8, use the optimized `fs.save` which writes Rope chunks directly to disk
+                // without allocating a contiguous string.
+                if encoding == encoding_rs::UTF_8 && !has_bom {
+                    return fs.save(&abs_path, &text, line_ending).await;
+                }
+
+                // For legacy encodings (e.g. Shift-JIS), we fall back to converting the entire Rope
+                // to a String/Bytes in memory before writing.
+                //
+                // Note: This is inefficient for very large files compared to the streaming approach above,
+                // but supporting streaming writes for arbitrary encodings would require a significant
+                // refactor of the `fs` crate to expose a Writer interface.
+                let text_string = text.to_string();
+                let normalized_text = match line_ending {
+                    LineEnding::Unix => text_string,
+                    LineEnding::Windows => text_string.replace('\n', "\r\n"),
+                };
+
+                // Create the byte vector manually for UTF-16 encodings because encoding_rs encodes to UTF-8 by default (per WHATWG standards),
+                //  which is not what we want for saving files.
+                let bytes = if encoding == encoding_rs::UTF_16BE {
+                    let mut data = Vec::with_capacity(normalized_text.len() * 2 + 2);
+                    if has_bom {
+                        data.extend_from_slice(&[0xFE, 0xFF]); // BOM
+                    }
+                    let utf16be_bytes =
+                        normalized_text.encode_utf16().flat_map(|u| u.to_be_bytes());
+                    data.extend(utf16be_bytes);
+                    data.into()
+                } else if encoding == encoding_rs::UTF_16LE {
+                    let mut data = Vec::with_capacity(normalized_text.len() * 2 + 2);
+                    if has_bom {
+                        data.extend_from_slice(&[0xFF, 0xFE]); // BOM
+                    }
+                    let utf16le_bytes =
+                        normalized_text.encode_utf16().flat_map(|u| u.to_le_bytes());
+                    data.extend(utf16le_bytes);
+                    data.into()
+                } else {
+                    // For other encodings (Shift-JIS, UTF-8 with BOM, etc.), delegate to encoding_rs.
+                    let bom_bytes = if has_bom {
+                        if encoding == encoding_rs::UTF_8 {
+                            vec![0xEF, 0xBB, 0xBF]
+                        } else {
+                            vec![]
+                        }
+                    } else {
+                        vec![]
+                    };
+                    let (cow, _, _) = encoding.encode(&normalized_text);
+                    if !bom_bytes.is_empty() {
+                        let mut bytes = bom_bytes;
+                        bytes.extend_from_slice(&cow);
+                        bytes.into()
+                    } else {
+                        cow
+                    }
+                };
+
+                fs.write(&abs_path, &bytes).await
+            }
         });
 
         cx.spawn(async move |this, cx| {
@@ -2565,13 +2650,21 @@ impl LocalSnapshot {
         } else {
             IgnoreStack::none()
         };
+
+        if let Some((repo_exclude, _)) = repo_root
+            .as_ref()
+            .and_then(|abs_path| self.repo_exclude_by_work_dir_abs_path.get(abs_path))
+        {
+            ignore_stack = ignore_stack.append(IgnoreKind::RepoExclude, repo_exclude.clone());
+        }
         ignore_stack.repo_root = repo_root;
         for (parent_abs_path, ignore) in new_ignores.into_iter().rev() {
             if ignore_stack.is_abs_path_ignored(parent_abs_path, true) {
                 ignore_stack = IgnoreStack::all();
                 break;
             } else if let Some(ignore) = ignore {
-                ignore_stack = ignore_stack.append(parent_abs_path.into(), ignore);
+                ignore_stack =
+                    ignore_stack.append(IgnoreKind::Gitignore(parent_abs_path.into()), ignore);
             }
         }
 
@@ -3142,7 +3235,8 @@ impl language::File for File {
             entry_id: self.entry_id.map(|id| id.to_proto()),
             path: self.path.as_ref().to_proto(),
             mtime: self.disk_state.mtime().map(|time| time.into()),
-            is_deleted: self.disk_state == DiskState::Deleted,
+            is_deleted: self.disk_state.is_deleted(),
+            is_historic: matches!(self.disk_state, DiskState::Historic { .. }),
         }
     }
 
@@ -3203,7 +3297,11 @@ impl File {
             "worktree id does not match file"
         );
 
-        let disk_state = if proto.is_deleted {
+        let disk_state = if proto.is_historic {
+            DiskState::Historic {
+                was_deleted: proto.is_deleted,
+            }
+        } else if proto.is_deleted {
             DiskState::Deleted
         } else if let Some(mtime) = proto.mtime.map(&Into::into) {
             DiskState::Present { mtime }
@@ -3646,13 +3744,23 @@ impl BackgroundScanner {
         let root_abs_path = self.state.lock().await.snapshot.abs_path.clone();
 
         let repo = if self.scanning_enabled {
-            let (ignores, repo) = discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await;
+            let (ignores, exclude, repo) =
+                discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await;
             self.state
                 .lock()
                 .await
                 .snapshot
                 .ignores_by_parent_abs_path
                 .extend(ignores);
+            if let Some(exclude) = exclude {
+                self.state
+                    .lock()
+                    .await
+                    .snapshot
+                    .repo_exclude_by_work_dir_abs_path
+                    .insert(root_abs_path.as_path().into(), (exclude, false));
+            }
+
             repo
         } else {
             None
@@ -3914,6 +4022,7 @@ impl BackgroundScanner {
 
         let mut relative_paths = Vec::with_capacity(abs_paths.len());
         let mut dot_git_abs_paths = Vec::new();
+        let mut work_dirs_needing_exclude_update = Vec::new();
         abs_paths.sort_unstable();
         abs_paths.dedup_by(|a, b| a.starts_with(b));
         {
@@ -3987,6 +4096,18 @@ impl BackgroundScanner {
                     continue;
                 };
 
+                let absolute_path = abs_path.to_path_buf();
+                if absolute_path.ends_with(Path::new(DOT_GIT).join(REPO_EXCLUDE)) {
+                    if let Some(repository) = snapshot
+                        .git_repositories
+                        .values()
+                        .find(|repo| repo.common_dir_abs_path.join(REPO_EXCLUDE) == absolute_path)
+                    {
+                        work_dirs_needing_exclude_update
+                            .push(repository.work_directory_abs_path.clone());
+                    }
+                }
+
                 if abs_path.file_name() == Some(OsStr::new(GITIGNORE)) {
                     for (_, repo) in snapshot
                         .git_repositories
@@ -4032,6 +4153,19 @@ impl BackgroundScanner {
             return;
         }
 
+        if !work_dirs_needing_exclude_update.is_empty() {
+            let mut state = self.state.lock().await;
+            for work_dir_abs_path in work_dirs_needing_exclude_update {
+                if let Some((_, needs_update)) = state
+                    .snapshot
+                    .repo_exclude_by_work_dir_abs_path
+                    .get_mut(&work_dir_abs_path)
+                {
+                    *needs_update = true;
+                }
+            }
+        }
+
         self.state.lock().await.snapshot.scan_id += 1;
 
         let (scan_job_tx, scan_job_rx) = channel::unbounded();
@@ -4299,7 +4433,8 @@ impl BackgroundScanner {
                 match build_gitignore(&child_abs_path, self.fs.as_ref()).await {
                     Ok(ignore) => {
                         let ignore = Arc::new(ignore);
-                        ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone());
+                        ignore_stack = ignore_stack
+                            .append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone());
                         new_ignore = Some(ignore);
                     }
                     Err(error) => {
@@ -4561,11 +4696,24 @@ impl BackgroundScanner {
                         .await;
 
                     if path.is_empty()
-                        && let Some((ignores, repo)) = new_ancestor_repo.take()
+                        && let Some((ignores, exclude, repo)) = new_ancestor_repo.take()
                     {
                         log::trace!("updating ancestor git repository");
                         state.snapshot.ignores_by_parent_abs_path.extend(ignores);
                         if let Some((ancestor_dot_git, work_directory)) = repo {
+                            if let Some(exclude) = exclude {
+                                let work_directory_abs_path = self
+                                    .state
+                                    .lock()
+                                    .await
+                                    .snapshot
+                                    .work_directory_abs_path(&work_directory);
+
+                                state
+                                    .snapshot
+                                    .repo_exclude_by_work_dir_abs_path
+                                    .insert(work_directory_abs_path.into(), (exclude, false));
+                            }
                             state
                                 .insert_git_repository_for_path(
                                     work_directory,
@@ -4663,6 +4811,36 @@ impl BackgroundScanner {
         {
             let snapshot = &mut self.state.lock().await.snapshot;
             let abs_path = snapshot.abs_path.clone();
+
+            snapshot.repo_exclude_by_work_dir_abs_path.retain(
+                |work_dir_abs_path, (exclude, needs_update)| {
+                    if *needs_update {
+                        *needs_update = false;
+                        ignores_to_update.push(work_dir_abs_path.clone());
+
+                        if let Some((_, repository)) = snapshot
+                            .git_repositories
+                            .iter()
+                            .find(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path)
+                        {
+                            let exclude_abs_path =
+                                repository.common_dir_abs_path.join(REPO_EXCLUDE);
+                            if let Ok(current_exclude) = self
+                                .executor
+                                .block(build_gitignore(&exclude_abs_path, self.fs.as_ref()))
+                            {
+                                *exclude = Arc::new(current_exclude);
+                            }
+                        }
+                    }
+
+                    snapshot
+                        .git_repositories
+                        .iter()
+                        .any(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path)
+                },
+            );
+
             snapshot
                 .ignores_by_parent_abs_path
                 .retain(|parent_abs_path, (_, needs_update)| {
@@ -4717,7 +4895,8 @@ impl BackgroundScanner {
 
         let mut ignore_stack = job.ignore_stack;
         if let Some((ignore, _)) = snapshot.ignores_by_parent_abs_path.get(&job.abs_path) {
-            ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone());
+            ignore_stack =
+                ignore_stack.append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone());
         }
 
         let mut entries_by_id_edits = Vec::new();
@@ -4892,6 +5071,9 @@ impl BackgroundScanner {
                 let preserve = ids_to_preserve.contains(work_directory_id);
                 if !preserve {
                     affected_repo_roots.push(entry.dot_git_abs_path.parent().unwrap().into());
+                    snapshot
+                        .repo_exclude_by_work_dir_abs_path
+                        .remove(&entry.work_directory_abs_path);
                 }
                 preserve
             });
@@ -4931,8 +5113,10 @@ async fn discover_ancestor_git_repo(
     root_abs_path: &SanitizedPath,
 ) -> (
     HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
+    Option<Arc<Gitignore>>,
     Option<(PathBuf, WorkDirectory)>,
 ) {
+    let mut exclude = None;
     let mut ignores = HashMap::default();
     for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() {
         if index != 0 {
@@ -4968,6 +5152,7 @@ async fn discover_ancestor_git_repo(
                     // also mark where in the git repo the root folder is located.
                     return (
                         ignores,
+                        exclude,
                         Some((
                             ancestor_dot_git,
                             WorkDirectory::AboveProject {
@@ -4979,12 +5164,17 @@ async fn discover_ancestor_git_repo(
                 };
             }
 
+            let repo_exclude_abs_path = ancestor_dot_git.join(REPO_EXCLUDE);
+            if let Ok(repo_exclude) = build_gitignore(&repo_exclude_abs_path, fs.as_ref()).await {
+                exclude = Some(Arc::new(repo_exclude));
+            }
+
             // Reached root of git repository.
             break;
         }
     }
 
-    (ignores, None)
+    (ignores, exclude, None)
 }
 
 fn build_diff(
@@ -5675,3 +5865,109 @@ impl fs::Watcher for NullWatcher {
         Ok(())
     }
 }
+
+fn decode_byte(bytes: Vec<u8>) -> anyhow::Result<(String, &'static Encoding, bool)> {
+    // check BOM
+    if let Some((encoding, _bom_len)) = Encoding::for_bom(&bytes) {
+        let (cow, _) = encoding.decode_with_bom_removal(&bytes);
+        return Ok((cow.into_owned(), encoding, true));
+    }
+
+    match analyze_byte_content(&bytes) {
+        ByteContent::Utf16Le => {
+            let encoding = encoding_rs::UTF_16LE;
+            let (cow, _, _) = encoding.decode(&bytes);
+            return Ok((cow.into_owned(), encoding, false));
+        }
+        ByteContent::Utf16Be => {
+            let encoding = encoding_rs::UTF_16BE;
+            let (cow, _, _) = encoding.decode(&bytes);
+            return Ok((cow.into_owned(), encoding, false));
+        }
+        ByteContent::Binary => {
+            anyhow::bail!("Binary files are not supported");
+        }
+        ByteContent::Unknown => {}
+    }
+
+    fn detect_encoding(bytes: Vec<u8>) -> (String, &'static Encoding) {
+        let mut detector = EncodingDetector::new();
+        detector.feed(&bytes, true);
+
+        let encoding = detector.guess(None, true); // Use None for TLD hint to ensure neutral detection logic.
+
+        let (cow, _, _) = encoding.decode(&bytes);
+        (cow.into_owned(), encoding)
+    }
+
+    match String::from_utf8(bytes) {
+        Ok(text) => {
+            // ISO-2022-JP (and other ISO-2022 variants) consists entirely of 7-bit ASCII bytes,
+            // so it is valid UTF-8. However, it contains escape sequences starting with '\x1b'.
+            // If we find an escape character, we double-check the encoding to prevent
+            // displaying raw escape sequences instead of the correct characters.
+            if text.contains('\x1b') {
+                let (s, enc) = detect_encoding(text.into_bytes());
+                Ok((s, enc, false))
+            } else {
+                Ok((text, encoding_rs::UTF_8, false))
+            }
+        }
+        Err(e) => {
+            let (s, enc) = detect_encoding(e.into_bytes());
+            Ok((s, enc, false))
+        }
+    }
+}
+
+#[derive(PartialEq)]
+enum ByteContent {
+    Utf16Le,
+    Utf16Be,
+    Binary,
+    Unknown,
+}
+// Heuristic check using null byte distribution.
+// NOTE: This relies on the presence of ASCII characters (which become `0x00` in UTF-16).
+// Files consisting purely of non-ASCII characters (like Japanese) may not be detected here
+// and will result in `Unknown`.
+fn analyze_byte_content(bytes: &[u8]) -> ByteContent {
+    if bytes.len() < 2 {
+        return ByteContent::Unknown;
+    }
+
+    let check_len = bytes.len().min(1024);
+    let sample = &bytes[..check_len];
+
+    if !sample.contains(&0) {
+        return ByteContent::Unknown;
+    }
+
+    let mut even_nulls = 0;
+    let mut odd_nulls = 0;
+
+    for (i, &byte) in sample.iter().enumerate() {
+        if byte == 0 {
+            if i % 2 == 0 {
+                even_nulls += 1;
+            } else {
+                odd_nulls += 1;
+            }
+        }
+    }
+
+    let total_nulls = even_nulls + odd_nulls;
+    if total_nulls < check_len / 10 {
+        return ByteContent::Unknown;
+    }
+
+    if even_nulls > odd_nulls * 4 {
+        return ByteContent::Utf16Be;
+    }
+
+    if odd_nulls > even_nulls * 4 {
+        return ByteContent::Utf16Le;
+    }
+
+    ByteContent::Binary
+}

crates/worktree/src/worktree_tests.rs 🔗

@@ -1,7 +1,8 @@
 use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
 use anyhow::Result;
+use encoding_rs;
 use fs::{FakeFs, Fs, RealFs, RemoveOptions};
-use git::GITIGNORE;
+use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
 use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
 use parking_lot::Mutex;
 use postage::stream::Stream;
@@ -19,6 +20,7 @@ use std::{
 };
 use util::{
     ResultExt, path,
+    paths::PathStyle,
     rel_path::{RelPath, rel_path},
     test::TempTree,
 };
@@ -723,6 +725,8 @@ async fn test_write_file(cx: &mut TestAppContext) {
                 rel_path("tracked-dir/file.txt").into(),
                 "hello".into(),
                 Default::default(),
+                encoding_rs::UTF_8,
+                false,
                 cx,
             )
         })
@@ -734,6 +738,8 @@ async fn test_write_file(cx: &mut TestAppContext) {
                 rel_path("ignored-dir/file.txt").into(),
                 "world".into(),
                 Default::default(),
+                encoding_rs::UTF_8,
+                false,
                 cx,
             )
         })
@@ -2035,8 +2041,14 @@ fn randomly_mutate_worktree(
                 })
             } else {
                 log::info!("overwriting file {:?} ({})", &entry.path, entry.id.0);
-                let task =
-                    worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx);
+                let task = worktree.write_file(
+                    entry.path.clone(),
+                    "".into(),
+                    Default::default(),
+                    encoding_rs::UTF_8,
+                    false,
+                    cx,
+                );
                 cx.background_spawn(async move {
                     task.await?;
                     Ok(())
@@ -2412,6 +2424,94 @@ async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppCon
     });
 }
 
+#[gpui::test]
+async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor);
+    let project_dir = Path::new(path!("/project"));
+    fs.insert_tree(
+        project_dir,
+        json!({
+            ".git": {
+                "info": {
+                    "exclude": ".env.*"
+                }
+            },
+            ".env.example": "secret=xxxx",
+            ".env.local": "secret=1234",
+            ".gitignore": "!.env.example",
+            "README.md": "# Repo Exclude",
+            "src": {
+                "main.rs": "fn main() {}",
+            },
+        }),
+    )
+    .await;
+
+    let worktree = Worktree::local(
+        project_dir,
+        true,
+        fs.clone(),
+        Default::default(),
+        true,
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+    worktree
+        .update(cx, |worktree, _| {
+            worktree.as_local().unwrap().scan_complete()
+        })
+        .await;
+    cx.run_until_parked();
+
+    // .gitignore overrides .git/info/exclude
+    worktree.update(cx, |worktree, _cx| {
+        let expected_excluded_paths = [];
+        let expected_ignored_paths = [".env.local"];
+        let expected_tracked_paths = [".env.example", "README.md", "src/main.rs"];
+        let expected_included_paths = [];
+
+        check_worktree_entries(
+            worktree,
+            &expected_excluded_paths,
+            &expected_ignored_paths,
+            &expected_tracked_paths,
+            &expected_included_paths,
+        );
+    });
+
+    // Ignore statuses are updated when .git/info/exclude file changes
+    fs.write(
+        &project_dir.join(DOT_GIT).join(REPO_EXCLUDE),
+        ".env.example".as_bytes(),
+    )
+    .await
+    .unwrap();
+    worktree
+        .update(cx, |worktree, _| {
+            worktree.as_local().unwrap().scan_complete()
+        })
+        .await;
+    cx.run_until_parked();
+
+    worktree.update(cx, |worktree, _cx| {
+        let expected_excluded_paths = [];
+        let expected_ignored_paths = [];
+        let expected_tracked_paths = [".env.example", ".env.local", "README.md", "src/main.rs"];
+        let expected_included_paths = [];
+
+        check_worktree_entries(
+            worktree,
+            &expected_excluded_paths,
+            &expected_ignored_paths,
+            &expected_tracked_paths,
+            &expected_included_paths,
+        );
+    });
+}
+
 #[track_caller]
 fn check_worktree_entries(
     tree: &Worktree,
@@ -2464,3 +2564,282 @@ fn init_test(cx: &mut gpui::TestAppContext) {
         cx.set_global(settings_store);
     });
 }
+
+#[gpui::test]
+async fn test_load_file_encoding(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    struct TestCase {
+        name: &'static str,
+        bytes: Vec<u8>,
+        expected_text: &'static str,
+    }
+
+    // --- Success Cases ---
+    let success_cases = vec![
+        TestCase {
+            name: "utf8.txt",
+            bytes: "こんにちは".as_bytes().to_vec(),
+            expected_text: "こんにちは",
+        },
+        TestCase {
+            name: "sjis.txt",
+            bytes: vec![0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd],
+            expected_text: "こんにちは",
+        },
+        TestCase {
+            name: "eucjp.txt",
+            bytes: vec![0xa4, 0xb3, 0xa4, 0xf3, 0xa4, 0xcb, 0xa4, 0xc1, 0xa4, 0xcf],
+            expected_text: "こんにちは",
+        },
+        TestCase {
+            name: "iso2022jp.txt",
+            bytes: vec![
+                0x1b, 0x24, 0x42, 0x24, 0x33, 0x24, 0x73, 0x24, 0x4b, 0x24, 0x41, 0x24, 0x4f, 0x1b,
+                0x28, 0x42,
+            ],
+            expected_text: "こんにちは",
+        },
+        TestCase {
+            name: "win1252.txt",
+            bytes: vec![0x43, 0x61, 0x66, 0xe9],
+            expected_text: "Café",
+        },
+        TestCase {
+            name: "gbk.txt",
+            bytes: vec![
+                0xbd, 0xf1, 0xcc, 0xec, 0xcc, 0xec, 0xc6, 0xf8, 0xb2, 0xbb, 0xb4, 0xed,
+            ],
+            expected_text: "今天天气不错",
+        },
+        // UTF-16LE with BOM
+        TestCase {
+            name: "utf16le_bom.txt",
+            bytes: vec![
+                0xFF, 0xFE, // BOM
+                0x53, 0x30, 0x93, 0x30, 0x6B, 0x30, 0x61, 0x30, 0x6F, 0x30,
+            ],
+            expected_text: "こんにちは",
+        },
+        // UTF-16BE with BOM
+        TestCase {
+            name: "utf16be_bom.txt",
+            bytes: vec![
+                0xFE, 0xFF, // BOM
+                0x30, 0x53, 0x30, 0x93, 0x30, 0x6B, 0x30, 0x61, 0x30, 0x6F,
+            ],
+            expected_text: "こんにちは",
+        },
+        // UTF-16LE without BOM (ASCII only)
+        // This relies on the "null byte heuristic" we implemented.
+        // "ABC" -> 41 00 42 00 43 00
+        TestCase {
+            name: "utf16le_ascii_no_bom.txt",
+            bytes: vec![0x41, 0x00, 0x42, 0x00, 0x43, 0x00],
+            expected_text: "ABC",
+        },
+    ];
+
+    // --- Failure Cases ---
+    let failure_cases = vec![
+        // Binary File (Should be detected by heuristic and return Error)
+        // Contains random bytes and mixed nulls that don't match UTF-16 patterns
+        TestCase {
+            name: "binary.bin",
+            bytes: vec![0x00, 0xFF, 0x12, 0x00, 0x99, 0x88, 0x77, 0x66, 0x00],
+            expected_text: "", // Not used
+        },
+    ];
+
+    let root_path = if cfg!(windows) {
+        Path::new("C:\\root")
+    } else {
+        Path::new("/root")
+    };
+
+    let fs = FakeFs::new(cx.background_executor.clone());
+    fs.create_dir(root_path).await.unwrap();
+
+    for case in success_cases.iter().chain(failure_cases.iter()) {
+        let path = root_path.join(case.name);
+        fs.write(&path, &case.bytes).await.unwrap();
+    }
+
+    let tree = Worktree::local(
+        root_path,
+        true,
+        fs,
+        Default::default(),
+        true,
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+
+    let rel_path = |name: &str| {
+        RelPath::new(&Path::new(name), PathStyle::local())
+            .unwrap()
+            .into_arc()
+    };
+
+    // Run Success Tests
+    for case in success_cases {
+        let loaded = tree
+            .update(cx, |tree, cx| tree.load_file(&rel_path(case.name), cx))
+            .await;
+        if let Err(e) = &loaded {
+            panic!("Failed to load success case '{}': {:?}", case.name, e);
+        }
+        let loaded = loaded.unwrap();
+        assert_eq!(
+            loaded.text, case.expected_text,
+            "Encoding mismatch for file: {}",
+            case.name
+        );
+    }
+
+    // Run Failure Tests
+    for case in failure_cases {
+        let loaded = tree
+            .update(cx, |tree, cx| tree.load_file(&rel_path(case.name), cx))
+            .await;
+        assert!(
+            loaded.is_err(),
+            "Failure case '{}' unexpectedly succeeded! It should have been detected as binary.",
+            case.name
+        );
+        let err_msg = loaded.unwrap_err().to_string();
+        println!("Got expected error for {}: {}", case.name, err_msg);
+    }
+}
+
+#[gpui::test]
+async fn test_write_file_encoding(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+
+    let root_path = if cfg!(windows) {
+        Path::new("C:\\root")
+    } else {
+        Path::new("/root")
+    };
+    fs.create_dir(root_path).await.unwrap();
+
+    let worktree = Worktree::local(
+        root_path,
+        true,
+        fs.clone(),
+        Default::default(),
+        true,
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    // Define test case structure
+    struct TestCase {
+        name: &'static str,
+        text: &'static str,
+        encoding: &'static encoding_rs::Encoding,
+        has_bom: bool,
+        expected_bytes: Vec<u8>,
+    }
+
+    let cases = vec![
+        // Shift_JIS with Japanese
+        TestCase {
+            name: "Shift_JIS with Japanese",
+            text: "こんにちは",
+            encoding: encoding_rs::SHIFT_JIS,
+            has_bom: false,
+            expected_bytes: vec![0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd],
+        },
+        // UTF-8 No BOM
+        TestCase {
+            name: "UTF-8 No BOM",
+            text: "AB",
+            encoding: encoding_rs::UTF_8,
+            has_bom: false,
+            expected_bytes: vec![0x41, 0x42],
+        },
+        // UTF-8 with BOM
+        TestCase {
+            name: "UTF-8 with BOM",
+            text: "AB",
+            encoding: encoding_rs::UTF_8,
+            has_bom: true,
+            expected_bytes: vec![0xEF, 0xBB, 0xBF, 0x41, 0x42],
+        },
+        // UTF-16LE No BOM with Japanese
+        // NOTE: This passes thanks to the manual encoding fix implemented in `write_file`.
+        TestCase {
+            name: "UTF-16LE No BOM with Japanese",
+            text: "こんにちは",
+            encoding: encoding_rs::UTF_16LE,
+            has_bom: false,
+            expected_bytes: vec![0x53, 0x30, 0x93, 0x30, 0x6b, 0x30, 0x61, 0x30, 0x6f, 0x30],
+        },
+        // UTF-16LE with BOM
+        TestCase {
+            name: "UTF-16LE with BOM",
+            text: "A",
+            encoding: encoding_rs::UTF_16LE,
+            has_bom: true,
+            expected_bytes: vec![0xFF, 0xFE, 0x41, 0x00],
+        },
+        // UTF-16BE No BOM with Japanese
+        // NOTE: This passes thanks to the manual encoding fix.
+        TestCase {
+            name: "UTF-16BE No BOM with Japanese",
+            text: "こんにちは",
+            encoding: encoding_rs::UTF_16BE,
+            has_bom: false,
+            expected_bytes: vec![0x30, 0x53, 0x30, 0x93, 0x30, 0x6b, 0x30, 0x61, 0x30, 0x6f],
+        },
+        // UTF-16BE with BOM
+        TestCase {
+            name: "UTF-16BE with BOM",
+            text: "A",
+            encoding: encoding_rs::UTF_16BE,
+            has_bom: true,
+            expected_bytes: vec![0xFE, 0xFF, 0x00, 0x41],
+        },
+    ];
+
+    for (i, case) in cases.into_iter().enumerate() {
+        let file_name = format!("test_{}.txt", i);
+        let path: Arc<Path> = Path::new(&file_name).into();
+        let file_path = root_path.join(&file_name);
+
+        fs.insert_file(&file_path, "".into()).await;
+
+        let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc();
+        let text = text::Rope::from(case.text);
+
+        let task = worktree.update(cx, |wt, cx| {
+            wt.write_file(
+                rel_path,
+                text,
+                text::LineEnding::Unix,
+                case.encoding,
+                case.has_bom,
+                cx,
+            )
+        });
+
+        if let Err(e) = task.await {
+            panic!("Unexpected error in case '{}': {:?}", case.name, e);
+        }
+
+        let bytes = fs.load_bytes(&file_path).await.unwrap();
+
+        assert_eq!(
+            bytes, case.expected_bytes,
+            "case '{}' mismatch. Expected {:?}, but got {:?}",
+            case.name, case.expected_bytes, bytes
+        );
+    }
+}

crates/worktree_benchmarks/src/main.rs 🔗

@@ -5,8 +5,7 @@ use std::{
 
 use fs::RealFs;
 use gpui::Application;
-use settings::Settings;
-use worktree::{Worktree, WorktreeSettings};
+use worktree::Worktree;
 
 fn main() {
     let Some(worktree_root_path) = std::env::args().nth(1) else {
@@ -27,6 +26,7 @@ fn main() {
                 true,
                 fs,
                 Arc::new(AtomicUsize::new(0)),
+                true,
                 cx,
             )
             .await

crates/zed/Cargo.toml 🔗

@@ -2,7 +2,7 @@
 description = "The fast, collaborative code editor."
 edition.workspace = true
 name = "zed"
-version = "0.218.0"
+version = "0.219.0"
 publish.workspace = true
 license = "GPL-3.0-or-later"
 authors = ["Zed Team <hi@zed.dev>"]
@@ -15,10 +15,6 @@ tracy = ["ztracing/tracy"]
 
 [[bin]]
 name = "zed"
-path = "src/zed-main.rs"
-
-[lib]
-name = "zed"
 path = "src/main.rs"
 
 [dependencies]
@@ -45,6 +41,7 @@ collab_ui.workspace = true
 collections.workspace = true
 command_palette.workspace = true
 component.workspace = true
+component_preview.workspace = true
 copilot.workspace = true
 crashes.workspace = true
 dap_adapters.workspace = true
@@ -152,7 +149,6 @@ ztracing.workspace = true
 tracing.workspace = true
 toolchain_selector.workspace = true
 ui.workspace = true
-ui_input.workspace = true
 ui_prompt.workspace = true
 url.workspace = true
 urlencoding.workspace = true
@@ -163,6 +159,7 @@ vim_mode_setting.workspace = true
 watch.workspace = true
 web_search.workspace = true
 web_search_providers.workspace = true
+which_key.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 zed_env_vars.workspace = true
@@ -195,6 +192,10 @@ terminal_view = { workspace = true, features = ["test-support"] }
 tree-sitter-md.workspace = true
 tree-sitter-rust.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
+agent_ui = { workspace = true, features = ["test-support"] }
+agent_ui_v2 = { workspace = true, features = ["test-support"] }
+search = { workspace = true, features = ["test-support"] }
+
 
 [package.metadata.bundle-dev]
 icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"]

crates/zed/src/main.rs 🔗

@@ -1,3 +1,6 @@
+// Disable command line from opening on release mode
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
 mod reliability;
 mod zed;
 
@@ -15,11 +18,13 @@ use extension::ExtensionHostProxy;
 use fs::{Fs, RealFs};
 use futures::{StreamExt, channel::oneshot, future};
 use git::GitHostingProviderRegistry;
+use git_ui::clone::clone_and_open;
 use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _};
 
 use gpui_tokio::Tokio;
 use language::LanguageRegistry;
 use onboarding::{FIRST_OPEN, show_onboarding_view};
+use project_panel::ProjectPanel;
 use prompt_store::PromptBuilder;
 use remote::RemoteConnectionOptions;
 use reqwest_client::ReqwestClient;
@@ -27,16 +32,18 @@ use reqwest_client::ReqwestClient;
 use assets::Assets;
 use node_runtime::{NodeBinaryOptions, NodeRuntime};
 use parking_lot::Mutex;
-use project::project_settings::ProjectSettings;
+use project::{project_settings::ProjectSettings, trusted_worktrees};
 use recent_projects::{SshSettings, open_remote_project};
 use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
 use session::{AppSession, Session};
 use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file};
 use std::{
+    cell::RefCell,
     env,
     io::{self, IsTerminal},
     path::{Path, PathBuf},
     process,
+    rc::Rc,
     sync::{Arc, OnceLock},
     time::Instant,
 };
@@ -163,9 +170,9 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) {
         .detach();
     }
 }
-pub static STARTUP_TIME: OnceLock<Instant> = OnceLock::new();
+static STARTUP_TIME: OnceLock<Instant> = OnceLock::new();
 
-pub fn main() {
+fn main() {
     STARTUP_TIME.get_or_init(|| Instant::now());
 
     #[cfg(unix)]
@@ -406,6 +413,14 @@ pub fn main() {
     });
 
     app.run(move |cx| {
+        let trusted_paths = match workspace::WORKSPACE_DB.fetch_trusted_worktrees(None, None, cx) {
+            Ok(trusted_paths) => trusted_paths,
+            Err(e) => {
+                log::error!("Failed to do initial trusted worktrees fetch: {e:#}");
+                HashMap::default()
+            }
+        };
+        trusted_worktrees::init(trusted_paths, None, None, cx);
         menu::init();
         zed_actions::init();
 
@@ -474,6 +489,7 @@ pub fn main() {
             tx.send(Some(options)).log_err();
         })
         .detach();
+
         let node_runtime = NodeRuntime::new(client.http_client(), Some(shell_env_loaded_rx), rx);
 
         debug_adapter_extension::init(extension_host_proxy.clone(), cx);
@@ -647,6 +663,7 @@ pub fn main() {
         inspector_ui::init(app_state.clone(), cx);
         json_schema_store::init(cx);
         miniprofiler_ui::init(*STARTUP_TIME.get().unwrap(), cx);
+        which_key::init(cx);
 
         cx.observe_global::<SettingsStore>({
             let http = app_state.client.http_client();
@@ -757,7 +774,7 @@ pub fn main() {
 
         let app_state = app_state.clone();
 
-        crate::zed::component_preview::init(app_state.clone(), cx);
+        component_preview::init(app_state.clone(), cx);
 
         cx.spawn(async move |cx| {
             while let Some(urls) = open_rx.next().await {
@@ -802,7 +819,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                         workspace::get_any_active_workspace(app_state, cx.clone()).await?;
                     workspace.update(cx, |workspace, window, cx| {
                         if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
-                            panel.focus_handle(cx).focus(window);
+                            panel.focus_handle(cx).focus(window, cx);
                         }
                     })
                 })
@@ -883,6 +900,79 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                 })
                 .detach_and_log_err(cx);
             }
+            OpenRequestKind::GitClone { repo_url } => {
+                workspace::with_active_or_new_workspace(cx, |_workspace, window, cx| {
+                    if window.is_window_active() {
+                        clone_and_open(
+                            repo_url,
+                            cx.weak_entity(),
+                            window,
+                            cx,
+                            Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
+                                workspace.focus_panel::<ProjectPanel>(window, cx);
+                            }),
+                        );
+                        return;
+                    }
+
+                    let subscription = Rc::new(RefCell::new(None));
+                    subscription.replace(Some(cx.observe_in(&cx.entity(), window, {
+                        let subscription = subscription.clone();
+                        let repo_url = repo_url;
+                        move |_, workspace_entity, window, cx| {
+                            if window.is_window_active() && subscription.take().is_some() {
+                                clone_and_open(
+                                    repo_url.clone(),
+                                    workspace_entity.downgrade(),
+                                    window,
+                                    cx,
+                                    Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
+                                        workspace.focus_panel::<ProjectPanel>(window, cx);
+                                    }),
+                                );
+                            }
+                        }
+                    })));
+                });
+            }
+            OpenRequestKind::GitCommit { sha } => {
+                cx.spawn(async move |cx| {
+                    let paths_with_position =
+                        derive_paths_with_position(app_state.fs.as_ref(), request.open_paths).await;
+                    let (workspace, _results) = open_paths_with_positions(
+                        &paths_with_position,
+                        &[],
+                        app_state,
+                        workspace::OpenOptions::default(),
+                        cx,
+                    )
+                    .await?;
+
+                    workspace
+                        .update(cx, |workspace, window, cx| {
+                            let Some(repo) = workspace.project().read(cx).active_repository(cx)
+                            else {
+                                log::error!("no active repository found for commit view");
+                                return Err(anyhow::anyhow!("no active repository found"));
+                            };
+
+                            git_ui::commit_view::CommitView::open(
+                                sha,
+                                repo.downgrade(),
+                                workspace.weak_handle(),
+                                None,
+                                None,
+                                window,
+                                cx,
+                            );
+                            Ok(())
+                        })
+                        .log_err();
+
+                    anyhow::Ok(())
+                })
+                .detach_and_log_err(cx);
+            }
         }
 
         return;
@@ -1157,7 +1247,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);
+                        }
+                    }
                 },
             )
         })?
@@ -1247,7 +1343,7 @@ fn init_paths() -> HashMap<io::ErrorKind, Vec<&'static Path>> {
     })
 }
 
-pub fn stdout_is_a_pty() -> bool {
+fn stdout_is_a_pty() -> bool {
     std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_none() && io::stdout().is_terminal()
 }
 
@@ -1493,14 +1589,14 @@ fn dump_all_gpui_actions() {
     struct ActionDef {
         name: &'static str,
         human_name: String,
-        aliases: &'static [&'static str],
+        deprecated_aliases: &'static [&'static str],
         documentation: Option<&'static str>,
     }
     let mut actions = gpui::generate_list_of_all_registered_actions()
         .map(|action| ActionDef {
             name: action.name,
             human_name: command_palette::humanize_action_name(action.name),
-            aliases: action.deprecated_aliases,
+            deprecated_aliases: action.deprecated_aliases,
             documentation: action.documentation,
         })
         .collect::<Vec<ActionDef>>();

crates/zed/src/zed-main.rs 🔗

@@ -1,8 +0,0 @@
-// Disable command line from opening on release mode
-#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
-
-pub fn main() {
-    // separated out so that the file containing the main function can be imported by other crates,
-    // while having all gpui resources that are registered in main (primarily actions) initialized
-    zed::main();
-}

crates/zed/src/zed.rs 🔗

@@ -1,5 +1,4 @@
 mod app_menus;
-pub mod component_preview;
 pub mod edit_prediction_registry;
 #[cfg(target_os = "macos")]
 pub(crate) mod mac_only_instance;
@@ -32,8 +31,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;
@@ -353,6 +352,8 @@ pub fn initialize_workspace(
 ) {
     let mut _on_close_subscription = bind_on_window_closed(cx);
     cx.observe_global::<SettingsStore>(move |cx| {
+        // A 1.92 regression causes unused-assignment to trigger on this variable.
+        _ = _on_close_subscription.is_some();
         _on_close_subscription = bind_on_window_closed(cx);
     })
     .detach();
@@ -475,7 +476,7 @@ pub fn initialize_workspace(
         initialize_panels(prompt_builder.clone(), window, cx);
         register_actions(app_state.clone(), workspace, window, cx);
 
-        workspace.focus_handle(cx).focus(window);
+        workspace.focus_handle(cx).focus(window, cx);
     })
     .detach();
 }
@@ -705,7 +706,6 @@ fn setup_or_teardown_ai_panel<P: Panel>(
         .disable_ai
         || cfg!(test);
     let existing_panel = workspace.panel::<P>(cx);
-
     match (disable_ai, existing_panel) {
         (false, None) => cx.spawn_in(window, async move |workspace, cx| {
             let panel = load_panel(workspace.clone(), cx.clone()).await?;
@@ -1109,7 +1109,21 @@ fn register_actions(
                         cx,
                         |workspace, window, cx| {
                             cx.activate(true);
-                            Editor::new_file(workspace, &Default::default(), window, cx)
+                            // Create buffer synchronously to avoid flicker
+                            let project = workspace.project().clone();
+                            let buffer = project.update(cx, |project, cx| {
+                                project.create_local_buffer("", None, true, cx)
+                            });
+                            let editor = cx.new(|cx| {
+                                Editor::for_buffer(buffer, Some(project), window, cx)
+                            });
+                            workspace.add_item_to_active_pane(
+                                Box::new(editor),
+                                None,
+                                true,
+                                window,
+                                cx,
+                            );
                         },
                     )
                     .detach();
@@ -1690,6 +1704,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 +1763,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)
                 })
             })
@@ -2308,7 +2325,7 @@ mod tests {
     use project::{Project, ProjectPath};
     use semver::Version;
     use serde_json::json;
-    use settings::{SettingsStore, watch_config_file};
+    use settings::{SaturatingBool, SettingsStore, watch_config_file};
     use std::{
         path::{Path, PathBuf},
         time::Duration,
@@ -4762,7 +4779,6 @@ mod tests {
                 "activity_indicator",
                 "agent",
                 "agents",
-                #[cfg(not(target_os = "macos"))]
                 "app_menu",
                 "assistant",
                 "assistant2",
@@ -4798,6 +4814,7 @@ mod tests {
                 "keymap_editor",
                 "keystroke_input",
                 "language_selector",
+                "welcome",
                 "line_ending_selector",
                 "lsp_tool",
                 "markdown",
@@ -5151,6 +5168,28 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_disable_ai_crash(cx: &mut gpui::TestAppContext) {
+        let app_state = init_test(cx);
+        cx.update(init);
+        let project = Project::test(app_state.fs.clone(), [], cx).await;
+        let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
+
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |settings_store, cx| {
+                settings_store.update_user_settings(cx, |settings| {
+                    settings.disable_ai = Some(SaturatingBool(true));
+                });
+            });
+        });
+
+        cx.run_until_parked();
+
+        // If this panics, the test has failed
+    }
+
     #[gpui::test]
     async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) {
         let app_state = init_test(cx);

crates/zed/src/zed/edit_prediction_registry.rs 🔗

@@ -145,23 +145,6 @@ fn register_backward_compatible_actions(editor: &mut Editor, cx: &mut Context<Ed
             },
         ))
         .detach();
-    editor
-        .register_action(cx.listener(
-            |editor, _: &copilot::NextSuggestion, window: &mut Window, cx: &mut Context<Editor>| {
-                editor.next_edit_prediction(&Default::default(), window, cx);
-            },
-        ))
-        .detach();
-    editor
-        .register_action(cx.listener(
-            |editor,
-             _: &copilot::PreviousSuggestion,
-             window: &mut Window,
-             cx: &mut Context<Editor>| {
-                editor.previous_edit_prediction(&Default::default(), window, cx);
-            },
-        ))
-        .detach();
 }
 
 fn assign_edit_prediction_provider(

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;
@@ -24,6 +25,7 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::thread;
 use std::time::Duration;
+use ui::SharedString;
 use util::ResultExt;
 use util::paths::PathWithPosition;
 use workspace::PathList;
@@ -57,6 +59,12 @@ pub enum OpenRequestKind {
         /// `None` opens settings without navigating to a specific path.
         setting_path: Option<String>,
     },
+    GitClone {
+        repo_url: SharedString,
+    },
+    GitCommit {
+        sha: String,
+    },
 }
 
 impl OpenRequest {
@@ -109,10 +117,24 @@ impl OpenRequest {
                 this.kind = Some(OpenRequestKind::Setting {
                     setting_path: Some(setting_path.to_string()),
                 });
+            } else if let Some(clone_path) = url.strip_prefix("zed://git/clone") {
+                this.parse_git_clone_url(clone_path)?
+            } else if let Some(commit_path) = url.strip_prefix("zed://git/commit/") {
+                this.parse_git_commit_url(commit_path)?
             } 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);
             }
@@ -127,6 +149,48 @@ impl OpenRequest {
         }
     }
 
+    fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> {
+        // Format: /?repo=<url> or ?repo=<url>
+        let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path);
+
+        let query = clone_path
+            .strip_prefix('?')
+            .context("invalid git clone url: missing query string")?;
+
+        let repo_url = url::form_urlencoded::parse(query.as_bytes())
+            .find_map(|(key, value)| (key == "repo").then_some(value))
+            .filter(|s| !s.is_empty())
+            .context("invalid git clone url: missing repo query parameter")?
+            .to_string()
+            .into();
+
+        self.kind = Some(OpenRequestKind::GitClone { repo_url });
+
+        Ok(())
+    }
+
+    fn parse_git_commit_url(&mut self, commit_path: &str) -> Result<()> {
+        // Format: <sha>?repo=<path>
+        let (sha, query) = commit_path
+            .split_once('?')
+            .context("invalid git commit url: missing query string")?;
+        anyhow::ensure!(!sha.is_empty(), "invalid git commit url: missing sha");
+
+        let repo = url::form_urlencoded::parse(query.as_bytes())
+            .find_map(|(key, value)| (key == "repo").then_some(value))
+            .filter(|s| !s.is_empty())
+            .context("invalid git commit url: missing repo query parameter")?
+            .to_string();
+
+        self.open_paths.push(repo);
+
+        self.kind = Some(OpenRequestKind::GitCommit {
+            sha: sha.to_string(),
+        });
+
+        Ok(())
+    }
+
     fn parse_ssh_file_path(&mut self, file: &str, cx: &App) -> Result<()> {
         let url = url::Url::parse(file)?;
         let host = url
@@ -156,31 +220,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 +553,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 +583,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 +701,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};
 
@@ -692,6 +741,86 @@ mod tests {
         assert_eq!(request.open_paths, vec!["/"]);
     }
 
+    #[gpui::test]
+    fn test_parse_git_commit_url(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        // Test basic git commit URL
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec!["zed://git/commit/abc123?repo=path/to/repo".into()],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind.unwrap() {
+            OpenRequestKind::GitCommit { sha } => {
+                assert_eq!(sha, "abc123");
+            }
+            _ => panic!("expected GitCommit variant"),
+        }
+        // Verify path was added to open_paths for workspace routing
+        assert_eq!(request.open_paths, vec!["path/to/repo"]);
+
+        // Test with URL encoded path
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec!["zed://git/commit/def456?repo=path%20with%20spaces".into()],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind.unwrap() {
+            OpenRequestKind::GitCommit { sha } => {
+                assert_eq!(sha, "def456");
+            }
+            _ => panic!("expected GitCommit variant"),
+        }
+        assert_eq!(request.open_paths, vec!["path with spaces"]);
+
+        // Test with empty path
+        cx.update(|cx| {
+            assert!(
+                OpenRequest::parse(
+                    RawOpenRequest {
+                        urls: vec!["zed://git/commit/abc123?repo=".into()],
+                        ..Default::default()
+                    },
+                    cx,
+                )
+                .unwrap_err()
+                .to_string()
+                .contains("missing repo")
+            );
+        });
+
+        // Test error case: missing SHA
+        let result = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec!["zed://git/commit/abc123?foo=bar".into()],
+                    ..Default::default()
+                },
+                cx,
+            )
+        });
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("missing repo query parameter")
+        );
+    }
+
     #[gpui::test]
     async fn test_open_workspace_with_directory(cx: &mut TestAppContext) {
         let app_state = init_test(cx);
@@ -754,6 +883,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);
@@ -930,4 +1113,80 @@ mod tests {
 
         assert!(!errored_reuse);
     }
+
+    #[gpui::test]
+    fn test_parse_git_clone_url(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec![
+                        "zed://git/clone/?repo=https://github.com/zed-industries/zed.git".into(),
+                    ],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::GitClone { repo_url }) => {
+                assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
+            }
+            _ => panic!("Expected GitClone kind"),
+        }
+    }
+
+    #[gpui::test]
+    fn test_parse_git_clone_url_without_slash(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec![
+                        "zed://git/clone?repo=https://github.com/zed-industries/zed.git".into(),
+                    ],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::GitClone { repo_url }) => {
+                assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
+            }
+            _ => panic!("Expected GitClone kind"),
+        }
+    }
+
+    #[gpui::test]
+    fn test_parse_git_clone_url_with_encoding(cx: &mut TestAppContext) {
+        let _app_state = init_test(cx);
+
+        let request = cx.update(|cx| {
+            OpenRequest::parse(
+                RawOpenRequest {
+                    urls: vec![
+                        "zed://git/clone/?repo=https%3A%2F%2Fgithub.com%2Fzed-industries%2Fzed.git"
+                            .into(),
+                    ],
+                    ..Default::default()
+                },
+                cx,
+            )
+            .unwrap()
+        });
+
+        match request.kind {
+            Some(OpenRequestKind::GitClone { repo_url }) => {
+                assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
+            }
+            _ => panic!("Expected GitClone kind"),
+        }
+    }
 }

crates/zed_actions/src/lib.rs 🔗

@@ -70,6 +70,8 @@ actions!(
         OpenTelemetryLog,
         /// Opens the performance profiler.
         OpenPerformanceProfiler,
+        /// Opens the onboarding view.
+        OpenOnboarding,
     ]
 );
 
@@ -352,6 +354,8 @@ pub mod agent {
             ResetAgentZoom,
             /// Toggles the utility/agent pane open/closed state.
             ToggleAgentPane,
+            /// Pastes clipboard content without any formatting.
+            PasteRaw,
         ]
     );
 }

crates/ztracing/src/lib.rs 🔗

@@ -1,8 +1,8 @@
-pub use tracing::Level;
+pub use tracing::{Level, field};
 
 #[cfg(ztracing)]
 pub use tracing::{
-    debug_span, error_span, event, info_span, instrument, span, trace_span, warn_span,
+    Span, debug_span, error_span, event, info_span, instrument, span, trace_span, warn_span,
 };
 #[cfg(not(ztracing))]
 pub use ztracing_macro::instrument;
@@ -26,17 +26,23 @@ pub use __consume_all_tokens as span;
 #[macro_export]
 macro_rules! __consume_all_tokens {
     ($($t:tt)*) => {
-        $crate::FakeSpan
+        $crate::Span
     };
 }
 
-pub struct FakeSpan;
-impl FakeSpan {
+#[cfg(not(ztracing))]
+pub struct Span;
+
+#[cfg(not(ztracing))]
+impl Span {
+    pub fn current() -> Self {
+        Self
+    }
+
     pub fn enter(&self) {}
-}
 
-// #[cfg(not(ztracing))]
-// pub use span;
+    pub fn record<T, S>(&self, _t: T, _s: S) {}
+}
 
 #[cfg(ztracing)]
 pub fn init() {

docs/.rules 🔗

@@ -0,0 +1,158 @@
+# Zed Documentation Guidelines
+
+## Voice and Tone
+
+### Core Principles
+
+- **Practical over promotional**: Focus on what users can do, not on selling Zed. Avoid marketing language like "powerful," "revolutionary," or "best-in-class."
+- **Honest about limitations**: When Zed lacks a feature or doesn't match another tool's depth, say so directly. Pair limitations with workarounds or alternative workflows.
+- **Direct and concise**: Use short sentences. Get to the point. Developers are scanning, not reading novels.
+- **Second person**: Address the reader as "you." Avoid "the user" or "one."
+- **Present tense**: "Zed opens the file" not "Zed will open the file."
+
+### What to Avoid
+
+- Superlatives without substance ("incredibly fast," "seamlessly integrated")
+- Hedging language ("simply," "just," "easily")—if something is simple, the instructions will show it
+- Apologetic tone for missing features—state the limitation and move on
+- Comparisons that disparage other tools—be factual, not competitive
+- Meta-commentary about honesty ("the honest take is...", "to be frank...", "honestly...")—let honesty show through frank assessments, not announcements
+- LLM-isms and filler words ("entirely," "certainly,", "deeply," "definitely," "actually")—these add nothing
+
+## Content Structure
+
+### Page Organization
+
+1. **Start with the goal**: Open with what the reader will accomplish, not background
+2. **Front-load the action**: Put the most common task first, edge cases later
+3. **Use headers liberally**: Readers scan; headers help them find what they need
+4. **End with "what's next"**: Link to related docs or logical next steps
+
+### Section Patterns
+
+For how-to content:
+1. Brief context (1-2 sentences max)
+2. Steps or instructions
+3. Example (code block or screenshot reference)
+4. Tips or gotchas (if any)
+
+For reference content:
+1. What it is (definition)
+2. How to access/configure it
+3. Options/parameters table
+4. Examples
+
+## Formatting Conventions
+
+### Keybindings
+
+- Use backticks for key combinations: `Cmd+Shift+P`
+- Show both macOS and Linux/Windows when they differ: `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+- Use `+` to join simultaneous keys, space for sequences: `Cmd+K Cmd+C`
+
+### Code and Settings
+
+- Inline code for setting names, file paths, commands: `format_on_save`, `.zed/settings.json`, `zed .`
+- Code blocks for JSON config, multi-line commands, or file contents
+- Always show complete, working examples—not fragments
+
+### Terminal Commands
+
+Use `sh` code blocks for terminal commands, not plain backticks:
+
+```sh
+brew install zed-editor/zed/zed
+```
+
+Not:
+```
+brew install zed-editor/zed/zed
+```
+
+For single inline commands in prose, backticks are fine: `zed .`
+
+### Tables
+
+Use tables for:
+- Keybinding comparisons between editors
+- Settings mappings (e.g., VS Code → Zed)
+- Feature comparisons with clear columns
+
+Format:
+```
+| Action | Shortcut | Notes |
+| --- | --- | --- |
+| Open File | `Cmd+O` | Works from any context |
+```
+
+### Tips and Notes
+
+Use blockquote format with bold label:
+```
+> **Tip:** Practical advice that helps bridge gaps or saves time.
+```
+
+Reserve tips for genuinely useful information, not padding.
+
+## Writing Guidelines
+
+### Settings Documentation
+
+- **Settings Editor first**: Show how to find and change settings in the UI before showing JSON
+- **JSON as secondary**: Present JSON examples as "Or add this to your settings.json" for users who prefer direct editing
+- **Complete examples**: Include the full JSON structure, not just the value
+
+### Migration Guides
+
+- **Jobs to be done**: Frame around tasks ("How do I search files?") not features ("File Search Feature")
+- **Acknowledge the source**: Respect that users have muscle memory and preferences from their previous editor
+- **Keybindings tables**: Essential for migration docs—show what maps, what's different, what's missing
+- **Trade-offs section**: Be explicit about what the user gains and loses in the switch
+
+### Feature Documentation
+
+- **Start with the default**: Document the out-of-box experience first
+- **Configuration options**: Group related settings together
+- **Cross-link generously**: Link to related features, settings reference, and relevant guides
+
+## Terminology
+
+| Use | Instead of |
+| --- | --- |
+| folder | directory (in user-facing text) |
+| project | workspace (Zed doesn't have workspaces) |
+| Settings Editor | settings UI, preferences |
+| command palette | command bar, action search |
+| language server | LSP (spell out first use, then LSP is fine) |
+| panel | tool window, sidebar (be specific: "Project Panel," "Terminal Panel") |
+
+## Examples
+
+### Good: Direct and actionable
+```
+To format on save, open the Settings Editor (`Cmd+,`) and search for `format_on_save`. Set it to `on`.
+
+Or add this to your settings.json:
+{
+  "format_on_save": "on"
+}
+```
+
+### Bad: Wordy and promotional
+```
+Zed provides a powerful and seamless formatting experience. Simply navigate to the settings and you'll find the format_on_save option which enables Zed's incredible auto-formatting capabilities.
+```
+
+### Good: Honest about limitations
+```
+Zed doesn't index your project like IntelliJ does. You open a folder and start working immediately—no waiting. The trade-off: cross-project analysis relies on language servers, which may not go as deep.
+
+**How to adapt:**
+- Use `Cmd+Shift+F` for project-wide text search
+- Use `Cmd+O` for symbol search (powered by your language server)
+```
+
+### Bad: Defensive or dismissive
+```
+While some users might miss indexing, Zed's approach is actually better because it's faster.
+```

docs/AGENTS.md 🔗

@@ -0,0 +1,353 @@
+# Documentation Automation Agent Guidelines
+
+This file governs automated documentation updates triggered by code changes. All automation phases must comply with these rules.
+
+## Documentation System
+
+This documentation uses **mdBook** (https://rust-lang.github.io/mdBook/).
+
+### Key Files
+
+- **`docs/src/SUMMARY.md`**: Table of contents following mdBook format (https://rust-lang.github.io/mdBook/format/summary.html)
+- **`docs/book.toml`**: mdBook configuration
+- **`docs/.prettierrc`**: Prettier config (80 char line width)
+
+### SUMMARY.md Format
+
+The `SUMMARY.md` file defines the book structure. Format rules:
+
+- Chapter titles are links: `[Title](./path/to/file.md)`
+- Nesting via indentation (2 spaces per level)
+- Separators: `---` for horizontal rules between sections
+- Draft chapters: `[Title]()` (empty parens, not yet written)
+
+Example:
+
+```markdown
+# Section Title
+
+- [Chapter](./chapter.md)
+  - [Nested Chapter](./nested.md)
+
+---
+
+# Another Section
+```
+
+### Custom Preprocessor
+
+The docs use a custom preprocessor (`docs_preprocessor`) that expands special commands:
+
+| Syntax                        | Purpose                               | Example                         |
+| ----------------------------- | ------------------------------------- | ------------------------------- |
+| `{#kb action::ActionName}`    | Keybinding for action                 | `{#kb agent::ToggleFocus}`      |
+| `{#action agent::ActionName}` | Action reference (renders as command) | `{#action agent::OpenSettings}` |
+
+**Rules:**
+
+- Always use preprocessor syntax for keybindings instead of hardcoding
+- Action names use `snake_case` in the namespace, `PascalCase` for the action
+- Common namespaces: `agent::`, `editor::`, `assistant::`, `vim::`
+
+### Formatting Requirements
+
+All documentation must pass **Prettier** formatting:
+
+```sh
+cd docs && npx prettier --check src/
+```
+
+Before any documentation change is considered complete:
+
+1. Run Prettier to format: `cd docs && npx prettier --write src/`
+2. Verify it passes: `cd docs && npx prettier --check src/`
+
+Prettier config: 80 character line width (`docs/.prettierrc`)
+
+### Section Anchors
+
+Use `{#anchor-id}` syntax for linkable section headers:
+
+```markdown
+## Getting Started {#getting-started}
+
+### Custom Models {#anthropic-custom-models}
+```
+
+Anchor IDs should be:
+
+- Lowercase with hyphens
+- Unique within the page
+- Descriptive (can include parent context like `anthropic-custom-models`)
+
+### Code Block Annotations
+
+Use annotations after the language identifier to indicate file context:
+
+```markdown
+\`\`\`json [settings]
+{
+"agent": { ... }
+}
+\`\`\`
+
+\`\`\`json [keymap]
+[
+{ "bindings": { ... } }
+]
+\`\`\`
+```
+
+Valid annotations: `[settings]` (for settings.json), `[keymap]` (for keymap.json)
+
+### Blockquote Formatting
+
+Use bold labels for callouts:
+
+```markdown
+> **Note:** Important information the user should know.
+
+> **Tip:** Helpful advice that saves time or improves workflow.
+
+> **Warn:** Caution about potential issues or gotchas.
+```
+
+### Image References
+
+Images are hosted externally. Reference format:
+
+```markdown
+![Alt text description](https://zed.dev/img/path/to/image.webp)
+```
+
+### Cross-Linking
+
+- Relative links for same-directory: `[Agent Panel](./agent-panel.md)`
+- With anchors: `[Custom Models](./llm-providers.md#anthropic-custom-models)`
+- Parent directory: `[Telemetry](../telemetry.md)`
+
+## Scope
+
+### In-Scope Documentation
+
+- All Markdown files in `docs/src/`
+- `docs/src/SUMMARY.md` (mdBook table of contents)
+- Language-specific docs in `docs/src/languages/`
+- Feature docs (AI, extensions, configuration, etc.)
+
+### Out-of-Scope (Do Not Modify)
+
+- `CHANGELOG.md`, `CONTRIBUTING.md`, `README.md` at repo root
+- Inline code comments and rustdoc
+- `CLAUDE.md`, `GEMINI.md`, or other AI instruction files
+- Build configuration (`book.toml`, theme files, `docs_preprocessor`)
+- Any file outside `docs/src/`
+
+## Page Structure Patterns
+
+### Standard Page Layout
+
+Most documentation pages follow this structure:
+
+1. **Title** (H1) - Single sentence or phrase
+2. **Overview/Introduction** - 1-3 paragraphs explaining what this is
+3. **Getting Started** `{#getting-started}` - Prerequisites and first steps
+4. **Main Content** - Feature details, organized by topic
+5. **Advanced/Configuration** - Power user options
+6. **See Also** (optional) - Related documentation links
+
+### Settings Documentation Pattern
+
+When documenting settings:
+
+1. Show the Settings Editor (UI) approach first
+2. Then show JSON as "Or add this to your settings.json:"
+3. Always show complete, valid JSON with surrounding structure:
+
+```json [settings]
+{
+  "agent": {
+    "default_model": {
+      "provider": "anthropic",
+      "model": "claude-sonnet-4"
+    }
+  }
+}
+```
+
+### Provider/Feature Documentation Pattern
+
+For each provider or distinct feature:
+
+1. H3 heading with anchor: `### Provider Name {#provider-name}`
+2. Brief description (1-2 sentences)
+3. Setup steps (numbered list)
+4. Configuration example (JSON code block)
+5. Custom models section if applicable: `#### Custom Models {#provider-custom-models}`
+
+## Style Rules
+
+Inherit all conventions from `docs/.rules`. Key points:
+
+### Voice
+
+- Second person ("you"), present tense
+- Direct and concise—no hedging ("simply", "just", "easily")
+- Honest about limitations; no promotional language
+
+### Formatting
+
+- Keybindings: backticks with `+` for simultaneous keys (`Cmd+Shift+P`)
+- Show both macOS and Linux/Windows variants when they differ
+- Use `sh` code blocks for terminal commands
+- Settings: show Settings Editor UI first, JSON as secondary
+
+### Terminology
+
+| Use             | Instead of                             |
+| --------------- | -------------------------------------- |
+| folder          | directory                              |
+| project         | workspace                              |
+| Settings Editor | settings UI                            |
+| command palette | command bar                            |
+| panel           | sidebar (be specific: "Project Panel") |
+
+## Zed-Specific Conventions
+
+### Recognized Rules Files
+
+When documenting rules/instructions for AI, note that Zed recognizes these files (in priority order):
+
+- `.rules`
+- `.cursorrules`
+- `.windsurfrules`
+- `.clinerules`
+- `.github/copilot-instructions.md`
+- `AGENT.md`
+- `AGENTS.md`
+- `CLAUDE.md`
+- `GEMINI.md`
+
+### Settings File Locations
+
+- macOS: `~/.config/zed/settings.json`
+- Linux: `~/.config/zed/settings.json`
+- Windows: `%AppData%\Zed\settings.json`
+
+### Keymap File Locations
+
+- macOS: `~/.config/zed/keymap.json`
+- Linux: `~/.config/zed/keymap.json`
+- Windows: `%AppData%\Zed\keymap.json`
+
+## Safety Constraints
+
+### Must Not
+
+- Delete existing documentation files
+- Remove sections documenting existing functionality
+- Change URLs or anchor links without verifying references
+- Modify `SUMMARY.md` structure without corresponding content
+- Add speculative documentation for unreleased features
+- Include internal implementation details not relevant to users
+
+### Must
+
+- Preserve existing structure when updating content
+- Maintain backward compatibility of documented settings/commands
+- Flag uncertainty explicitly rather than guessing
+- Link to related documentation when adding new sections
+
+## Change Classification
+
+### Requires Documentation Update
+
+- New user-facing features or commands
+- Changed keybindings or default behaviors
+- Modified settings schema or options
+- Deprecated or removed functionality
+- API changes affecting extensions
+
+### Does Not Require Documentation Update
+
+- Internal refactoring without behavioral changes
+- Performance optimizations (unless user-visible)
+- Bug fixes that restore documented behavior
+- Test changes
+- CI/CD changes
+
+## Output Format
+
+### Phase 4 Documentation Plan
+
+When generating a documentation plan, use this structure:
+
+```markdown
+## Documentation Impact Assessment
+
+### Summary
+
+Brief description of code changes analyzed.
+
+### Documentation Updates Required: [Yes/No]
+
+### Planned Changes
+
+#### 1. [File Path]
+
+- **Section**: [Section name or "New section"]
+- **Change Type**: [Update/Add/Deprecate]
+- **Reason**: Why this change is needed
+- **Description**: What will be added/modified
+
+#### 2. [File Path]
+
+...
+
+### Uncertainty Flags
+
+- [ ] [Description of any assumptions or areas needing confirmation]
+
+### No Changes Needed
+
+- [List files reviewed but not requiring updates, with brief reason]
+```
+
+### Phase 6 Summary Format
+
+```markdown
+## Documentation Update Summary
+
+### Changes Made
+
+| File           | Change            | Related Code      |
+| -------------- | ----------------- | ----------------- |
+| path/to/doc.md | Brief description | link to PR/commit |
+
+### Rationale
+
+Brief explanation of why these updates were made.
+
+### Review Notes
+
+Any items reviewers should pay special attention to.
+```
+
+## Behavioral Guidelines
+
+### Conservative by Default
+
+- When uncertain whether to document something, flag it for human review
+- Prefer smaller, focused updates over broad rewrites
+- Do not "improve" documentation unrelated to the triggering code change
+
+### Traceability
+
+- Every documentation change should trace to a specific code change
+- Include references to relevant commits, PRs, or issues in summaries
+
+### Incremental Updates
+
+- Update existing sections rather than creating parallel documentation
+- Maintain consistency with surrounding content
+- Follow the established patterns in each documentation area

docs/src/SUMMARY.md 🔗

@@ -23,6 +23,9 @@
 - [Visual Customization](./visual-customization.md)
 - [Vim Mode](./vim.md)
 - [Helix Mode](./helix.md)
+- [Privacy and Security](./ai/privacy-and-security.md)
+  - [Worktree Trust](./worktree-trust.md)
+  - [AI Improvement](./ai/ai-improvement.md)
 
 <!-- - [Globs](./globs.md) -->
 <!-- - [Fonts](./fonts.md) -->
@@ -43,6 +46,7 @@
 - [Tasks](./tasks.md)
 - [Tab Switcher](./tab-switcher.md)
 - [Remote Development](./remote-development.md)
+- [Dev Containers](./dev-containers.md)
 - [Environment Variables](./environment.md)
 - [REPL](./repl.md)
 
@@ -69,8 +73,6 @@
   - [Models](./ai/models.md)
   - [Plans and Usage](./ai/plans-and-usage.md)
   - [Billing](./ai/billing.md)
-- [Privacy and Security](./ai/privacy-and-security.md)
-  - [AI Improvement](./ai/ai-improvement.md)
 
 # Extensions
 
@@ -86,9 +88,13 @@
 - [Agent Server Extensions](./extensions/agent-servers.md)
 - [MCP Server Extensions](./extensions/mcp-extensions.md)
 
-# Migrate
+# Coming From...
 
 - [VS Code](./migrate/vs-code.md)
+- [IntelliJ IDEA](./migrate/intellij.md)
+- [PyCharm](./migrate/pycharm.md)
+- [WebStorm](./migrate/webstorm.md)
+- [RustRover](./migrate/rustrover.md)
 
 # Language Support
 
@@ -171,7 +177,6 @@
   - [Linux](./development/linux.md)
   - [Windows](./development/windows.md)
   - [FreeBSD](./development/freebsd.md)
-  - [Local Collaboration](./development/local-collaboration.md)
   - [Using Debuggers](./development/debuggers.md)
   - [Performance](./performance.md)
   - [Glossary](./development/glossary.md)

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/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/ai/privacy-and-security.md 🔗

@@ -2,7 +2,7 @@
 
 ## Philosophy
 
-Zed aims to collect on the minimum data necessary to serve and improve our product.
+Zed aims to collect only the minimum data necessary to serve and improve our product.
 
 We believe in opt-in data sharing as the default in building AI products, rather than opt-out, like most of our competitors. Privacy Mode is not a setting to be toggled, it's a default stance.
 
@@ -12,6 +12,8 @@ It is entirely possible to use Zed, including Zed's AI capabilities, without sha
 
 ## Documentation
 
+- [Worktree trust](../worktree-trust.md): How Zed opens files and directories in restricted mode.
+
 - [Telemetry](../telemetry.md): How Zed collects general telemetry data.
 
 - [AI Improvement](./ai-improvement.md): Zed's opt-in-only approach to data collection for AI improvement, whether our Agentic offering or Edit Predictions.

docs/src/ai/rules.md 🔗

@@ -46,7 +46,7 @@ Having a series of rules files specifically tailored to prompt engineering can a
 
 Here are a couple of helpful resources for writing better rules:
 
-- [Anthropic: Prompt Engineering](https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview)
+- [Anthropic: Prompt Engineering](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/overview)
 - [OpenAI: Prompt Engineering](https://platform.openai.com/docs/guides/prompt-engineering)
 
 ### Editing the Default Rules {#default-rules}

docs/src/completions.md 🔗

@@ -15,6 +15,10 @@ When there is an appropriate language server available, Zed will provide complet
 
 You can manually trigger completions with `ctrl-space` or by triggering the `editor::ShowCompletions` action from the command palette.
 
+> Note: Using `ctrl-space` in Zed requires disabling the macOS global shortcut.
+> Open **System Settings** > **Keyboard** > **Keyboard Shortcut**s >
+> **Input Sources** and uncheck **Select the previous input source**.
+
 For more information, see:
 
 - [Configuring Supported Languages](./configuring-languages.md)

docs/src/configuring-zed.md 🔗

@@ -1451,6 +1451,47 @@ or
 
 `boolean` values
 
+### Session
+
+- Description: Controls Zed lifecycle-related behavior.
+- Setting: `session`
+- Default:
+
+```json
+{
+  "session": {
+    "restore_unsaved_buffers": true,
+    "trust_all_worktrees": false
+  }
+}
+```
+
+**Options**
+
+1.  Whether or not to restore unsaved buffers on restart:
+
+```json [settings]
+{
+  "session": {
+    "restore_unsaved_buffers": true
+  }
+}
+```
+
+If this is true, user won't be prompted whether to save/discard dirty files when closing the application.
+
+2. Whether or not to skip worktree and workspace trust checks:
+
+```json [settings]
+{
+  "session": {
+    "trust_all_worktrees": false
+  }
+}
+```
+
+When trusted, project settings are synchronized automatically, language and MCP servers are downloaded and started automatically.
+
 ### Drag And Drop Selection
 
 - Description: Whether to allow drag and drop text selection in buffer. `delay` is the milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created.
@@ -1544,6 +1585,26 @@ Positive `integer` value between 1 and 32. Values outside of this range will be
 
 `boolean` values
 
+## Extend List On Newline
+
+- Description: Whether to continue lists when pressing Enter at the end of a list item. Supports unordered, ordered, and task lists. Pressing Enter on an empty list item removes the marker and exits the list.
+- Setting: `extend_list_on_newline`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## Indent List On Tab
+
+- Description: Whether to indent list items when pressing Tab on a line containing only a list marker. This enables quick creation of nested lists.
+- Setting: `indent_list_on_tab`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
 ## Status Bar
 
 - Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere.
@@ -3142,7 +3203,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/dev-containers.md 🔗

@@ -0,0 +1,50 @@
+# Dev Containers
+
+Dev Containers provide a consistent, reproducible development environment by defining your project's dependencies, tools, and settings in a container configuration.
+
+If your repository includes a `.devcontainer/devcontainer.json` file, Zed can open a project inside a development container.
+
+## Requirements
+
+- Docker must be installed and available in your `PATH`. Zed requires the `docker` command to be present. If you use Podman, you can alias it to `docker` (e.g., `alias docker=podman`).
+- Your project must contain a `.devcontainer/devcontainer.json` directory/file.
+
+## Using Dev Containers in Zed
+
+### Automatic prompt
+
+When you open a project that contains the `.devcontainer/devcontainer.json` directory/file, Zed will display a prompt asking whether to open the project inside the dev container. Choosing "Open in Container" will:
+
+1. Build the dev container image (if needed).
+2. Launch the container.
+3. Reopen the project connected to the container environment.
+
+### Manual open
+
+If you dismiss the prompt or want to reopen the project inside a container later, you can use Zed's command palette to run the "Project: Open Remote" command and select the option to open the project in a dev container.
+Alternatively, you can reach for the Remote Projects modal (through the {#kb projects::OpenRemote} binding) and choose the "Connect Dev Container" option.
+
+## Editing the dev container configuration
+
+If you modify `.devcontainer/devcontainer.json`, Zed does not currently rebuild or reload the container automatically. After changing configuration:
+
+- Stop or kill the existing container manually (e.g., via `docker kill <container>`).
+- Reopen the project in the container.
+
+## Working in a Dev Container
+
+Once connected, Zed operates inside the container environment for tasks, terminals, and language servers.
+Files are linked from your workspace into the container according to the dev container specification.
+
+## Known Limitations
+
+> **Note:** This feature is still in development.
+
+- **Extensions:** Zed does not yet manage extensions separately for container environments. The host's extensions are used as-is.
+- **Port forwarding:** Only the `appPort` field is supported. `forwardPorts` and other advanced port-forwarding features are not implemented.
+- **Configuration changes:** Updates to `devcontainer.json` do not trigger automatic rebuilds or reloads; containers must be manually restarted.
+
+## See also
+
+- [Remote Development](./remote-development.md) for connecting to remote servers over SSH.
+- [Tasks](./tasks.md) for running commands in the integrated terminal.

docs/src/development.md 🔗

@@ -6,10 +6,6 @@ See the platform-specific instructions for building Zed from source:
 - [Linux](./development/linux.md)
 - [Windows](./development/windows.md)
 
-If you'd like to develop collaboration features, additionally see:
-
-- [Local Collaboration](./development/local-collaboration.md)
-
 ## Keychain access
 
 Zed stores secrets in the system keychain.

docs/src/development/glossary.md 🔗

@@ -73,7 +73,7 @@ h_flex()
 
 - `Window`: A struct in zed representing a zed window in your desktop environment (see image below). There can be multiple if you have multiple zed instances open. Mostly passed around for rendering.
 - `Modal`: A UI element that floats on top of the rest of the UI
-- `Picker`: A struct representing a list of items in floating on top of the UI (Modal). You can select an item and confirm. What happens on select or confirm is determined by the picker's delegate. (The 'Model' in the image below is a picker.)
+- `Picker`: A struct representing a list of items floating on top of the UI (Modal). You can select an item and confirm. What happens on select or confirm is determined by the picker's delegate. (The 'Modal' in the image below is a picker.)
 - `PickerDelegate`: A trait used to specialize behavior for a `Picker`. The `Picker` stores the `PickerDelegate` in the field delegate.
 - `Center`: The middle of the zed window, the center is split into multiple `Pane`s. In the codebase this is a field on the `Workspace` struct. (see image below).
 - `Pane`: An area in the `Center` where we can place items, such as an editor, multi-buffer or terminal (see image below).

docs/src/development/linux.md 🔗

@@ -16,10 +16,6 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed).
 
   If you prefer to install the system libraries manually, you can find the list of required packages in the `script/linux` file.
 
-### Backend Dependencies (optional) {#backend-dependencies}
-
-If you are looking to develop Zed collaboration features using a local collaboration server, please see: [Local Collaboration](./local-collaboration.md) docs.
-
 ### Linkers {#linker}
 
 On Linux, Rust's default linker is [LLVM's `lld`](https://blog.rust-lang.org/2025/09/18/Rust-1.90.0/). Alternative linkers, especially [Wild](https://github.com/davidlattimore/wild) and [Mold](https://github.com/rui314/mold) can significantly improve clean and incremental build time.

docs/src/development/local-collaboration.md 🔗

@@ -1,207 +0,0 @@
-# Local Collaboration
-
-1. Ensure you have access to our cloud infrastructure. If you don't have access, you can't collaborate locally at this time.
-
-2. Make sure you've installed Zed's dependencies for your platform:
-
-- [macOS](#macos)
-- [Linux](#linux)
-- [Windows](#backend-windows)
-
-Note that `collab` can be compiled only with MSVC toolchain on Windows
-
-3. Clone down our cloud repository and follow the instructions in the cloud README
-
-4. Setup the local database for your platform:
-
-- [macOS & Linux](#database-unix)
-- [Windows](#database-windows)
-
-5. Run collab:
-
-- [macOS & Linux](#run-collab-unix)
-- [Windows](#run-collab-windows)
-
-## Backend Dependencies
-
-If you are developing collaborative features of Zed, you'll need to install the dependencies of zed's `collab` server:
-
-- PostgreSQL
-- LiveKit
-- Foreman
-
-You can install these dependencies natively or run them under Docker.
-
-### macOS
-
-1. Install [Postgres.app](https://postgresapp.com) or [postgresql via homebrew](https://formulae.brew.sh/formula/postgresql@15):
-
-   ```sh
-   brew install postgresql@15
-   ```
-
-2. Install [Livekit](https://formulae.brew.sh/formula/livekit) and [Foreman](https://formulae.brew.sh/formula/foreman)
-
-   ```sh
-   brew install livekit foreman
-   ```
-
-- Follow the steps in the [collab README](https://github.com/zed-industries/zed/blob/main/crates/collab/README.md) to configure the Postgres database for integration tests
-
-Alternatively, if you have [Docker](https://www.docker.com/) installed you can bring up all the `collab` dependencies using Docker Compose.
-
-### Linux
-
-1. Install [Postgres](https://www.postgresql.org/download/linux/)
-
-   ```sh
-   sudo apt-get install postgresql                    # Ubuntu/Debian
-   sudo pacman -S postgresql                          # Arch Linux
-   sudo dnf install postgresql postgresql-server      # RHEL/Fedora
-   sudo zypper install postgresql postgresql-server   # OpenSUSE
-   ```
-
-2. Install [Livekit](https://github.com/livekit/livekit-cli)
-
-   ```sh
-   curl -sSL https://get.livekit.io/cli | bash
-   ```
-
-3. Install [Foreman](https://theforeman.org/manuals/3.15/quickstart_guide.html)
-
-### Windows {#backend-windows}
-
-> This section is still in development. The instructions are not yet complete.
-
-- Install [Postgres](https://www.postgresql.org/download/windows/)
-- Install [Livekit](https://github.com/livekit/livekit), optionally you can add the `livekit-server` binary to your `PATH`.
-
-Alternatively, if you have [Docker](https://www.docker.com/) installed you can bring up all the `collab` dependencies using Docker Compose.
-
-### Docker {#Docker}
-
-If you have docker or podman available, you can run the backend dependencies inside containers with Docker Compose:
-
-```sh
-docker compose up -d
-```
-
-## Database setup
-
-Before you can run the `collab` server locally, you'll need to set up a `zed` Postgres database.
-
-### On macOS and Linux {#database-unix}
-
-```sh
-script/bootstrap
-```
-
-This script will set up the `zed` Postgres database, and populate it with some users. It requires internet access, because it fetches some users from the GitHub API.
-
-The script will seed the database with various content defined by:
-
-```sh
-cat crates/collab/seed.default.json
-```
-
-To use a different set of admin users, you can create your own version of that json file and export the `SEED_PATH` environment variable. Note that the usernames listed in the admins list currently must correspond to valid GitHub users.
-
-```json [settings]
-{
-  "admins": ["admin1", "admin2"],
-  "channels": ["zed"]
-}
-```
-
-### On Windows {#database-windows}
-
-```powershell
-.\script\bootstrap.ps1
-```
-
-## Testing collaborative features locally
-
-### On macOS and Linux {#run-collab-unix}
-
-Ensure that Postgres is configured and running, then run Zed's collaboration server and the `livekit` dev server:
-
-```sh
-foreman start
-# OR
-docker compose up
-```
-
-Alternatively, if you're not testing voice and screenshare, you can just run `collab` and `cloud`, and not the `livekit` dev server:
-
-```sh
-cargo run -p collab -- serve all
-```
-
-```sh
-cd ../cloud; cargo make dev
-```
-
-In a new terminal, run two or more instances of Zed.
-
-```sh
-script/zed-local -3
-```
-
-This script starts one to four instances of Zed, depending on the `-2`, `-3` or `-4` flags. Each instance will be connected to the local `collab` server, signed in as a different user from `.admins.json` or `.admins.default.json`.
-
-### On Windows {#run-collab-windows}
-
-Since `foreman` is not available on Windows, you can run the following commands in separate terminals:
-
-```powershell
-cargo run --package=collab -- serve all
-```
-
-If you have added the `livekit-server` binary to your `PATH`, you can run:
-
-```powershell
-livekit-server --dev
-```
-
-Otherwise,
-
-```powershell
-.\path\to\livekit-serve.exe --dev
-```
-
-You'll also need to start the cloud server:
-
-```powershell
-cd ..\cloud; cargo make dev
-```
-
-In a new terminal, run two or more instances of Zed.
-
-```powershell
-node .\script\zed-local -2
-```
-
-Note that this requires `node.exe` to be in your `PATH`.
-
-## Running a local collab server
-
-> [!NOTE]
-> Because of recent changes to our authentication system, Zed will not be able to authenticate itself with, and therefore use, a local collab server.
-
-If you want to run your own version of the zed collaboration service, you can, but note that this is still under development, and there is no support for authentication nor extensions.
-
-Configuration is done through environment variables. By default it will read the configuration from [`.env.toml`](https://github.com/zed-industries/zed/blob/main/crates/collab/.env.toml) and you should use that as a guide for setting this up.
-
-By default Zed assumes that the DATABASE_URL is a Postgres database, but you can make it use Sqlite by compiling with `--features sqlite` and using a sqlite DATABASE_URL with `?mode=rwc`.
-
-To authenticate you must first configure the server by creating a seed.json file that contains at a minimum your github handle. This will be used to create the user on demand.
-
-```json [settings]
-{
-  "admins": ["nathansobo"]
-}
-```
-
-By default the collab server will seed the database when first creating it, but if you want to add more users you can explicitly reseed them with `SEED_PATH=./seed.json cargo run -p collab seed`
-
-Then when running the zed client you must specify two environment variables, `ZED_ADMIN_API_TOKEN` (which should match the value of `API_TOKEN` in .env.toml) and `ZED_IMPERSONATE` (which should match one of the users in your seed.json)

docs/src/development/macos.md 🔗

@@ -31,10 +31,6 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed).
   brew install cmake
   ```
 
-### Backend Dependencies (optional) {#backend-dependencies}
-
-If you are looking to develop Zed collaboration features using a local collaboration server, please see: [Local Collaboration](./local-collaboration.md) docs.
-
 ## Building Zed from Source
 
 Once you have the dependencies installed, you can build Zed using [Cargo](https://doc.rust-lang.org/cargo/).

docs/src/development/windows.md 🔗

@@ -66,10 +66,6 @@ The list can be obtained as follows:
 - Click on `More` in the `Installed` tab
 - Click on `Export configuration`
 
-### Backend Dependencies (optional) {#backend-dependencies}
-
-If you are looking to develop Zed collaboration features using a local collaboration server, please see: [Local Collaboration](./local-collaboration.md) docs.
-
 ### Notes
 
 You should modify the `pg_hba.conf` file in the `data` directory to use `trust` instead of `scram-sha-256` for the `host` method. Otherwise, the connection will fail with the error `password authentication failed`. The `pg_hba.conf` file typically locates at `C:\Program Files\PostgreSQL\17\data\pg_hba.conf`. After the modification, the file should look like this:

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/languages/javascript.md 🔗

@@ -175,6 +175,34 @@ You can configure ESLint's `workingDirectory` setting:
 }
 ```
 
+## Using the Tailwind CSS Language Server with JavaScript
+
+To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in vanilla JavaScript files (`.js`), you can customize the `classRegex` field under it in your `settings.json`:
+
+```json [settings]
+{
+  "lsp": {
+    "tailwindcss-language-server": {
+      "settings": {
+        "experimental": {
+          "classRegex": [
+            "\\.className\\s*[+]?=\\s*['\"]([^'\"]*)['\"]",
+            "\\.setAttributeNS\\(.*,\\s*['\"]class['\"],\\s*['\"]([^'\"]*)['\"]",
+            "\\.setAttribute\\(['\"]class['\"],\\s*['\"]([^'\"]*)['\"]",
+            "\\.classList\\.add\\(['\"]([^'\"]*)['\"]",
+            "\\.classList\\.remove\\(['\"]([^'\"]*)['\"]",
+            "\\.classList\\.toggle\\(['\"]([^'\"]*)['\"]",
+            "\\.classList\\.contains\\(['\"]([^'\"]*)['\"]",
+            "\\.classList\\.replace\\(\\s*['\"]([^'\"]*)['\"]",
+            "\\.classList\\.replace\\([^,)]+,\\s*['\"]([^'\"]*)['\"]"
+          ]
+        }
+      }
+    }
+  }
+}
+```
+
 ## Debugging
 
 Zed supports debugging JavaScript code out of the box with `vscode-js-debug`.
@@ -186,7 +214,7 @@ The following can be debugged without writing additional configuration:
 Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks.
 
 > **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`.
->
+
 > **Note:** Node test is automatically detected when `@types/node` is present in `package.json` (requires Node.js 20+).
 
 As for all languages, configurations from `.vscode/launch.json` are also available for debugging in Zed.

docs/src/languages/markdown.md 🔗

@@ -33,6 +33,40 @@ Zed supports using Prettier to automatically re-format Markdown documents. You c
   },
 ```
 
+### List Continuation
+
+Zed automatically continues lists when you press Enter at the end of a list item. Supported list types:
+
+- Unordered lists (`-`, `*`, or `+` markers)
+- Ordered lists (numbers are auto-incremented)
+- Task lists (`- [ ]` and `- [x]`)
+
+Pressing Enter on an empty list item removes the marker and exits the list.
+
+To disable this behavior:
+
+```json [settings]
+  "languages": {
+    "Markdown": {
+      "extend_list_on_newline": false
+    }
+  },
+```
+
+### List Indentation
+
+Zed indents list items when you press Tab while the cursor is on a line containing only a list marker. This allows you to quickly create nested lists.
+
+To disable this behavior:
+
+```json [settings]
+  "languages": {
+    "Markdown": {
+      "indent_list_on_tab": false
+    }
+  },
+```
+
 ### Trailing Whitespace
 
 By default Zed will remove trailing whitespace on save. If you rely on invisible trailing whitespace being converted to `<br />` in Markdown files you can disable this behavior with:

docs/src/languages/ruby.md 🔗

@@ -258,17 +258,10 @@ To enable Steep, add `\"steep\"` to the `language_servers` list for Ruby in your
 
 ## Using the Tailwind CSS Language Server with Ruby
 
-It's possible to use the [Tailwind CSS Language Server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in Ruby and ERB files.
-
-In order to do that, you need to configure the language server so that it knows about where to look for CSS classes in Ruby/ERB files by adding the following to your `settings.json`:
+To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in Ruby/ERB files, you need to configure the language server so that it knows about where to look for CSS classes by adding the following to your `settings.json`:
 
 ```json [settings]
 {
-  "languages": {
-    "Ruby": {
-      "language_servers": ["tailwindcss-language-server", "..."]
-    }
-  },
   "lsp": {
     "tailwindcss-language-server": {
       "settings": {
@@ -281,7 +274,7 @@ In order to do that, you need to configure the language server so that it knows
 }
 ```
 
-With these settings you will get completions for Tailwind CSS classes in HTML attributes inside ERB files and inside Ruby/ERB strings that are coming after a `class:` key. Examples:
+With these settings, you will get completions for Tailwind CSS classes in HTML attributes inside ERB files and inside Ruby/ERB strings that are coming after a `class:` key. Examples:
 
 ```rb
 # Ruby file:

docs/src/languages/tailwindcss.md 🔗

@@ -4,9 +4,23 @@ Zed has built-in support for Tailwind CSS autocomplete, linting, and hover previ
 
 - Language Server: [tailwindlabs/tailwindcss-intellisense](https://github.com/tailwindlabs/tailwindcss-intellisense)
 
+Languages which can be used with Tailwind CSS in Zed:
+
+- [Astro](./astro.md)
+- [CSS](./css.md)
+- [ERB](./ruby.md)
+- [Gleam](./gleam.md)
+- [HEEx](./elixir.md#heex)
+- [HTML](./html.md)
+- [TypeScript](./typescript.md)
+- [JavaScript](./javascript.md)
+- [PHP](./php.md)
+- [Svelte](./svelte.md)
+- [Vue](./vue.md)
+
 ## Configuration
 
-To configure the Tailwind CSS language server, refer [to the extension settings](https://github.com/tailwindlabs/tailwindcss-intellisense?tab=readme-ov-file#extension-settings) and add them to the `lsp` section of your `settings.json`:
+If by default the language server isn't enough to make Tailwind work for a given language, you can configure the language server settings and add them to the `lsp` section of your `settings.json`:
 
 ```json [settings]
 {
@@ -23,19 +37,7 @@ To configure the Tailwind CSS language server, refer [to the extension settings]
 }
 ```
 
-Languages which can be used with Tailwind CSS in Zed:
-
-- [Astro](./astro.md)
-- [CSS](./css.md)
-- [ERB](./ruby.md)
-- [Gleam](./gleam.md)
-- [HEEx](./elixir.md#heex)
-- [HTML](./html.md)
-- [TypeScript](./typescript.md)
-- [JavaScript](./javascript.md)
-- [PHP](./php.md)
-- [Svelte](./svelte.md)
-- [Vue](./vue.md)
+Refer to [the Tailwind CSS language server settings docs](https://github.com/tailwindlabs/tailwindcss-intellisense?tab=readme-ov-file#extension-settings) for more information.
 
 ### Prettier Plugin
 

docs/src/languages/typescript.md 🔗

@@ -45,6 +45,34 @@ Prettier will also be used for TypeScript files by default. To disable this:
 }
 ```
 
+## Using the Tailwind CSS Language Server with TypeScript
+
+To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in vanilla TypeScript files (`.ts`), you can customize the `classRegex` field under it in your `settings.json`:
+
+```json [settings]
+{
+  "lsp": {
+    "tailwindcss-language-server": {
+      "settings": {
+        "experimental": {
+          "classRegex": [
+            "\\.className\\s*[+]?=\\s*['\"]([^'\"]*)['\"]",
+            "\\.setAttributeNS\\(.*,\\s*['\"]class['\"],\\s*['\"]([^'\"]*)['\"]",
+            "\\.setAttribute\\(['\"]class['\"],\\s*['\"]([^'\"]*)['\"]",
+            "\\.classList\\.add\\(['\"]([^'\"]*)['\"]",
+            "\\.classList\\.remove\\(['\"]([^'\"]*)['\"]",
+            "\\.classList\\.toggle\\(['\"]([^'\"]*)['\"]",
+            "\\.classList\\.contains\\(['\"]([^'\"]*)['\"]",
+            "\\.classList\\.replace\\(\\s*['\"]([^'\"]*)['\"]",
+            "\\.classList\\.replace\\([^,)]+,\\s*['\"]([^'\"]*)['\"]"
+          ]
+        }
+      }
+    }
+  }
+}
+```
+
 ## Large projects
 
 `vtsls` may run out of memory on very large projects. We default the limit to 8092 (8 GiB) vs. the default of 3072 but this may not be sufficient for you:
@@ -167,7 +195,7 @@ The following can be debugged without writing additional configuration:
 Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks.
 
 > **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`.
->
+
 > **Note:** Node test is automatically detected when `@types/node` is present in `package.json` (requires Node.js 20+).
 
 As for all languages, configurations from `.vscode/launch.json` are also available for debugging in Zed.

docs/src/migrate/_research-notes.md 🔗

@@ -0,0 +1,73 @@
+<!--
+  TEMPORARY RESEARCH FILE - Delete when migration guides are complete
+
+  This file contains external community insights used to add "flair" to migration guides.
+  These are NOT the template or backbone—use intellij.md as the structural template.
+
+  STATUS:
+  ✅ PyCharm guide - COMPLETE
+  ✅ WebStorm guide - COMPLETE
+  ✅ RustRover guide - COMPLETE
+-->
+
+# Migration Research Notes
+
+## Completed Guides
+
+All three JetBrains migration guides have been populated with full content:
+
+1. **pycharm.md** - Python development, virtual environments, Ruff/Pyright, Django/Flask workflows
+2. **webstorm.md** - JavaScript/TypeScript development, npm workflows, framework considerations
+3. **rustrover.md** - Rust development, rust-analyzer parity, Cargo workflows, licensing notes
+
+## Key Sources Used
+
+- IntelliJ IDEA migration doc (structural template)
+- JetBrains PyCharm Getting Started docs
+- JetBrains WebStorm Getting Started docs
+- JetBrains RustRover Quick Start Guide
+- External community feedback (Reddit, Hacker News, Medium)
+
+## External Quotes Incorporated
+
+### WebStorm Guide
+
+> "I work for AWS and the applications I deal with are massive. Often I need to keep many projects open due to tight dependencies. I'm talking about complex microservices and micro frontend infrastructure which oftentimes lead to 2-15 minutes of indexing wait time whenever I open a project or build the system locally."
+
+### RustRover Guide
+
+- Noted rust-analyzer shared foundation between RustRover and Zed
+- Addressed licensing/telemetry concerns that motivate some users to switch
+- Included debugger caveats based on community feedback
+
+## Cross-Cutting Themes Applied to All Guides
+
+### Universal Pain Points Addressed
+
+1. Indexing (instant in Zed)
+2. Resource usage (Zed is lightweight)
+3. Startup time (Zed is near-instant)
+4. UI clutter (Zed is minimal by design)
+
+### Universal Missing Features Documented
+
+- No project model / SDK management
+- No database tools
+- No framework-specific integration
+- No visual run configurations (use tasks)
+- No built-in HTTP client
+
+### JetBrains Keymap Emphasized
+
+All three guides emphasize:
+
+- Select JetBrains keymap during onboarding or in settings
+- `Shift Shift` for Search Everywhere works
+- Most familiar shortcuts preserved
+
+## Next Steps (Optional Enhancements)
+
+- [ ] Cross-link guides to JetBrains docs for users who want to reference original IDE features
+- [ ] Add a consolidated "hub page" linking to all migration guides
+- [ ] Consider adding VS Code migration guide using similar structure
+- [ ] Review for tone consistency against Zed Documentation Guidelines

docs/src/migrate/intellij.md 🔗

@@ -0,0 +1,357 @@
+# How to Migrate from IntelliJ IDEA to Zed
+
+This guide covers how to set up Zed if you're coming from IntelliJ IDEA, including keybindings, settings, and the differences you should expect.
+
+## Install Zed
+
+Zed is available on macOS, Windows, and Linux.
+
+For macOS, you can download it from zed.dev/download, or install via Homebrew:
+
+```sh
+brew install --cask zed
+```
+
+For Windows, download the installer from zed.dev/download, or install via winget:
+
+```sh
+winget install Zed.Zed
+```
+
+For most Linux users, the easiest way to install Zed is through our installation script:
+
+```sh
+curl -f https://zed.dev/install.sh | sh
+```
+
+After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using:
+`zed .`
+This opens the current directory in Zed.
+
+## Set Up the JetBrains Keymap
+
+If you're coming from IntelliJ, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime:
+
+1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+2. Search for `Base Keymap`
+3. Select `JetBrains`
+
+Or add this directly to your `settings.json`:
+
+```json
+{
+  "base_keymap": "JetBrains"
+}
+```
+
+This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action.
+
+## Set Up Editor Preferences
+
+You can configure settings manually in the Settings Editor.
+
+To edit your settings:
+
+1. `Cmd+,` to open the Settings Editor.
+2. Run `zed: open settings` in the Command Palette.
+
+Settings IntelliJ users typically configure first:
+
+| Zed Setting             | What it does                                                                    |
+| ----------------------- | ------------------------------------------------------------------------------- |
+| `format_on_save`        | Auto-format when saving. Set to `"on"` to enable.                               |
+| `soft_wrap`             | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` |
+| `preferred_line_length` | Column width for wrapping and rulers. Default is 80.                            |
+| `inlay_hints`           | Show parameter names and type hints inline, like IntelliJ's hints.              |
+| `relative_line_numbers` | Useful if you're coming from IdeaVim.                                           |
+
+Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in IntelliJ.
+
+> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line.
+
+## Open or Create a Project
+
+After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike IntelliJ, there's no project configuration wizard, no `.iml` files, and no SDK setup required.
+
+To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project.
+
+You can also launch Zed from the terminal inside any folder with:
+`zed .`
+
+Once inside a project:
+
+- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like IntelliJ's "Recent Files")
+- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like IntelliJ's "Search Everywhere")
+- Use `Cmd+O` to search for symbols (like IntelliJ's "Go to Class")
+
+Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like IntelliJ's Project tool window).
+
+## Differences in Keybindings
+
+If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to IntelliJ.
+
+### Common Shared Keybindings (Zed with JetBrains keymap ↔ IntelliJ)
+
+| Action                        | Shortcut                |
+| ----------------------------- | ----------------------- |
+| Search Everywhere             | `Shift Shift`           |
+| Find Action / Command Palette | `Cmd + Shift + A`       |
+| Go to File                    | `Cmd + Shift + O`       |
+| Go to Symbol / Class          | `Cmd + O`               |
+| Recent Files                  | `Cmd + E`               |
+| Go to Definition              | `Cmd + B`               |
+| Find Usages                   | `Alt + F7`              |
+| Rename Symbol                 | `Shift + F6`            |
+| Reformat Code                 | `Cmd + Alt + L`         |
+| Toggle Project Panel          | `Cmd + 1`               |
+| Toggle Terminal               | `Alt + F12`             |
+| Duplicate Line                | `Cmd + D`               |
+| Delete Line                   | `Cmd + Backspace`       |
+| Move Line Up/Down             | `Shift + Alt + Up/Down` |
+| Expand/Shrink Selection       | `Alt + Up/Down`         |
+| Comment Line                  | `Cmd + /`               |
+| Go Back / Forward             | `Cmd + [` / `Cmd + ]`   |
+| Toggle Breakpoint             | `Ctrl + F8`             |
+
+### Different Keybindings (IntelliJ → Zed)
+
+| Action                 | IntelliJ    | Zed (JetBrains keymap)   |
+| ---------------------- | ----------- | ------------------------ |
+| File Structure         | `Cmd + F12` | `Cmd + F12` (outline)    |
+| Navigate to Next Error | `F2`        | `F2`                     |
+| Run                    | `Ctrl + R`  | `Ctrl + Alt + R` (tasks) |
+| Debug                  | `Ctrl + D`  | `Alt + Shift + F9`       |
+| Stop                   | `Cmd + F2`  | `Ctrl + F2`              |
+
+### Unique to Zed
+
+| Action            | Shortcut                   | Notes                          |
+| ----------------- | -------------------------- | ------------------------------ |
+| Toggle Right Dock | `Cmd + R`                  | Assistant panel, notifications |
+| Split Panes       | `Cmd + K`, then arrow keys | Create splits in any direction |
+
+### How to Customize Keybindings
+
+- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`)
+- Run `Zed: Open Keymap Editor`
+
+This opens a list of all available bindings. You can override individual shortcuts or remove conflicts.
+
+Zed also supports key sequences (multi-key shortcuts).
+
+## Differences in User Interfaces
+
+### No Indexing
+
+If you've used IntelliJ on large projects, you know the wait: "Indexing..." can take anywhere from 30 seconds to 15 minutes depending on project size. IntelliJ builds a comprehensive index of your entire codebase to power its code intelligence, and it re-indexes when dependencies change or after builds.
+
+Zed doesn't index. You open a folder and start working immediately. File search and navigation work instantly regardless of project size.
+
+IntelliJ's index powers features like finding all usages across your entire codebase, understanding class hierarchies, and detecting dead code. Zed delegates this work to language servers, which may not analyze at the same depth.
+
+**How to adapt:**
+
+- For project-wide symbol search, use `Cmd+O` / Go to Symbol (relies on your language server)
+- For finding files by name, use `Cmd+Shift+O` / Go to File
+- For text search across files, use `Cmd+Shift+F`—this is fast even on large codebases
+- If you need deep static analysis for JVM code, consider running IntelliJ's inspections as a separate step or using standalone tools like Checkstyle, PMD, or SpotBugs
+
+### LSP vs. Native Language Intelligence
+
+IntelliJ has its own language analysis engine built from scratch for each supported language. For Java, Kotlin, and other JVM languages, this engine understands your code thoroughly: it resolves types, tracks data flow, knows about framework annotations, and offers dozens of specialized refactorings.
+
+Zed uses the Language Server Protocol (LSP) for code intelligence. Each language has its own server: `jdtls` for Java, `rust-analyzer` for Rust, and so on.
+
+For some languages, the LSP experience is excellent. TypeScript, Rust, and Go have mature language servers that provide fast, accurate completions, diagnostics, and refactorings. For JVM languages, the gap might be more noticeable. The Eclipse-based Java language server is capable, but it won't match IntelliJ's depth for things like:
+
+- Spring and Jakarta EE annotation processing
+- Complex refactorings (extract interface, pull members up, change signature with all callers)
+- Framework-aware inspections
+- Automatic import optimization with custom ordering rules
+
+**How to adapt:**
+
+- Use `Alt+Enter` for available code actions—the list will vary by language server
+- For Java, ensure `jdtls` is properly configured with your JDK path in settings
+
+### No Project Model
+
+IntelliJ manages projects through `.idea` folders containing XML configuration files, `.iml` module definitions, SDK assignments, and run configurations. This model enables IntelliJ to understand multi-module projects, manage dependencies automatically, and persist complex run/debug setups.
+
+Zed has no project model. A project is a folder. There's no wizard, no SDK selection screen, no module configuration.
+
+This means:
+
+- Build commands are manual. Zed doesn't detect Maven or Gradle projects.
+- Run configurations don't exist. You define tasks or use the terminal.
+- SDK management is external. Your language server uses whatever JDK is on your PATH.
+- There are no module boundaries. Zed sees folders, not project structure.
+
+**How to adapt:**
+
+- Create a `.zed/settings.json` in your project root for project-specific settings
+- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`):
+
+```json
+[
+  {
+    "label": "build",
+    "command": "./gradlew build"
+  },
+  {
+    "label": "run",
+    "command": "./gradlew bootRun"
+  },
+  {
+    "label": "test current file",
+    "command": "./gradlew test --tests $ZED_STEM"
+  }
+]
+```
+
+- Use `Ctrl+Alt+R` to run tasks quickly
+- Lean on your terminal (`Alt+F12`) for anything tasks don't cover
+- For multi-module projects, you can open each module as a separate Zed window, or open the root and navigate via file finder
+
+### No Framework Integration
+
+IntelliJ's value for enterprise Java development comes largely from its framework integration. Spring beans are understood and navigable. JPA entities get special treatment. Endpoints are indexed and searchable. Jakarta EE annotations modify how the IDE analyzes your code.
+
+Zed has none of this. The language server sees Java code as Java code, so it doesn't understand that `@Autowired` means something special or that this class is a REST controller.
+
+Similarly for other ecosystems: no Rails integration, no Django awareness, no Angular/React-specific tooling beyond what the TypeScript language server provides.
+
+**How to adapt:**
+
+- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find endpoint definitions, bean names, or annotation usages.
+- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context
+- For Spring Boot, keep the Actuator endpoints or a separate tool for understanding bean wiring
+- Consider using framework-specific CLI tools (Spring CLI, Rails generators) from Zed's terminal
+
+> **Tip:** For database work, pick up a dedicated tool like DataGrip, DBeaver, or TablePlus. Many developers who switch to Zed keep DataGrip around specifically for SQL—it integrates well with your existing JetBrains license.
+
+If your daily work depends heavily on framework-aware navigation and refactoring, you'll feel the gap. Zed works best when you're comfortable navigating code through search rather than specialized tooling, or when your language has strong LSP support that covers most of what you need.
+
+### Tool Windows vs. Docks
+
+IntelliJ organizes auxiliary views into numbered tool windows (Project = 1, Git = 9, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks":
+
+| IntelliJ Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) |
+| -------------------- | -------------- | --------------------------- |
+| Project (1)          | Project Panel  | `Cmd + 1`                   |
+| Git (9 or Cmd+0)     | Git Panel      | `Cmd + 0`                   |
+| Terminal (Alt+F12)   | Terminal Panel | `Alt + F12`                 |
+| Structure (7)        | Outline Panel  | `Cmd + 7`                   |
+| Problems (6)         | Diagnostics    | `Cmd + 6`                   |
+| Debug (5)            | Debug Panel    | `Cmd + 5`                   |
+
+Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings.
+
+> **Tip:** IntelliJ has an "Override IDE shortcuts" setting that lets terminal shortcuts like `Ctrl+Left/Right` work normally. In Zed, terminal keybindings are separate—check your keymap if familiar shortcuts aren't working in the terminal panel.
+
+### Debugging
+
+Both IntelliJ and Zed offer integrated debugging, but the experience differs:
+
+- Zed's debugger uses the Debug Adapter Protocol (DAP), supporting multiple languages
+- Set breakpoints with `Ctrl+F8`
+- Start debugging with `Alt+Shift+F9`
+- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out)
+- Continue execution with `F9`
+
+The Debug Panel (`Cmd+5`) shows variables, call stack, and breakpoints—similar to IntelliJ's Debug tool window.
+
+### Extensions vs. Plugins
+
+IntelliJ has a massive plugin ecosystem covering everything from language support to database tools to deployment integrations.
+
+Zed's extension ecosystem is smaller and more focused:
+
+- Language support and syntax highlighting
+- Themes
+- Slash commands for AI
+- Context servers
+
+Several features that require plugins in other editors are built into Zed:
+
+- Real-time collaboration with voice chat
+- AI coding assistance
+- Built-in terminal
+- Task runner
+- LSP-based code intelligence
+
+You won't find one-to-one replacements for every IntelliJ plugin, especially for framework-specific tools, database clients, or application server integrations. For those workflows, you may need to use external tools alongside Zed.
+
+## Collaboration in Zed vs. IntelliJ
+
+IntelliJ offers Code With Me as a separate plugin for collaboration. Zed has collaboration built into the core experience.
+
+- Open the Collab Panel in the left dock
+- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join
+- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly
+
+Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins.
+
+## Using AI in Zed
+
+If you're used to AI assistants in IntelliJ (like GitHub Copilot or JetBrains AI), Zed offers similar capabilities with more flexibility.
+
+### Configuring GitHub Copilot
+
+1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+2. Navigate to **AI → Edit Predictions**
+3. Click **Configure** next to "Configure Providers"
+4. Under **GitHub Copilot**, click **Sign in to GitHub**
+
+Once signed in, just start typing. Zed will offer suggestions inline for you to accept.
+
+### Additional AI Options
+
+To use other AI models in Zed, you have several options:
+
+- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html).
+- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed
+- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html)
+
+## Advanced Config and Productivity Tweaks
+
+Zed exposes advanced settings for power users who want to fine-tune their environment.
+
+Here are a few useful tweaks:
+
+**Format on Save:**
+
+```json
+"format_on_save": "on"
+```
+
+**Enable direnv support:**
+
+```json
+"load_direnv": "shell_hook"
+```
+
+**Configure language servers**: For Java development, you may want to configure the Java language server in your settings:
+
+```json
+{
+  "lsp": {
+    "jdtls": {
+      "settings": {
+        "java_home": "/path/to/jdk"
+      }
+    }
+  }
+}
+```
+
+## Next Steps
+
+Now that you're set up, here are some resources to help you get the most out of Zed:
+
+- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior
+- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap
+- [Tasks](../tasks.md) — Set up build and run commands for your projects
+- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion
+- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time
+- [Languages](../languages.md) — Language-specific setup guides, including Java and Kotlin

docs/src/migrate/pycharm.md 🔗

@@ -0,0 +1,438 @@
+# How to Migrate from PyCharm to Zed
+
+This guide covers how to set up Zed if you're coming from PyCharm, including keybindings, settings, and the differences you should expect.
+
+## Install Zed
+
+Zed is available on macOS, Windows, and Linux.
+
+For macOS, you can download it from zed.dev/download, or install via Homebrew:
+
+```sh
+brew install --cask zed
+```
+
+For Windows, download the installer from zed.dev/download, or install via winget:
+
+```sh
+winget install Zed.Zed
+```
+
+For most Linux users, the easiest way to install Zed is through our installation script:
+
+```sh
+curl -f https://zed.dev/install.sh | sh
+```
+
+After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using:
+`zed .`
+This opens the current directory in Zed.
+
+## Set Up the JetBrains Keymap
+
+If you're coming from PyCharm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime:
+
+1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+2. Search for `Base Keymap`
+3. Select `JetBrains`
+
+Or add this directly to your `settings.json`:
+
+```json
+{
+  "base_keymap": "JetBrains"
+}
+```
+
+This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action.
+
+## Set Up Editor Preferences
+
+You can configure settings manually in the Settings Editor.
+
+To edit your settings:
+
+1. `Cmd+,` to open the Settings Editor.
+2. Run `zed: open settings` in the Command Palette.
+
+Settings PyCharm users typically configure first:
+
+| Zed Setting             | What it does                                                                    |
+| ----------------------- | ------------------------------------------------------------------------------- |
+| `format_on_save`        | Auto-format when saving. Set to `"on"` to enable.                               |
+| `soft_wrap`             | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` |
+| `preferred_line_length` | Column width for wrapping and rulers. Default is 80, PEP 8 recommends 79.       |
+| `inlay_hints`           | Show parameter names and type hints inline, like PyCharm's hints.               |
+| `relative_line_numbers` | Useful if you're coming from IdeaVim.                                           |
+
+Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in PyCharm.
+
+> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line.
+
+## Open or Create a Project
+
+After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike PyCharm, there's no project configuration wizard, no interpreter selection dialog, and no project structure setup required.
+
+To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project.
+
+You can also launch Zed from the terminal inside any folder with:
+`zed .`
+
+Once inside a project:
+
+- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like PyCharm's "Recent Files")
+- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like PyCharm's "Search Everywhere")
+- Use `Cmd+O` to search for symbols (like PyCharm's "Go to Symbol")
+
+Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like PyCharm's Project tool window).
+
+## Differences in Keybindings
+
+If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to PyCharm.
+
+### Common Shared Keybindings
+
+| Action                        | Shortcut                |
+| ----------------------------- | ----------------------- |
+| Search Everywhere             | `Shift Shift`           |
+| Find Action / Command Palette | `Cmd + Shift + A`       |
+| Go to File                    | `Cmd + Shift + O`       |
+| Go to Symbol                  | `Cmd + O`               |
+| Recent Files                  | `Cmd + E`               |
+| Go to Definition              | `Cmd + B`               |
+| Find Usages                   | `Alt + F7`              |
+| Rename Symbol                 | `Shift + F6`            |
+| Reformat Code                 | `Cmd + Alt + L`         |
+| Toggle Project Panel          | `Cmd + 1`               |
+| Toggle Terminal               | `Alt + F12`             |
+| Duplicate Line                | `Cmd + D`               |
+| Delete Line                   | `Cmd + Backspace`       |
+| Move Line Up/Down             | `Shift + Alt + Up/Down` |
+| Expand/Shrink Selection       | `Alt + Up/Down`         |
+| Comment Line                  | `Cmd + /`               |
+| Go Back / Forward             | `Cmd + [` / `Cmd + ]`   |
+| Toggle Breakpoint             | `Ctrl + F8`             |
+
+### Different Keybindings (PyCharm → Zed)
+
+| Action                 | PyCharm     | Zed (JetBrains keymap)   |
+| ---------------------- | ----------- | ------------------------ |
+| File Structure         | `Cmd + F12` | `Cmd + F12` (outline)    |
+| Navigate to Next Error | `F2`        | `F2`                     |
+| Run                    | `Ctrl + R`  | `Ctrl + Alt + R` (tasks) |
+| Debug                  | `Ctrl + D`  | `Alt + Shift + F9`       |
+| Stop                   | `Cmd + F2`  | `Ctrl + F2`              |
+
+### Unique to Zed
+
+| Action            | Shortcut                   | Notes                          |
+| ----------------- | -------------------------- | ------------------------------ |
+| Toggle Right Dock | `Cmd + R`                  | Assistant panel, notifications |
+| Split Panes       | `Cmd + K`, then arrow keys | Create splits in any direction |
+
+### How to Customize Keybindings
+
+- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`)
+- Run `Zed: Open Keymap Editor`
+
+This opens a list of all available bindings. You can override individual shortcuts or remove conflicts.
+
+Zed also supports key sequences (multi-key shortcuts).
+
+## Differences in User Interfaces
+
+### No Indexing
+
+If you've used PyCharm on large projects, you know the wait: "Indexing..." can take anywhere from 30 seconds to several minutes depending on project size and dependencies. PyCharm builds a comprehensive index of your entire codebase to power its code intelligence, and it re-indexes when dependencies change or when you install new packages.
+
+Zed doesn't index. You open a folder and start working immediately. File search and navigation work instantly regardless of project size. For many PyCharm users, this alone is reason enough to switch—no more waiting, no more "Indexing paused" interruptions.
+
+PyCharm's index powers features like finding all usages across your entire codebase, understanding class hierarchies, and detecting unused imports project-wide. Zed delegates this work to language servers, which may not analyze as deeply or as broadly.
+
+**How to adapt:**
+
+- For project-wide symbol search, use `Cmd+O` / Go to Symbol (relies on your language server)
+- For finding files by name, use `Cmd+Shift+O` / Go to File
+- For text search across files, use `Cmd+Shift+F`—this is fast even on large codebases
+- For deep static analysis, consider running tools like `mypy`, `pylint`, or `ruff check` from the terminal
+
+### LSP vs. Native Language Intelligence
+
+PyCharm has its own language analysis engine built specifically for Python. This engine understands your code deeply: it resolves types without annotations, tracks data flow, knows about Django models and Flask routes, and offers specialized refactorings.
+
+Zed uses the Language Server Protocol (LSP) for code intelligence. For Python, Zed provides several language servers out of the box:
+
+- **basedpyright** (default) — Fast type checking and completions
+- **Ruff** (default) — Linting and formatting
+- **Ty** — Up-and-coming language server from Astral, built for speed
+- **Pyright** — Microsoft's type checker
+- **PyLSP** — Plugin-based server with tool integrations
+
+The LSP experience for Python is strong. basedpyright provides accurate completions, type checking, and navigation. Ruff handles formatting and linting with excellent performance.
+
+Where you might notice differences:
+
+- Framework-specific intelligence (Django ORM, Flask routes) isn't built-in
+- Some complex refactorings (extract method with proper scope analysis) may be less sophisticated
+- Auto-import suggestions depend on what the language server knows about your environment
+
+**How to adapt:**
+
+- Use `Alt+Enter` for available code actions—the list will vary by language server
+- Ensure your virtual environment is selected so the language server can resolve your dependencies
+- Use Ruff for fast, consistent formatting (it's enabled by default)
+- For code inspection similar to PyCharm's "Inspect Code," run `ruff check .` or check the Diagnostics panel (`Cmd+6`)—basedpyright and Ruff together catch many of the same issues
+
+### Virtual Environments and Interpreters
+
+In PyCharm, you select a Python interpreter through a GUI, and PyCharm manages the connection between your project and that interpreter. It shows available packages, lets you install new ones, and keeps track of which environment each project uses.
+
+Zed handles virtual environments through its toolchain system:
+
+- Zed automatically discovers virtual environments in common locations (`.venv`, `venv`, `.env`, `env`)
+- When a virtual environment is detected, the terminal auto-activates it
+- Language servers are automatically configured to use the discovered environment
+- You can manually select a toolchain if auto-detection picks the wrong one
+
+**How to adapt:**
+
+- Create your virtual environment with `python -m venv .venv` or `uv sync`
+- Open the folder in Zed—it will detect the environment automatically
+- If you need to switch environments, use the toolchain selector
+- For conda environments, ensure they're activated in your shell before launching Zed
+
+> **Tip:** If basedpyright shows import errors for packages you've installed, check that Zed has selected the correct virtual environment. Use the toolchain selector to verify or change the active environment.
+
+### No Project Model
+
+PyCharm manages projects through `.idea` folders containing XML configuration files, interpreter assignments, and run configurations. This model lets PyCharm remember your interpreter choice, manage dependencies through the UI, and persist complex run/debug setups.
+
+Zed has no project model. A project is a folder. There's no wizard, no interpreter selection screen, no project structure configuration.
+
+This means:
+
+- Run configurations don't exist. You define tasks or use the terminal. Your existing PyCharm run configs in `.idea/` won't be read—you'll recreate the ones you need in `tasks.json`.
+- Interpreter management is external. Zed discovers environments but doesn't create them.
+- Dependencies are managed through pip, uv, poetry, or conda—not through the editor.
+- There's no Python Console (interactive REPL) panel. Use `python` or `ipython` in the terminal instead.
+
+**How to adapt:**
+
+- Create a `.zed/settings.json` in your project root for project-specific settings
+- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`):
+
+```json
+[
+  {
+    "label": "run",
+    "command": "python main.py"
+  },
+  {
+    "label": "test",
+    "command": "pytest"
+  },
+  {
+    "label": "test current file",
+    "command": "pytest $ZED_FILE"
+  }
+]
+```
+
+- Use `Ctrl+Alt+R` to run tasks quickly
+- Lean on your terminal (`Alt+F12`) for anything tasks don't cover
+
+### No Framework Integration
+
+PyCharm Professional's value for web development comes largely from its framework integration. Django templates are understood and navigable. Flask routes are indexed. SQLAlchemy models get special treatment. Template variables autocomplete.
+
+Zed has none of this. The language server sees Python code as Python code—it doesn't understand that `@app.route` defines an endpoint or that a Django model class creates database tables.
+
+**How to adapt:**
+
+- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find route definitions, model classes, or template usages.
+- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context
+- Consider using framework-specific CLI tools (`python manage.py`, `flask routes`) from Zed's terminal
+
+> **Tip:** For database work, pick up a dedicated tool like DataGrip, DBeaver, or TablePlus. Many developers who switch to Zed keep DataGrip around specifically for SQL.
+
+### Tool Windows vs. Docks
+
+PyCharm organizes auxiliary views into numbered tool windows (Project = 1, Python Console = 4, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks":
+
+| PyCharm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) |
+| ------------------- | -------------- | --------------------------- |
+| Project (1)         | Project Panel  | `Cmd + 1`                   |
+| Git (9 or Cmd+0)    | Git Panel      | `Cmd + 0`                   |
+| Terminal (Alt+F12)  | Terminal Panel | `Alt + F12`                 |
+| Structure (7)       | Outline Panel  | `Cmd + 7`                   |
+| Problems (6)        | Diagnostics    | `Cmd + 6`                   |
+| Debug (5)           | Debug Panel    | `Cmd + 5`                   |
+
+Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings.
+
+### Debugging
+
+Both PyCharm and Zed offer integrated debugging, but the experience differs:
+
+- Zed uses `debugpy` (the same debug adapter that VS Code uses)
+- Set breakpoints with `Ctrl+F8`
+- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target
+- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out)
+- Continue execution with `F9`
+
+Zed can automatically detect debuggable entry points. Press `F4` to see available options, including:
+
+- Python scripts
+- Modules
+- pytest tests
+
+For more control, create a `.zed/debug.json` file:
+
+```json
+[
+  {
+    "label": "Debug Current File",
+    "adapter": "Debugpy",
+    "program": "$ZED_FILE",
+    "request": "launch"
+  },
+  {
+    "label": "Debug Flask App",
+    "adapter": "Debugpy",
+    "request": "launch",
+    "module": "flask",
+    "args": ["run", "--debug"],
+    "env": {
+      "FLASK_APP": "app.py"
+    }
+  }
+]
+```
+
+### Running Tests
+
+PyCharm has a dedicated test runner with a visual interface showing pass/fail status for each test. Zed provides test running through:
+
+- **Gutter icons** — Click the play button next to test functions or classes
+- **Tasks** — Define pytest or unittest commands in `tasks.json`
+- **Terminal** — Run `pytest` directly
+
+The test output appears in the terminal panel. For pytest, use `--tb=short` for concise tracebacks or `-v` for verbose output.
+
+### Extensions vs. Plugins
+
+PyCharm has a plugin ecosystem covering everything from additional language support to database tools to deployment integrations.
+
+Zed's extension ecosystem is smaller and more focused:
+
+- Language support and syntax highlighting
+- Themes
+- Slash commands for AI
+- Context servers
+
+Several features that require plugins in PyCharm are built into Zed:
+
+- Real-time collaboration with voice chat
+- AI coding assistance
+- Built-in terminal
+- Task runner
+- LSP-based code intelligence
+- Ruff formatting and linting
+
+### What's Not in Zed
+
+To set expectations clearly, here's what PyCharm offers that Zed doesn't have:
+
+- **Scientific Mode / Jupyter integration** — For notebooks and data science workflows, use JupyterLab or VS Code with the Jupyter extension alongside Zed for your Python editing
+- **Database tools** — Use DataGrip, DBeaver, or TablePlus
+- **Django/Flask template navigation** — Use file search and grep
+- **Visual package manager** — Use pip, uv, or poetry from the terminal
+- **Remote interpreters** — Zed has remote development, but it works differently
+- **Profiler integration** — Use cProfile, py-spy, or similar tools externally
+
+## Collaboration in Zed vs. PyCharm
+
+PyCharm offers Code With Me as a separate plugin for collaboration. Zed has collaboration built into the core experience.
+
+- Open the Collab Panel in the left dock
+- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join
+- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly
+
+Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins.
+
+## Using AI in Zed
+
+If you're used to AI assistants in PyCharm (like GitHub Copilot or JetBrains AI Assistant), Zed offers similar capabilities with more flexibility.
+
+### Configuring GitHub Copilot
+
+1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+2. Navigate to **AI → Edit Predictions**
+3. Click **Configure** next to "Configure Providers"
+4. Under **GitHub Copilot**, click **Sign in to GitHub**
+
+Once signed in, just start typing. Zed will offer suggestions inline for you to accept.
+
+### Additional AI Options
+
+To use other AI models in Zed, you have several options:
+
+- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html).
+- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed
+- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html)
+
+## Advanced Config and Productivity Tweaks
+
+Zed exposes advanced settings for power users who want to fine-tune their environment.
+
+Here are a few useful tweaks:
+
+**Format on Save:**
+
+```json
+"format_on_save": "on"
+```
+
+**Enable direnv support (useful for Python projects using direnv):**
+
+```json
+"load_direnv": "shell_hook"
+```
+
+**Customize virtual environment detection:**
+
+```json
+{
+  "terminal": {
+    "detect_venv": {
+      "on": {
+        "directories": [".venv", "venv", ".env", "env"],
+        "activate_script": "default"
+      }
+    }
+  }
+}
+```
+
+**Configure basedpyright type checking strictness:**
+
+If you find basedpyright too strict or too lenient, configure it in your project's `pyrightconfig.json`:
+
+```json
+{
+  "typeCheckingMode": "basic"
+}
+```
+
+Options are `"off"`, `"basic"`, `"standard"` (default), `"strict"`, or `"all"`.
+
+## Next Steps
+
+Now that you're set up, here are some resources to help you get the most out of Zed:
+
+- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior
+- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap
+- [Tasks](../tasks.md) — Set up build and run commands for your projects
+- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion
+- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time
+- [Python in Zed](../languages/python.md) — Python-specific setup and configuration

docs/src/migrate/rustrover.md 🔗

@@ -0,0 +1,501 @@
+# How to Migrate from RustRover to Zed
+
+This guide covers how to set up Zed if you're coming from RustRover, including keybindings, settings, and the differences you should expect as a Rust developer.
+
+## Install Zed
+
+Zed is available on macOS, Windows, and Linux.
+
+For macOS, you can download it from zed.dev/download, or install via Homebrew:
+
+```sh
+brew install --cask zed
+```
+
+For Windows, download the installer from zed.dev/download, or install via winget:
+
+```sh
+winget install Zed.Zed
+```
+
+For most Linux users, the easiest way to install Zed is through our installation script:
+
+```sh
+curl -f https://zed.dev/install.sh | sh
+```
+
+After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using:
+`zed .`
+This opens the current directory in Zed.
+
+## Set Up the JetBrains Keymap
+
+If you're coming from RustRover, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime:
+
+1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+2. Search for `Base Keymap`
+3. Select `JetBrains`
+
+Or add this directly to your `settings.json`:
+
+```json
+{
+  "base_keymap": "JetBrains"
+}
+```
+
+This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action.
+
+## Set Up Editor Preferences
+
+You can configure settings manually in the Settings Editor.
+
+To edit your settings:
+
+1. `Cmd+,` to open the Settings Editor.
+2. Run `zed: open settings` in the Command Palette.
+
+Settings RustRover users typically configure first:
+
+| Zed Setting             | What it does                                                                    |
+| ----------------------- | ------------------------------------------------------------------------------- |
+| `format_on_save`        | Auto-format when saving. Set to `"on"` to enable (uses rustfmt by default).     |
+| `soft_wrap`             | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` |
+| `preferred_line_length` | Column width for wrapping and rulers. Rust convention is 100.                   |
+| `inlay_hints`           | Show type hints, parameter names, and chaining hints inline.                    |
+| `relative_line_numbers` | Useful if you're coming from IdeaVim.                                           |
+
+Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in RustRover.
+
+> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line.
+
+## Open or Create a Project
+
+After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike RustRover, there's no project configuration wizard, no toolchain selection dialog, and no Cargo project setup screen.
+
+To start a new project, use Cargo from the terminal:
+
+```sh
+cargo new my_project
+cd my_project
+zed .
+```
+
+Or for a library:
+
+```sh
+cargo new --lib my_library
+```
+
+You can also launch Zed from the terminal inside any existing Cargo project with:
+`zed .`
+
+Once inside a project:
+
+- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like RustRover's "Recent Files")
+- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like RustRover's "Search Everywhere")
+- Use `Cmd+O` to search for symbols (like RustRover's "Go to Symbol")
+
+Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like RustRover's Project tool window).
+
+## Differences in Keybindings
+
+If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to RustRover.
+
+### Common Shared Keybindings
+
+| Action                        | Shortcut                |
+| ----------------------------- | ----------------------- |
+| Search Everywhere             | `Shift Shift`           |
+| Find Action / Command Palette | `Cmd + Shift + A`       |
+| Go to File                    | `Cmd + Shift + O`       |
+| Go to Symbol                  | `Cmd + O`               |
+| Recent Files                  | `Cmd + E`               |
+| Go to Definition              | `Cmd + B`               |
+| Find Usages                   | `Alt + F7`              |
+| Rename Symbol                 | `Shift + F6`            |
+| Reformat Code                 | `Cmd + Alt + L`         |
+| Toggle Project Panel          | `Cmd + 1`               |
+| Toggle Terminal               | `Alt + F12`             |
+| Duplicate Line                | `Cmd + D`               |
+| Delete Line                   | `Cmd + Backspace`       |
+| Move Line Up/Down             | `Shift + Alt + Up/Down` |
+| Expand/Shrink Selection       | `Alt + Up/Down`         |
+| Comment Line                  | `Cmd + /`               |
+| Go Back / Forward             | `Cmd + [` / `Cmd + ]`   |
+| Toggle Breakpoint             | `Ctrl + F8`             |
+
+### Different Keybindings (RustRover → Zed)
+
+| Action                 | RustRover   | Zed (JetBrains keymap)   |
+| ---------------------- | ----------- | ------------------------ |
+| File Structure         | `Cmd + F12` | `Cmd + F12` (outline)    |
+| Navigate to Next Error | `F2`        | `F2`                     |
+| Run                    | `Ctrl + R`  | `Ctrl + Alt + R` (tasks) |
+| Debug                  | `Ctrl + D`  | `Alt + Shift + F9`       |
+| Stop                   | `Cmd + F2`  | `Ctrl + F2`              |
+| Expand Macro           | `Alt+Enter` | `Cmd + Shift + M`        |
+
+### Unique to Zed
+
+| Action            | Shortcut                   | Notes                          |
+| ----------------- | -------------------------- | ------------------------------ |
+| Toggle Right Dock | `Cmd + R`                  | Assistant panel, notifications |
+| Split Panes       | `Cmd + K`, then arrow keys | Create splits in any direction |
+
+### How to Customize Keybindings
+
+- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`)
+- Run `Zed: Open Keymap Editor`
+
+This opens a list of all available bindings. You can override individual shortcuts or remove conflicts.
+
+Zed also supports key sequences (multi-key shortcuts).
+
+## Differences in User Interfaces
+
+### No Indexing
+
+RustRover indexes your project when you first open it to build a model of your codebase. This process runs whenever you open a project or when dependencies change via Cargo.
+
+Zed skips the indexing step. You open a folder and start working right away. Since both editors rely on rust-analyzer for Rust intelligence, the analysis still happens—but in Zed it runs in the background without blocking the UI or showing modal progress dialogs.
+
+**How to adapt:**
+
+- Use `Cmd+O` to search symbols across your crate (rust-analyzer handles this)
+- Jump to files by name with `Cmd+Shift+O`
+- `Cmd+Shift+F` gives you fast text search across the entire project
+- For linting and deeper checks, run `cargo clippy` in the terminal
+
+### rust-analyzer: Shared Foundation, Different Integration
+
+Here's what makes the RustRover-to-Zed transition unique: **both editors use rust-analyzer** for Rust language intelligence. This means the core code analysis—completions, go-to-definition, find references, type inference—is fundamentally the same.
+
+RustRover integrates rust-analyzer into its JetBrains platform, adding a GUI layer, additional refactorings, and its own indexing on top. Zed uses rust-analyzer more directly through the Language Server Protocol (LSP).
+
+What this means for you:
+
+- **Completions** — Same quality, powered by rust-analyzer
+- **Type inference** — Identical, it's the same engine
+- **Go to definition / Find usages** — Works the same way
+- **Macro expansion** — Available in both (use `Cmd+Shift+M` in Zed)
+- **Inlay hints** — Both support type hints, parameter hints, and chaining hints
+
+Where you might notice differences:
+
+- Some refactorings available in RustRover may not have rust-analyzer equivalents
+- RustRover's GUI for configuring rust-analyzer is replaced by JSON configuration in Zed
+- RustRover-specific inspections (beyond Clippy) won't exist in Zed
+
+**How to adapt:**
+
+- Use `Alt+Enter` for available code actions—rust-analyzer provides many
+- Configure rust-analyzer settings in `.zed/settings.json` for project-specific needs
+- Run `cargo clippy` for linting (it integrates with rust-analyzer diagnostics)
+
+### No Project Model
+
+RustRover manages projects through `.idea` folders containing XML configuration files, toolchain assignments, and run configurations. The Cargo tool window provides a visual interface for your project structure, targets, and dependencies.
+
+Zed keeps it simpler: a project is a folder with a `Cargo.toml`. No project wizard, no toolchain dialogs, no visual Cargo management layer.
+
+In practice:
+
+- Run configurations don't carry over. Your `.idea/` setup stays behind—define the commands you need in `tasks.json` instead.
+- Toolchains are managed externally via `rustup`.
+- Dependencies live in `Cargo.toml`. Edit the file directly; rust-analyzer provides completions for crate names and versions.
+
+**How to adapt:**
+
+- Create a `.zed/settings.json` in your project root for project-specific settings
+- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`):
+
+```json
+[
+  {
+    "label": "cargo run",
+    "command": "cargo run"
+  },
+  {
+    "label": "cargo build",
+    "command": "cargo build"
+  },
+  {
+    "label": "cargo test",
+    "command": "cargo test"
+  },
+  {
+    "label": "cargo clippy",
+    "command": "cargo clippy"
+  },
+  {
+    "label": "cargo run --release",
+    "command": "cargo run --release"
+  }
+]
+```
+
+- Use `Ctrl+Alt+R` to run tasks quickly
+- Lean on your terminal (`Alt+F12`) for anything tasks don't cover
+
+### No Cargo Integration UI
+
+RustRover's Cargo tool window provides visual access to your project's targets, dependencies, and common Cargo commands. You can run builds, tests, and benchmarks with a click.
+
+Zed doesn't have a Cargo GUI. You work with Cargo through:
+
+- **Terminal** — Run any Cargo command directly
+- **Tasks** — Define shortcuts for common commands
+- **Gutter icons** — Run tests and binaries with clickable icons
+
+**How to adapt:**
+
+- Get comfortable with Cargo CLI commands: `cargo build`, `cargo run`, `cargo test`, `cargo clippy`, `cargo doc`
+- Use tasks for commands you run frequently
+- For dependency management, edit `Cargo.toml` directly (rust-analyzer provides completions for crate names and versions)
+
+### Tool Windows vs. Docks
+
+RustRover organizes auxiliary views into numbered tool windows (Project = 1, Cargo = Alt+1, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks":
+
+| RustRover Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) |
+| --------------------- | -------------- | --------------------------- |
+| Project (1)           | Project Panel  | `Cmd + 1`                   |
+| Git (9 or Cmd+0)      | Git Panel      | `Cmd + 0`                   |
+| Terminal (Alt+F12)    | Terminal Panel | `Alt + F12`                 |
+| Structure (7)         | Outline Panel  | `Cmd + 7`                   |
+| Problems (6)          | Diagnostics    | `Cmd + 6`                   |
+| Debug (5)             | Debug Panel    | `Cmd + 5`                   |
+
+Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings.
+
+Note that there's no dedicated Cargo tool window in Zed. Use the terminal or define tasks for your common Cargo commands.
+
+### Debugging
+
+Both RustRover and Zed offer integrated debugging for Rust, but using different backends:
+
+- RustRover uses its own debugger integration
+- Zed uses **CodeLLDB** (the same debug adapter popular in VS Code)
+
+To debug Rust code in Zed:
+
+- Set breakpoints with `Ctrl+F8`
+- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target
+- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out)
+- Continue execution with `F9`
+
+Zed can automatically detect debuggable targets in your Cargo project. Press `F4` to see available options.
+
+For more control, create a `.zed/debug.json` file:
+
+```json
+[
+  {
+    "label": "Debug Binary",
+    "adapter": "CodeLLDB",
+    "request": "launch",
+    "program": "${workspaceFolder}/target/debug/my_project"
+  },
+  {
+    "label": "Debug Tests",
+    "adapter": "CodeLLDB",
+    "request": "launch",
+    "cargo": {
+      "args": ["test", "--no-run"],
+      "filter": {
+        "kind": "test"
+      }
+    }
+  },
+  {
+    "label": "Debug with Arguments",
+    "adapter": "CodeLLDB",
+    "request": "launch",
+    "program": "${workspaceFolder}/target/debug/my_project",
+    "args": ["--config", "dev.toml"]
+  }
+]
+```
+
+> **Note:** Some users have reported that RustRover's debugger can have issues with variable inspection and breakpoints in certain scenarios. CodeLLDB in Zed provides a solid alternative, though debugging Rust can be challenging in any editor due to optimizations and macro-generated code.
+
+### Running Tests
+
+RustRover has a dedicated test runner with a visual interface showing pass/fail status for each test. Zed provides test running through:
+
+- **Gutter icons** — Click the play button next to `#[test]` functions or test modules
+- **Tasks** — Define `cargo test` commands in `tasks.json`
+- **Terminal** — Run `cargo test` directly
+
+The test output appears in the terminal panel. For more detailed output, use:
+
+- `cargo test -- --nocapture` to see println! output
+- `cargo test -- --test-threads=1` for sequential test execution
+- `cargo test specific_test_name` to run a single test
+
+### Extensions vs. Plugins
+
+RustRover has a plugin ecosystem, though it's more limited than other JetBrains IDEs since Rust support is built-in.
+
+Zed's extension ecosystem is smaller and more focused:
+
+- Language support and syntax highlighting
+- Themes
+- Slash commands for AI
+- Context servers
+
+Several features that might require plugins in other editors are built into Zed:
+
+- Real-time collaboration with voice chat
+- AI coding assistance
+- Built-in terminal
+- Task runner
+- rust-analyzer integration
+- rustfmt formatting
+
+### What's Not in Zed
+
+To set expectations clearly, here's what RustRover offers that Zed doesn't have:
+
+- **Cargo.toml GUI editor** — Edit the file directly (rust-analyzer helps with completions)
+- **Visual dependency management** — Use `cargo add`, `cargo remove`, or edit `Cargo.toml`
+- **Profiler integration** — Use `cargo flamegraph`, `perf`, or external profiling tools
+- **Database tools** — Use DataGrip, DBeaver, or TablePlus
+- **HTTP Client** — Use tools like `curl`, `httpie`, or Postman
+- **Coverage visualization** — Use `cargo tarpaulin` or `cargo llvm-cov` externally
+
+## A Note on Licensing and Telemetry
+
+If you're moving from RustRover partly due to licensing concerns or telemetry policies, you should know:
+
+- **Zed is open source** (MIT licensed for the editor, AGPL for collaboration services)
+- **Telemetry is optional** and can be disabled during onboarding or in settings
+- **No license tiers**: All features are available to everyone
+
+## Collaboration in Zed vs. RustRover
+
+RustRover offers Code With Me as a separate feature for collaboration. Zed has collaboration built into the core experience.
+
+- Open the Collab Panel in the left dock
+- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join
+- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly
+
+Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins.
+
+## Using AI in Zed
+
+If you're used to AI assistants in RustRover (like JetBrains AI Assistant), Zed offers similar capabilities with more flexibility.
+
+### Configuring GitHub Copilot
+
+1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+2. Navigate to **AI → Edit Predictions**
+3. Click **Configure** next to "Configure Providers"
+4. Under **GitHub Copilot**, click **Sign in to GitHub**
+
+Once signed in, just start typing. Zed will offer suggestions inline for you to accept.
+
+### Additional AI Options
+
+To use other AI models in Zed, you have several options:
+
+- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html).
+- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed
+- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html)
+
+## Advanced Config and Productivity Tweaks
+
+Zed exposes advanced settings for power users who want to fine-tune their environment.
+
+Here are a few useful tweaks for Rust developers:
+
+**Format on Save (uses rustfmt by default):**
+
+```json
+"format_on_save": "on"
+```
+
+**Configure inlay hints for Rust:**
+
+```json
+{
+  "inlay_hints": {
+    "enabled": true,
+    "show_type_hints": true,
+    "show_parameter_hints": true,
+    "show_other_hints": true
+  }
+}
+```
+
+**Configure rust-analyzer settings:**
+
+```json
+{
+  "lsp": {
+    "rust-analyzer": {
+      "initialization_options": {
+        "checkOnSave": {
+          "command": "clippy"
+        },
+        "cargo": {
+          "allFeatures": true
+        },
+        "procMacro": {
+          "enable": true
+        }
+      }
+    }
+  }
+}
+```
+
+**Use a separate target directory for rust-analyzer (faster builds):**
+
+```json
+{
+  "lsp": {
+    "rust-analyzer": {
+      "initialization_options": {
+        "rust-analyzer.cargo.targetDir": true
+      }
+    }
+  }
+}
+```
+
+This tells rust-analyzer to use `target/rust-analyzer` instead of `target`, so IDE analysis doesn't conflict with your manual `cargo build` commands.
+
+**Enable direnv support (useful for Rust projects using direnv):**
+
+```json
+"load_direnv": "shell_hook"
+```
+
+**Configure linked projects for workspaces:**
+
+If you work with multiple Cargo projects that aren't in a workspace, you can tell rust-analyzer about them:
+
+```json
+{
+  "lsp": {
+    "rust-analyzer": {
+      "initialization_options": {
+        "linkedProjects": ["./project-a/Cargo.toml", "./project-b/Cargo.toml"]
+      }
+    }
+  }
+}
+```
+
+## Next Steps
+
+Now that you're set up, here are some resources to help you get the most out of Zed:
+
+- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior
+- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap
+- [Tasks](../tasks.md) — Set up build and run commands for your projects
+- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion
+- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time
+- [Rust in Zed](../languages/rust.md) — Rust-specific setup and configuration

docs/src/migrate/webstorm.md 🔗

@@ -0,0 +1,455 @@
+# How to Migrate from WebStorm to Zed
+
+This guide covers how to set up Zed if you're coming from WebStorm, including keybindings, settings, and the differences you should expect as a JavaScript/TypeScript developer.
+
+## Install Zed
+
+Zed is available on macOS, Windows, and Linux.
+
+For macOS, you can download it from zed.dev/download, or install via Homebrew:
+
+```sh
+brew install --cask zed
+```
+
+For Windows, download the installer from zed.dev/download, or install via winget:
+
+```sh
+winget install Zed.Zed
+```
+
+For most Linux users, the easiest way to install Zed is through our installation script:
+
+```sh
+curl -f https://zed.dev/install.sh | sh
+```
+
+After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using:
+`zed .`
+This opens the current directory in Zed.
+
+## Set Up the JetBrains Keymap
+
+If you're coming from WebStorm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime:
+
+1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+2. Search for `Base Keymap`
+3. Select `JetBrains`
+
+Or add this directly to your `settings.json`:
+
+```json
+{
+  "base_keymap": "JetBrains"
+}
+```
+
+This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action.
+
+## Set Up Editor Preferences
+
+You can configure settings manually in the Settings Editor.
+
+To edit your settings:
+
+1. `Cmd+,` to open the Settings Editor.
+2. Run `zed: open settings` in the Command Palette.
+
+Settings WebStorm users typically configure first:
+
+| Zed Setting             | What it does                                                                    |
+| ----------------------- | ------------------------------------------------------------------------------- |
+| `format_on_save`        | Auto-format when saving. Set to `"on"` to enable.                               |
+| `soft_wrap`             | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` |
+| `preferred_line_length` | Column width for wrapping and rulers. Default is 80.                            |
+| `inlay_hints`           | Show parameter names and type hints inline, like WebStorm's hints.              |
+| `relative_line_numbers` | Useful if you're coming from IdeaVim.                                           |
+
+Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in WebStorm.
+
+> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line.
+
+## Open or Create a Project
+
+After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required.
+
+To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. For new projects, you'd typically run `npm init`, `pnpm create`, or your framework's CLI tool first, then open the resulting folder in Zed.
+
+You can also launch Zed from the terminal inside any folder with:
+`zed .`
+
+Once inside a project:
+
+- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like WebStorm's "Recent Files")
+- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like WebStorm's "Search Everywhere")
+- Use `Cmd+O` to search for symbols (like WebStorm's "Go to Symbol")
+
+Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like WebStorm's Project tool window).
+
+## Differences in Keybindings
+
+If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to WebStorm.
+
+### Common Shared Keybindings
+
+| Action                        | Shortcut                |
+| ----------------------------- | ----------------------- |
+| Search Everywhere             | `Shift Shift`           |
+| Find Action / Command Palette | `Cmd + Shift + A`       |
+| Go to File                    | `Cmd + Shift + O`       |
+| Go to Symbol                  | `Cmd + O`               |
+| Recent Files                  | `Cmd + E`               |
+| Go to Definition              | `Cmd + B`               |
+| Find Usages                   | `Alt + F7`              |
+| Rename Symbol                 | `Shift + F6`            |
+| Reformat Code                 | `Cmd + Alt + L`         |
+| Toggle Project Panel          | `Cmd + 1`               |
+| Toggle Terminal               | `Alt + F12`             |
+| Duplicate Line                | `Cmd + D`               |
+| Delete Line                   | `Cmd + Backspace`       |
+| Move Line Up/Down             | `Shift + Alt + Up/Down` |
+| Expand/Shrink Selection       | `Alt + Up/Down`         |
+| Comment Line                  | `Cmd + /`               |
+| Go Back / Forward             | `Cmd + [` / `Cmd + ]`   |
+| Toggle Breakpoint             | `Ctrl + F8`             |
+
+### Different Keybindings (WebStorm → Zed)
+
+| Action                 | WebStorm    | Zed (JetBrains keymap)   |
+| ---------------------- | ----------- | ------------------------ |
+| File Structure         | `Cmd + F12` | `Cmd + F12` (outline)    |
+| Navigate to Next Error | `F2`        | `F2`                     |
+| Run                    | `Ctrl + R`  | `Ctrl + Alt + R` (tasks) |
+| Debug                  | `Ctrl + D`  | `Alt + Shift + F9`       |
+| Stop                   | `Cmd + F2`  | `Ctrl + F2`              |
+
+### Unique to Zed
+
+| Action            | Shortcut                   | Notes                          |
+| ----------------- | -------------------------- | ------------------------------ |
+| Toggle Right Dock | `Cmd + R`                  | Assistant panel, notifications |
+| Split Panes       | `Cmd + K`, then arrow keys | Create splits in any direction |
+
+### How to Customize Keybindings
+
+- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`)
+- Run `Zed: Open Keymap Editor`
+
+This opens a list of all available bindings. You can override individual shortcuts or remove conflicts.
+
+Zed also supports key sequences (multi-key shortcuts).
+
+## Differences in User Interfaces
+
+### No Indexing
+
+If you've used WebStorm on large projects, you know the wait. Opening a project with many dependencies can mean watching "Indexing..." for anywhere from 30 seconds to several minutes. WebStorm indexes your entire codebase and `node_modules` to power its code intelligence, and re-indexes when dependencies change.
+
+Zed doesn't index. You open a folder and start coding immediately—no progress bars, no "Indexing paused" banners. File search and navigation stay fast regardless of project size or how many `node_modules` dependencies you have.
+
+WebStorm's index enables features like finding all usages across your entire codebase, tracking import hierarchies, and flagging unused exports project-wide. Zed relies on language servers for this analysis, which may not cover as much ground.
+
+**How to adapt:**
+
+- Search symbols across the project with `Cmd+O` (powered by the TypeScript language server)
+- Find files by name with `Cmd+Shift+O`
+- Use `Cmd+Shift+F` for text search—it stays fast even in large monorepos
+- Run `tsc --noEmit` or `eslint .` from the terminal when you need deeper project-wide analysis
+
+### LSP vs. Native Language Intelligence
+
+WebStorm has its own JavaScript and TypeScript analysis engine built by JetBrains. This engine understands your code deeply: it resolves types, tracks data flow, knows about framework-specific patterns, and offers specialized refactorings.
+
+Zed uses the Language Server Protocol (LSP) for code intelligence. For JavaScript and TypeScript, Zed supports:
+
+- **vtsls** (default) — Fast TypeScript language server with excellent performance
+- **typescript-language-server** — The standard TypeScript LSP implementation
+- **ESLint** — Linting integration
+- **Prettier** — Code formatting (built-in)
+
+The TypeScript LSP experience is mature and robust. You get accurate completions, type checking, go-to-definition, and find-references. The experience is comparable to VS Code, which uses the same underlying TypeScript services.
+
+Where you might notice differences:
+
+- Framework-specific intelligence (Angular templates, Vue SFCs) may be less integrated
+- Some complex refactorings (extract component with proper imports) may be less sophisticated
+- Auto-import suggestions depend on what the language server knows about your project
+
+**How to adapt:**
+
+- Use `Alt+Enter` for available code actions—the list will vary by language server
+- Ensure your `tsconfig.json` is properly configured so the language server understands your project structure
+- Use Prettier for consistent formatting (it's enabled by default for JS/TS)
+- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel (`Cmd+6`)—ESLint and TypeScript together catch many of the same issues
+
+### No Project Model
+
+WebStorm manages projects through `.idea` folders containing XML configuration files, framework detection, and run configurations. This model lets WebStorm remember your project settings, manage npm scripts through the UI, and persist run/debug setups.
+
+Zed takes a different approach: a project is just a folder. There's no setup wizard, no framework detection dialog, no project structure to configure.
+
+What this means in practice:
+
+- Run configurations aren't a thing. Define reusable commands in `tasks.json` instead. Note that your existing `.idea/` configurations won't carry over—you'll set up the ones you need fresh.
+- npm scripts live in the terminal. Run `npm run dev`, `pnpm build`, or `yarn test` directly—there's no dedicated npm panel.
+- No framework detection. Zed treats React, Angular, Vue, and vanilla JS/TS the same way.
+
+**How to adapt:**
+
+- Create a `.zed/settings.json` in your project root for project-specific settings
+- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`):
+
+```json
+[
+  {
+    "label": "dev",
+    "command": "npm run dev"
+  },
+  {
+    "label": "build",
+    "command": "npm run build"
+  },
+  {
+    "label": "test",
+    "command": "npm test"
+  },
+  {
+    "label": "test current file",
+    "command": "npm test -- $ZED_FILE"
+  }
+]
+```
+
+- Use `Ctrl+Alt+R` to run tasks quickly
+- Lean on your terminal (`Alt+F12`) for anything tasks don't cover
+
+### No Framework Integration
+
+WebStorm's value for web development comes largely from its framework integration. React components get special treatment. Angular has dedicated tooling. Vue single-file components are fully understood. The npm tool window shows all your scripts.
+
+Zed has none of this built-in. The TypeScript language server sees your code as TypeScript—it doesn't understand that a function is a React component or that a file is an Angular service.
+
+**How to adapt:**
+
+- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find component definitions, route configurations, or API endpoints.
+- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context
+- Consider using framework-specific CLI tools (`ng`, `next`, `vite`) from Zed's terminal
+- For React, JSX/TSX syntax and TypeScript types still provide good intelligence
+
+> **Tip:** For projects with complex configurations, keep your framework's documentation handy. Zed's speed comes with less hand-holding for framework-specific features.
+
+### Tool Windows vs. Docks
+
+WebStorm organizes auxiliary views into numbered tool windows (Project = 1, npm = Alt+F11, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks":
+
+| WebStorm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) |
+| -------------------- | -------------- | --------------------------- |
+| Project (1)          | Project Panel  | `Cmd + 1`                   |
+| Git (9 or Cmd+0)     | Git Panel      | `Cmd + 0`                   |
+| Terminal (Alt+F12)   | Terminal Panel | `Alt + F12`                 |
+| Structure (7)        | Outline Panel  | `Cmd + 7`                   |
+| Problems (6)         | Diagnostics    | `Cmd + 6`                   |
+| Debug (5)            | Debug Panel    | `Cmd + 5`                   |
+
+Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings.
+
+Note that there's no dedicated npm tool window in Zed. Use the terminal or define tasks for your common npm scripts.
+
+### Debugging
+
+Both WebStorm and Zed offer integrated debugging for JavaScript and TypeScript:
+
+- Zed uses `vscode-js-debug` (the same debug adapter that VS Code uses)
+- Set breakpoints with `Ctrl+F8`
+- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target
+- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out)
+- Continue execution with `F9`
+
+Zed can debug:
+
+- Node.js applications and scripts
+- Chrome/browser JavaScript
+- Jest, Mocha, Vitest, and other test frameworks
+- Next.js (both server and client-side)
+
+For more control, create a `.zed/debug.json` file:
+
+```json
+[
+  {
+    "label": "Debug Current File",
+    "adapter": "JavaScript",
+    "program": "$ZED_FILE",
+    "request": "launch"
+  },
+  {
+    "label": "Debug Node Server",
+    "adapter": "JavaScript",
+    "request": "launch",
+    "program": "${workspaceFolder}/src/server.js"
+  },
+  {
+    "label": "Attach to Chrome",
+    "adapter": "JavaScript",
+    "request": "attach",
+    "port": 9222
+  }
+]
+```
+
+Zed also recognizes `.vscode/launch.json` configurations, so existing VS Code debug setups often work out of the box.
+
+### Running Tests
+
+WebStorm has a dedicated test runner with a visual interface showing pass/fail status for each test. Zed provides test running through:
+
+- **Gutter icons** — Click the play button next to test functions or describe blocks
+- **Tasks** — Define test commands in `tasks.json`
+- **Terminal** — Run `npm test`, `jest`, `vitest`, etc. directly
+
+Zed supports auto-detection for common test frameworks:
+
+- Jest
+- Mocha
+- Vitest
+- Jasmine
+- Bun test
+- Node.js test runner
+
+The test output appears in the terminal panel. For Jest, use `--verbose` for detailed output or `--watch` for continuous testing during development.
+
+### Extensions vs. Plugins
+
+WebStorm has a plugin ecosystem covering additional language support, themes, and tool integrations.
+
+Zed's extension ecosystem is smaller and more focused:
+
+- Language support and syntax highlighting
+- Themes
+- Slash commands for AI
+- Context servers
+
+Several features that require plugins in WebStorm are built into Zed:
+
+- Real-time collaboration with voice chat
+- AI coding assistance
+- Built-in terminal
+- Task runner
+- LSP-based code intelligence
+- Prettier formatting
+- ESLint integration
+
+### What's Not in Zed
+
+To set expectations clearly, here's what WebStorm offers that Zed doesn't have:
+
+- **npm tool window** — Use the terminal or tasks instead
+- **HTTP Client** — Use tools like Postman, Insomnia, or curl
+- **Database tools** — Use DataGrip, DBeaver, or TablePlus
+- **Framework-specific tooling** (Angular schematics, React refactorings) — Use CLI tools
+- **Visual package.json editor** — Edit the file directly
+- **Built-in REST client** — Use external tools or extensions
+- **Profiler integration** — Use Chrome DevTools or Node.js profiling tools
+
+## Collaboration in Zed vs. WebStorm
+
+WebStorm offers Code With Me as a separate feature for collaboration. Zed has collaboration built into the core experience.
+
+- Open the Collab Panel in the left dock
+- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join
+- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly
+
+Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins.
+
+## Using AI in Zed
+
+If you're used to AI assistants in WebStorm (like GitHub Copilot, JetBrains AI Assistant, or Junie), Zed offers similar capabilities with more flexibility.
+
+### Configuring GitHub Copilot
+
+1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+2. Navigate to **AI → Edit Predictions**
+3. Click **Configure** next to "Configure Providers"
+4. Under **GitHub Copilot**, click **Sign in to GitHub**
+
+Once signed in, just start typing. Zed will offer suggestions inline for you to accept.
+
+### Additional AI Options
+
+To use other AI models in Zed, you have several options:
+
+- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html).
+- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed
+- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html)
+
+## Advanced Config and Productivity Tweaks
+
+Zed exposes advanced settings for power users who want to fine-tune their environment.
+
+Here are a few useful tweaks for JavaScript/TypeScript developers:
+
+**Format on Save:**
+
+```json
+"format_on_save": "on"
+```
+
+**Configure Prettier as the default formatter:**
+
+```json
+{
+  "formatter": {
+    "external": {
+      "command": "prettier",
+      "arguments": ["--stdin-filepath", "{buffer_path}"]
+    }
+  }
+}
+```
+
+**Enable ESLint code actions:**
+
+```json
+{
+  "lsp": {
+    "eslint": {
+      "settings": {
+        "codeActionOnSave": {
+          "rules": ["import/order"]
+        }
+      }
+    }
+  }
+}
+```
+
+**Configure TypeScript strict mode hints:**
+
+In your `tsconfig.json`, enable strict mode for better type checking:
+
+```json
+{
+  "compilerOptions": {
+    "strict": true,
+    "noUncheckedIndexedAccess": true
+  }
+}
+```
+
+**Enable direnv support (useful for projects using direnv for environment variables):**
+
+```json
+"load_direnv": "shell_hook"
+```
+
+## Next Steps
+
+Now that you're set up, here are some resources to help you get the most out of Zed:
+
+- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior
+- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap
+- [Tasks](../tasks.md) — Set up build and run commands for your projects
+- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion
+- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time
+- [JavaScript in Zed](../languages/javascript.md) — JavaScript-specific setup and configuration
+- [TypeScript in Zed](../languages/typescript.md) — TypeScript-specific setup and configuration

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.

docs/src/worktree-trust.md 🔗

@@ -0,0 +1,58 @@
+# Zed and trusted worktrees
+
+A worktree in Zed is either a directory or a single file that Zed opens as a standalone "project".
+Zed opens a worktree every time `zed some/path` is invoked, on drag and dropping a file or directory into Zed, on opening user settings.json, etc.
+
+Every worktree opened may contain a `.zed/settings.json` file with extra configuration options that may require installing and spawning language servers or MCP servers.
+In order to provide users the opportunity to make their own choices according to their unique threat model and risk tolerance, all worktrees will be started in Restricted mode, which prevents download and execution of any related items from `.zed/settings.json`. Until configured to trust the worktree(s), Zed will not perform any related untrusted actions and will wait for user confirmation. This gives users a chance to review and understand any pre-configured settings, MCP servers, or language servers associated with a project.
+
+Note that at this point, Zed trusts the tools it installs itself, hence global entities such as global MCP servers, language servers like prettier and copilot are still in installed and started as usual, independent of worktree trust.
+
+If a worktree is not trusted, Zed will indicate this with an exclamation mark icon in the title bar. Clicking this icon or using `workspace::ToggleWorktreeSecurity` action will bring up the security modal that allows the user to trust the worktree.
+
+Trusting any worktree will persist this information between restarts. It's possible to clear all trusted worktrees with `workspace::ClearTrustedWorktrees` command.
+This command will restart Zed, to ensure no untrusted settings, language servers or MCP servers persist.
+
+This feature works locally and on SSH and WSL remote hosts. Zed tracks trust information per host in these cases.
+
+## What is restricted
+
+Restricted Mode prevents:
+
+- Project settings (`.zed/settings.json`) from being parsed and applied
+- Language servers from being installed and spawned
+- MCP servers from being installed and spawned
+
+## Configuring broad worktree trust
+
+By default, Zed won't trust any new worktrees and users will be required to trust each new worktree. Though not recommended, users may elect to trust all worktrees by configuring the following setting:
+
+```json [settings]
+"session": {
+  "trust_all_worktrees": true
+}
+```
+
+Note that auto trusted worktrees are not persisted between restarts, only manually trusted worktrees are. This ensures that new trust decisions must be made if a users elects to disable the `trust_all_worktrees` setting.
+
+## Trust hierarchy
+
+These are mostly internal details and may change in the future, but are helpful to understand how multiple different trust requests can be approved at once.
+Zed has multiple layers of trust, based on the requests, from the least to most trusted level:
+
+- "single file worktree"
+
+After opening an empty Zed it's possible to open just a file, same as after opening a directory in Zed it's possible to open a file outside of this directory.
+A typical scenario where a directory might be open and a single file is subsequently opened is opening Zed's settings.json file via `zed: open settings file` command: that starts a language server for a new file open, which originates from a newly created, single file worktree.
+
+Spawning a language server presents a risk should the language server experience a supply-chain attack; therefore, Zed restricts that by default. Each single file worktree requires a separate trust grant, unless the directory containing it is trusted or all worktrees are trusted.
+
+- "directory worktree"
+
+If a directory is open in Zed, it's a full worktree which may spawn multiple language servers associated with it or spawn MCP servers if contained in a project settings file.Therefore, each directory worktree requires a separate trust grant unless a parent directory worktree trust is granted (see below).
+
+When a directory worktree is trusted, language and MCP servers are permitted to be downloaded and started, hence we also enable single file worktree trust for the host in question automatically when this occurs: this helps when opening single files when using language server features in the trusted directory worktree.
+
+- "parent directory worktree"
+
+To permit trust decisions for multiple directory worktrees at once, it's possible to trust all subdirectories of a given parent directory worktree opened in Zed by checking the appropriate checkbox. This will grant trust to all its subdirectories, including all current and potential directory worktrees.

flake.lock 🔗

@@ -2,11 +2,11 @@
   "nodes": {
     "crane": {
       "locked": {
-        "lastModified": 1762538466,
-        "narHash": "sha256-8zrIPl6J+wLm9MH5ksHcW7BUHo7jSNOu0/hA0ohOOaM=",
+        "lastModified": 1765145449,
+        "narHash": "sha256-aBVHGWWRzSpfL++LubA0CwOOQ64WNLegrYHwsVuVN7A=",
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "0cea393fffb39575c46b7a0318386467272182fe",
+        "rev": "69f538cdce5955fcd47abfed4395dc6d5194c1c5",
         "type": "github"
       },
       "original": {
@@ -17,11 +17,11 @@
     },
     "flake-compat": {
       "locked": {
-        "lastModified": 1761588595,
-        "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
+        "lastModified": 1765121682,
+        "narHash": "sha256-4VBOP18BFeiPkyhy9o4ssBNQEvfvv1kXkasAYd0+rrA=",
         "owner": "edolstra",
         "repo": "flake-compat",
-        "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
+        "rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3",
         "type": "github"
       },
       "original": {
@@ -32,11 +32,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 315532800,
-        "narHash": "sha256-5CwQ80ucRHiqVbMEEbTFnjz70/axSJ0aliyzSaFSkmY=",
-        "rev": "f6b44b2401525650256b977063dbcf830f762369",
+        "lastModified": 1765772535,
+        "narHash": "sha256-I715zWsdVZ+CipmLtoCAeNG0etQywiWRE5PaWntnaYk=",
+        "rev": "09b8fda8959d761445f12b55f380d90375a1d6bb",
         "type": "tarball",
-        "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre891648.f6b44b240152/nixexprs.tar.xz"
+        "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre911985.09b8fda8959d/nixexprs.tar.xz"
       },
       "original": {
         "type": "tarball",
@@ -53,16 +53,14 @@
     },
     "rust-overlay": {
       "inputs": {
-        "nixpkgs": [
-          "nixpkgs"
-        ]
+        "nixpkgs": ["nixpkgs"]
       },
       "locked": {
-        "lastModified": 1762915112,
-        "narHash": "sha256-d9j1g8nKmYDHy+/bIOPQTh9IwjRliqaTM0QLHMV92Ic=",
+        "lastModified": 1765465581,
+        "narHash": "sha256-fCXT0aZXmTalM3NPCTedVs9xb0egBG5BOZkcrYo5PGE=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "aa1e85921cfa04de7b6914982a94621fbec5cc02",
+        "rev": "99cc5667eece98bb35dcf35f7e511031a8b7a125",
         "type": "github"
       },
       "original": {

flake.nix 🔗

@@ -37,14 +37,14 @@
           rustToolchain = rustBin.fromRustupToolchainFile ./rust-toolchain.toml;
         };
     in
-    rec {
+    {
       packages = forAllSystems (pkgs: rec {
         default = mkZed pkgs;
         debug = default.override { profile = "dev"; };
       });
       devShells = forAllSystems (pkgs: {
         default = pkgs.callPackage ./nix/shell.nix {
-          zed-editor = packages.${pkgs.hostPlatform.system}.default;
+          zed-editor = mkZed pkgs;
         };
       });
       formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style);

nix/build.nix 🔗

@@ -83,70 +83,94 @@ let
 
       cargoLock = ../Cargo.lock;
 
-      nativeBuildInputs =
-        [
-          cmake
-          copyDesktopItems
-          curl
-          perl
-          pkg-config
-          protobuf
-          cargo-about
-          rustPlatform.bindgenHook
-        ]
-        ++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ]
-        ++ lib.optionals stdenv'.hostPlatform.isDarwin [
-          (cargo-bundle.overrideAttrs (
-            new: old: {
-              version = "0.6.1-zed";
-              src = fetchFromGitHub {
-                owner = "zed-industries";
-                repo = "cargo-bundle";
-                rev = "2be2669972dff3ddd4daf89a2cb29d2d06cad7c7";
-                hash = "sha256-cSvW0ND148AGdIGWg/ku0yIacVgW+9f1Nsi+kAQxVrI=";
-              };
-              cargoHash = "sha256-urn+A3yuw2uAO4HGmvQnKvWtHqvG9KHxNCCWTiytE4k=";
-
-              # NOTE: can drop once upstream uses `finalAttrs` here:
-              # https://github.com/NixOS/nixpkgs/blob/10214747f5e6e7cb5b9bdf9e018a3c7b3032f5af/pkgs/build-support/rust/build-rust-package/default.nix#L104
-              #
-              # See (for context): https://github.com/NixOS/nixpkgs/pull/382550
-              cargoDeps = rustPlatform.fetchCargoVendor {
-                inherit (new) src;
-                hash = new.cargoHash;
-                patches = new.cargoPatches or [];
-                name = new.cargoDepsName or new.finalPackage.name;
-              };
-            }
-          ))
-        ];
-
-      buildInputs =
-        [
-          curl
-          fontconfig
-          freetype
-          # TODO: need staticlib of this for linking the musl remote server.
-          # should make it a separate derivation/flake output
-          # see https://crane.dev/examples/cross-musl.html
-          libgit2
-          openssl
-          sqlite
-          zlib
-          zstd
-        ]
-        ++ lib.optionals stdenv'.hostPlatform.isLinux [
-          alsa-lib
-          libxkbcommon
-          wayland
-          gpu-lib
-          xorg.libX11
-          xorg.libxcb
-        ]
-        ++ lib.optionals stdenv'.hostPlatform.isDarwin [
-          apple-sdk_15
-          (darwinMinVersionHook "10.15")
-        ];
+      nativeBuildInputs = [
+        cmake
+        copyDesktopItems
+        curl
+        perl
+        pkg-config
+        protobuf
+        # Pin cargo-about to 0.8.2. Newer versions don't work with the current license identifiers
+        # See https://github.com/zed-industries/zed/pull/44012
+        (cargo-about.overrideAttrs (
+          new: old: rec {
+            version = "0.8.2";
+
+            src = fetchFromGitHub {
+              owner = "EmbarkStudios";
+              repo = "cargo-about";
+              tag = version;
+              sha256 = "sha256-cNKZpDlfqEXeOE5lmu79AcKOawkPpk4PQCsBzNtIEbs=";
+            };
+
+            cargoHash = "sha256-NnocSs6UkuF/mCM3lIdFk+r51Iz2bHuYzMT/gEbT/nk=";
+
+            # NOTE: can drop once upstream uses `finalAttrs` here:
+            # https://github.com/NixOS/nixpkgs/blob/10214747f5e6e7cb5b9bdf9e018a3c7b3032f5af/pkgs/build-support/rust/build-rust-package/default.nix#L104
+            #
+            # See (for context): https://github.com/NixOS/nixpkgs/pull/382550
+            cargoDeps = rustPlatform.fetchCargoVendor {
+              inherit (new) src;
+              hash = new.cargoHash;
+              patches = new.cargoPatches or [ ];
+              name = new.cargoDepsName or new.finalPackage.name;
+            };
+          }
+        ))
+        rustPlatform.bindgenHook
+      ]
+      ++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ]
+      ++ lib.optionals stdenv'.hostPlatform.isDarwin [
+        (cargo-bundle.overrideAttrs (
+          new: old: {
+            version = "0.6.1-zed";
+            src = fetchFromGitHub {
+              owner = "zed-industries";
+              repo = "cargo-bundle";
+              rev = "2be2669972dff3ddd4daf89a2cb29d2d06cad7c7";
+              hash = "sha256-cSvW0ND148AGdIGWg/ku0yIacVgW+9f1Nsi+kAQxVrI=";
+            };
+            cargoHash = "sha256-urn+A3yuw2uAO4HGmvQnKvWtHqvG9KHxNCCWTiytE4k=";
+
+            # NOTE: can drop once upstream uses `finalAttrs` here:
+            # https://github.com/NixOS/nixpkgs/blob/10214747f5e6e7cb5b9bdf9e018a3c7b3032f5af/pkgs/build-support/rust/build-rust-package/default.nix#L104
+            #
+            # See (for context): https://github.com/NixOS/nixpkgs/pull/382550
+            cargoDeps = rustPlatform.fetchCargoVendor {
+              inherit (new) src;
+              hash = new.cargoHash;
+              patches = new.cargoPatches or [ ];
+              name = new.cargoDepsName or new.finalPackage.name;
+            };
+          }
+        ))
+      ];
+
+      buildInputs = [
+        curl
+        fontconfig
+        freetype
+        # TODO: need staticlib of this for linking the musl remote server.
+        # should make it a separate derivation/flake output
+        # see https://crane.dev/examples/cross-musl.html
+        libgit2
+        openssl
+        sqlite
+        zlib
+        zstd
+      ]
+      ++ lib.optionals stdenv'.hostPlatform.isLinux [
+        alsa-lib
+        libxkbcommon
+        wayland
+        gpu-lib
+        xorg.libX11
+        xorg.libxcb
+      ]
+      ++ lib.optionals stdenv'.hostPlatform.isDarwin [
+        apple-sdk_15
+        (darwinMinVersionHook "10.15")
+      ];
 
       cargoExtraArgs = "-p zed -p cli --locked --features=gpui/runtime_shaders";
 
@@ -177,7 +201,7 @@ let
         ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled.";
         RELEASE_VERSION = version;
         LK_CUSTOM_WEBRTC = livekit-libwebrtc;
-        PROTOC="${protobuf}/bin/protoc";
+        PROTOC = "${protobuf}/bin/protoc";
 
         CARGO_PROFILE = profile;
         # need to handle some profiles specially https://github.com/rust-lang/cargo/issues/11053
@@ -217,14 +241,13 @@ let
             # `webrtc-sys` expects a staticlib; nixpkgs' `livekit-webrtc` has been patched to
             # produce a `dylib`... patching `webrtc-sys`'s build script is the easier option
             # TODO: send livekit sdk a PR to make this configurable
-            postPatch =
-              ''
-                substituteInPlace webrtc-sys/build.rs --replace-fail \
-                  "cargo:rustc-link-lib=static=webrtc" "cargo:rustc-link-lib=dylib=webrtc"
-              ''
-              + lib.optionalString withGLES ''
-                cat ${glesConfig} >> .cargo/config/config.toml
-              '';
+            postPatch = ''
+              substituteInPlace webrtc-sys/build.rs --replace-fail \
+                "cargo:rustc-link-lib=static=webrtc" "cargo:rustc-link-lib=dylib=webrtc"
+            ''
+            + lib.optionalString withGLES ''
+              cat ${glesConfig} >> .cargo/config/config.toml
+            '';
           in
           crates: drv:
           if hasWebRtcSys crates then

rust-toolchain.toml 🔗

@@ -1,5 +1,5 @@
 [toolchain]
-channel = "1.91.1"
+channel = "1.92"
 profile = "minimal"
 components = [ "rustfmt", "clippy" ]
 targets = [

script/bundle-mac 🔗

@@ -106,6 +106,17 @@ mv Cargo.toml.backup Cargo.toml
 popd
 echo "Bundled ${app_path}"
 
+# DocumentTypes.plist references CFBundleTypeIconFile "Document", so the bundle must contain Document.icns.
+# We use the app icon as a placeholder document icon for now.
+document_icon_source="crates/zed/resources/Document.icns"
+document_icon_target="${app_path}/Contents/Resources/Document.icns"
+if [[ -f "${document_icon_source}" ]]; then
+    mkdir -p "$(dirname "${document_icon_target}")"
+    cp "${document_icon_source}" "${document_icon_target}"
+else
+    echo "cargo::warning=Missing ${document_icon_source}; macOS document icons may not appear in Finder."
+fi
+
 if [[ -n "${MACOS_CERTIFICATE:-}" && -n "${MACOS_CERTIFICATE_PASSWORD:-}" && -n "${APPLE_NOTARIZATION_KEY:-}" && -n "${APPLE_NOTARIZATION_KEY_ID:-}" && -n "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then
     can_code_sign=true
 

script/danger/dangerfile.ts 🔗

@@ -6,6 +6,9 @@ prHygiene({
   rules: {
     // Don't enable this rule just yet, as it can have false positives.
     useImperativeMood: "off",
+    noConventionalCommits: {
+      bannedTypes: ["feat", "fix", "style", "refactor", "perf", "test", "chore", "build", "revert"],
+    },
   },
 });
 

script/danger/package.json 🔗

@@ -8,6 +8,6 @@
   },
   "devDependencies": {
     "danger": "13.0.4",
-    "danger-plugin-pr-hygiene": "0.6.1"
+    "danger-plugin-pr-hygiene": "0.7.1"
   }
 }

script/danger/pnpm-lock.yaml 🔗

@@ -12,8 +12,8 @@ importers:
         specifier: 13.0.4
         version: 13.0.4
       danger-plugin-pr-hygiene:
-        specifier: 0.6.1
-        version: 0.6.1
+        specifier: 0.7.1
+        version: 0.7.1
 
 packages:
 
@@ -134,8 +134,8 @@ packages:
   core-js@3.45.1:
     resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==}
 
-  danger-plugin-pr-hygiene@0.6.1:
-    resolution: {integrity: sha512-nb+iUQvirE3BlKXI1WoOND6sujyGzHar590mJm5tt4RLi65HXFaU5hqONxgDoWFujJNHYnXse9yaZdxnxEi4QA==}
+  danger-plugin-pr-hygiene@0.7.1:
+    resolution: {integrity: sha512-ll070nNaL3OeO2nooYWflPE/CRKLeq8GiH2C68u5zM3gW4gepH89GhVv0sYNNGLx4cYwa1zZ/TuiYYhC49z06Q==}
 
   danger@13.0.4:
     resolution: {integrity: sha512-IAdQ5nSJyIs4zKj6AN35ixt2B0Ce3WZUm3IFe/CMnL/Op7wV7IGg4D348U0EKNaNPP58QgXbdSk9pM+IXP1QXg==}
@@ -573,7 +573,7 @@ snapshots:
 
   core-js@3.45.1: {}
 
-  danger-plugin-pr-hygiene@0.6.1: {}
+  danger-plugin-pr-hygiene@0.7.1: {}
 
   danger@13.0.4:
     dependencies:

script/generate-action-metadata 🔗

@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+echo "Generating action metadata..."
+cargo run -p zed -- --dump-all-actions > crates/docs_preprocessor/actions.json
+
+echo "Generated crates/docs_preprocessor/actions.json with $(grep -c '"name":' crates/docs_preprocessor/actions.json) actions"

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

script/triage_watcher.jl 🔗

@@ -0,0 +1,38 @@
+## Triage Watcher v0.1
+# This is a small script to watch for new issues on the Zed repository and open them in a new browser tab interactively.
+#
+## Installing Julia
+#
+# You need Julia installed on your system:
+# curl -fsSL https://install.julialang.org | sh
+#
+## Running this script:
+# 1. It only works on Macos/Linux
+# Open a new Julia repl with `julia` inside the `zed` repo
+# 2. Paste the following code
+# 3. Whenever you close your computer, just type the Up arrow on the REPL + enter to rerun the loop again to resume
+function get_issues()
+    entries = filter(x -> occursin("state:needs triage", x), split(read(`gh issue list -L 10`, String), '\n'))
+    top = findfirst.('\t', entries) .- 1
+    [entries[i][begin:top[i]] for i in eachindex(entries)]
+end
+
+nums = get_issues();
+while true
+    new_nums = get_issues()
+    # Open each new issue in a new browser tab
+    for issue_num in setdiff(new_nums, nums)
+        url = "https://github.com/zed-industries/zed/issues/" * issue_num
+        println("\nOpening $url")
+        open_tab = `/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome $url`
+        try
+            sound_file = "/Users/mrg/Downloads/mario_coin_sound.mp3"
+            run(`afplay -v 0.02 $sound_file`)
+        finally
+        end
+        run(open_tab)
+    end
+    nums = new_nums
+    print("🧘🏼")
+    sleep(60)
+end

script/verify-macos-document-icon 🔗

@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+  cat <<'USAGE'
+Usage:
+  script/verify-macos-document-icon /path/to/Zed.app
+
+Verifies that the given macOS app bundle's Info.plist references a document icon
+named "Document" and that the corresponding icon file exists in the bundle.
+
+Specifically checks:
+  - CFBundleDocumentTypes[*].CFBundleTypeIconFile includes "Document"
+  - Contents/Resources/Document.icns exists
+
+Exit codes:
+  0 - success
+  1 - verification failed
+  2 - invalid usage / missing prerequisites
+USAGE
+}
+
+fail() {
+  echo "error: $*" >&2
+  exit 1
+}
+
+if [[ $# -ne 1 ]]; then
+  usage >&2
+  exit 2
+fi
+
+app_path="$1"
+
+if [[ ! -d "${app_path}" ]]; then
+  fail "app bundle not found: ${app_path}"
+fi
+
+info_plist="${app_path}/Contents/Info.plist"
+if [[ ! -f "${info_plist}" ]]; then
+  fail "missing Info.plist: ${info_plist}"
+fi
+
+if ! command -v plutil >/dev/null 2>&1; then
+  fail "plutil not found (required on macOS to read Info.plist)"
+fi
+
+# Convert to JSON for robust parsing. plutil outputs JSON to stdout in this mode.
+info_json="$(plutil -convert json -o - "${info_plist}")"
+
+# Check that CFBundleDocumentTypes exists and that at least one entry references "Document".
+# We use Python for JSON parsing; macOS ships with Python 3 on many setups, but not all.
+# If python3 isn't available, fall back to a simpler grep-based check.
+has_document_icon_ref="false"
+if command -v python3 >/dev/null 2>&1; then
+  has_document_icon_ref="$(python3 -c "import json,sys; d=json.load(sys.stdin); types=d.get('CFBundleDocumentTypes', []); vals=[t.get('CFBundleTypeIconFile') for t in types if isinstance(t, dict)]; print('true' if 'Document' in vals else 'false')" <<<"${info_json}")"
+else
+  # This is a best-effort fallback. It may produce false negatives if the JSON formatting differs.
+  if echo "${info_json}" | grep -q '"CFBundleTypeIconFile"[[:space:]]*:[[:space:]]*"Document"'; then
+    has_document_icon_ref="true"
+  fi
+fi
+
+if [[ "${has_document_icon_ref}" != "true" ]]; then
+  echo "Verification failed for: ${app_path}" >&2
+  echo "Expected Info.plist to reference CFBundleTypeIconFile \"Document\" in CFBundleDocumentTypes." >&2
+  echo "Tip: This bundle may be missing DocumentTypes.plist extensions or may have different icon naming." >&2
+  exit 1
+fi
+
+document_icon_path="${app_path}/Contents/Resources/Document.icns"
+if [[ ! -f "${document_icon_path}" ]]; then
+  echo "Verification failed for: ${app_path}" >&2
+  echo "Expected document icon to exist: ${document_icon_path}" >&2
+  echo "Tip: The bundle script should copy crates/zed/resources/Document.icns into Contents/Resources/Document.icns." >&2
+  exit 1
+fi
+
+echo "OK: ${app_path}"
+echo " - Info.plist references CFBundleTypeIconFile \"Document\""
+echo " - Found ${document_icon_path}"

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,162 @@
+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 run_clippy = WorkflowInput::bool("run_clippy", Some(true));
+    let run_autofix = run_autofix(&pr_number, &run_clippy);
+    let commit_changes = commit_changes(&pr_number, &run_autofix);
+    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_input(run_clippy.name, run_clippy.input()),
+        ))
+        .concurrency(
+            Concurrency::new(Expression::new(format!(
+                "${{{{ github.workflow }}}}-{pr_number}"
+            )))
+            .cancel_in_progress(true),
+        )
+        .add_job(run_autofix.name.clone(), run_autofix.job)
+        .add_job(commit_changes.name, commit_changes.job)
+}
+
+const PATCH_ARTIFACT_NAME: &str = "autofix-patch";
+const PATCH_FILE_PATH: &str = "autofix.patch";
+
+fn upload_patch_artifact() -> Step<Use> {
+    Step::new(format!("upload artifact {}", PATCH_ARTIFACT_NAME))
+        .uses(
+            "actions",
+            "upload-artifact",
+            "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5
+        )
+        .add_with(("name", PATCH_ARTIFACT_NAME))
+        .add_with(("path", PATCH_FILE_PATH))
+        .add_with(("if-no-files-found", "ignore"))
+        .add_with(("retention-days", "1"))
+}
+
+fn download_patch_artifact() -> Step<Use> {
+    named::uses(
+        "actions",
+        "download-artifact",
+        "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
+    )
+    .add_with(("name", PATCH_ARTIFACT_NAME))
+}
+
+fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJob {
+    fn checkout_pr(pr_number: &WorkflowInput) -> Step<Run> {
+        named::bash(&format!("gh pr checkout {pr_number}"))
+            .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
+    }
+
+    fn run_cargo_fmt() -> Step<Run> {
+        named::bash("cargo fmt --all")
+    }
+
+    fn run_cargo_fix() -> Step<Run> {
+        named::bash(
+            "cargo fix --workspace --release --all-targets --all-features --allow-dirty --allow-staged",
+        )
+    }
+
+    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 create_patch() -> Step<Run> {
+        named::bash(indoc::indoc! {r#"
+            if git diff --quiet; then
+                echo "No changes to commit"
+                echo "has_changes=false" >> "$GITHUB_OUTPUT"
+            else
+                git diff > autofix.patch
+                echo "has_changes=true" >> "$GITHUB_OUTPUT"
+            fi
+        "#})
+        .id("create-patch")
+    }
+
+    named::job(
+        Job::default()
+            .runs_on(runners::LINUX_DEFAULT)
+            .outputs([(
+                "has_changes".to_owned(),
+                "${{ steps.create-patch.outputs.has_changes }}".to_owned(),
+            )])
+            .add_step(steps::checkout_repo())
+            .add_step(checkout_pr(pr_number))
+            .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_cargo_fix().if_condition(Expression::new(run_clippy.to_string())))
+            .add_step(run_clippy_fix().if_condition(Expression::new(run_clippy.to_string())))
+            .add_step(create_patch())
+            .add_step(upload_patch_artifact())
+            .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)),
+    )
+}
+
+fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob {
+    fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step<Run> {
+        named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token))
+    }
+
+    fn apply_patch() -> Step<Run> {
+        named::bash("git apply autofix.patch")
+    }
+
+    fn commit_and_push(token: &StepOutput) -> Step<Run> {
+        named::bash(indoc::indoc! {r#"
+            git commit -am "Autofix"
+            git push
+        "#})
+        .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) = steps::authenticate_as_zippy();
+
+    named::job(
+        Job::default()
+            .runs_on(runners::LINUX_SMALL)
+            .needs(vec![autofix_job.name.clone()])
+            .cond(Expression::new(format!(
+                "needs.{}.outputs.has_changes == 'true'",
+                autofix_job.name
+            )))
+            .add_step(authenticate)
+            .add_step(steps::checkout_repo_with_token(&token))
+            .add_step(checkout_pr(pr_number, &token))
+            .add_step(download_patch_artifact())
+            .add_step(apply_patch())
+            .add_step(commit_and_push(&token)),
+    )
+}

tooling/xtask/src/tasks/workflows/cherry_pick.rs 🔗

@@ -3,7 +3,7 @@ use gh_workflow::*;
 use crate::tasks::workflows::{
     runners,
     steps::{self, NamedJob, named},
-    vars::{self, StepOutput, WorkflowInput},
+    vars::{StepOutput, WorkflowInput},
 };
 
 pub fn cherry_pick() -> Workflow {
@@ -29,19 +29,6 @@ fn run_cherry_pick(
     commit: &WorkflowInput,
     channel: &WorkflowInput,
 ) -> NamedJob {
-    fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
-        let step = named::uses(
-            "actions",
-            "create-github-app-token",
-            "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
-        ) // v2
-        .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 cherry_pick(
         branch: &WorkflowInput,
         commit: &WorkflowInput,
@@ -54,7 +41,7 @@ fn run_cherry_pick(
             .add_env(("GITHUB_TOKEN", token))
     }
 
-    let (authenticate, token) = authenticate_as_zippy();
+    let (authenticate, token) = steps::authenticate_as_zippy();
 
     named::job(
         Job::default()

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/release.rs 🔗

@@ -97,17 +97,20 @@ pub(crate) fn create_sentry_release() -> Step<Use> {
 }
 
 fn auto_release_preview(deps: &[&NamedJob; 1]) -> NamedJob {
+    let (authenticate, token) = steps::authenticate_as_zippy();
+
     named::job(
         dependant_job(deps)
             .runs_on(runners::LINUX_SMALL)
             .cond(Expression::new(indoc::indoc!(
                 r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
             )))
+            .add_step(authenticate)
             .add_step(
                 steps::script(
                     r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
                 )
-                .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
+                .add_env(("GITHUB_TOKEN", &token)),
             )
     )
 }

tooling/xtask/src/tasks/workflows/run_tests.rs 🔗

@@ -236,11 +236,11 @@ fn check_style() -> NamedJob {
             .add_step(steps::checkout_repo())
             .add_step(steps::cache_rust_dependencies_namespace())
             .add_step(steps::setup_pnpm())
-            .add_step(steps::script("./script/prettier"))
+            .add_step(steps::prettier())
+            .add_step(steps::cargo_fmt())
             .add_step(steps::script("./script/check-todos"))
             .add_step(steps::script("./script/check-keymaps"))
-            .add_step(check_for_typos())
-            .add_step(steps::cargo_fmt()),
+            .add_step(check_for_typos()),
     )
 }
 
@@ -448,6 +448,7 @@ fn check_docs() -> NamedJob {
                 lychee_link_check("./docs/src/**/*"), // check markdown links
             )
             .map(steps::install_linux_dependencies)
+            .add_step(steps::script("./script/generate-action-metadata"))
             .add_step(install_mdbook())
             .add_step(build_docs())
             .add_step(

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",
@@ -44,6 +54,10 @@ pub fn setup_sentry() -> Step<Use> {
     .add_with(("token", vars::SENTRY_AUTH_TOKEN))
 }
 
+pub fn prettier() -> Step<Run> {
+    named::bash("./script/prettier")
+}
+
 pub fn cargo_fmt() -> Step<Run> {
     named::bash("cargo fmt --all -- --check")
 }
@@ -334,3 +348,16 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step<Run> {
         "git fetch origin {ref_name} && git checkout {ref_name}"
     ))
 }
+
+pub 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)
+}